Components

sitelo-ui is a component library for sitelo. Every component is a function that returns a string of HTML, so it nests straight into a javascript-to-html tree with nothing in between — no compiler, no runtime, no hydration. What you build is what lands in dist/.

It ships with sitelo, under the sitelo/ui entry point.

Quick start

npm install sitelo javascript-to-html

Put styles() in the head and call components in the body. That is the whole setup:

import { body, head, html, meta, title } from 'javascript-to-html'
import { styles, container, stack, heading, text, button } from 'sitelo/ui'

export default () => html({ lang: 'en' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('My site'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, 'Hello'),
        text({ variant: 'lead' }, 'A page built from components.'),
        button({ href: '/docs' }, 'Read the docs'),
      ),
    ),
  ),
)

Component names deliberately match what they render, which means a few of them — button, input, table, link, code, select, progress — collide with javascript-to-html’s element functions. Import the library as a namespace when you need both:

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: 'Routing', subtitle: 'File based' }),
  ui.cardBody(ui.text('src/about.ht.js becomes /about.')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, 'Read more')),
)

The calling convention

Every component takes an optional props object followed by children, exactly like a javascript-to-html element. Props the component understands are consumed by name; everything else falls through to the rendered element as an attribute, so id, data-*, aria-* and event attributes work without the library having to list them:

button({ id: 'save', 'data-analytics': 'save-click', onclick: 'save()' }, 'Save')
// <button type="button" id="save" data-analytics="save-click" onclick="save()" class="su-btn …">

A prop the component does not recognise the value of — variant: 'nonsense' — falls back to the default rather than throwing. A cosmetic typo should not fail a build.

Styling

styles() returns a <style> element holding the whole sheet, minified. It is around 7 kB over the wire and cannot go missing from dist/, which is why it is the default. If you would rather link it once and let the browser cache it across pages, import the CSS from a bundled entry file instead and Vite will emit it:

// src/main.js — bundled by Vite, cached across pages
import 'sitelo/ui/styles.css'

Use one or the other, not both.

Theming

Everything is driven by CSS custom properties on :root — five palettes, a spacing scale, radii, type and shadows. theme() writes overrides for them, and takes camelCase names (radiusMd--su-radius-md), palette objects, or literal custom properties:

import { styles, theme } from 'sitelo/ui'

head(
  styles(),
  // After styles(), so these win.
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

Dark mode resolves from prefers-color-scheme on its own. Setting data-theme or data-su-theme to light or dark on any ancestor overrides it — which is what themeToggle() does:

import { styles, themeScript, themeToggle } from 'sitelo/ui'

head(
  themeScript(), // applies the stored choice before the first paint
  styles(),
)

// …anywhere in the body
themeToggle()

JavaScript, and how little of it there is

Most components need none. The modal and the drawer are popover elements, so the browser handles opening, the backdrop, click-outside and Escape. The accordion is <details name>. Menus are <details>. Tooltips are CSS.

Four things do want a script, and each one goes and gets its own:

There is nothing to add to your entry file — the import is the event attribute:

<!-- rendered by alert({ dismissible: true }) -->
<button class="su-alert-dismiss"
        onclick="import('/su/alert.js').then(m=>m.dismiss(this))">
  &times;
</button>

sitelo serves those modules from /su/ while you develop, and copies the ones your pages actually reference into the build. Each is well under a kilobyte, none is fetched before the first interaction, and every component renders correctly until it is: panel tabs show the panel the server marked active, menus open and close on their own, the dismiss button does nothing.

The exception is toast(), because nothing on the page triggers it for you:

// src/main.js
import { toast } from 'sitelo/ui/client'

Examples

Forms wire their own labels, ids, help text and error messages:

import { card, cardBody, cardFooter, button, stack, textField, selectField } from 'sitelo/ui'

card(
  cardBody(
    stack({ gap: 'md' },
      textField({ label: 'Email', name: 'email', type: 'email', help: 'Never shared.' }),
      textField({ label: 'Site', name: 'site', startAdornment: 'https://', error: 'Not a URL.' }),
      selectField({ label: 'Plan', name: 'plan', options: ['Free', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, 'Save')),
)

A modal is a popover and its trigger is any button pointing at its id:

import { button, modal } from 'sitelo/ui'

button({ popovertarget: 'confirm' }, 'Delete…')

modal({
  id: 'confirm',
  title: 'Delete this page?',
  footer: button({ color: 'danger' }, 'Delete'),
}, 'This cannot be undone.')

Tabs come in two shapes — links, or panels:

// Link tabs: one page per tab, no script at all.
tabs({ items: [
  { label: 'Docs', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// Panel tabs: swap in place, and fetch the code to do it themselves.
tabs({ value: 'use', items: [
  { id: 'install', label: 'Install', panel: code('npm install sitelo') },
  { id: 'use', label: 'Use', panel: code("import * as ui from 'sitelo/ui'") },
] })

Tables take columns and rows, with a render function wherever a cell needs more than a value:

table({
  striped: true,
  columns: [
    { key: 'page', header: 'Page' },
    { key: 'size', header: 'Size', align: 'end' },
    { header: 'Status', render: (row) => chip({ color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : 'failed') },
  ],
  rows: pages,
})

The examples/ui directory in the repository renders every component on one page — it is the fastest way to see the whole set.

Component reference

Every export, by group. Props are typed: sitelo/ui ships .d.ts files, so an editor completes variant, color and size for you in JavaScript as well as TypeScript.

GroupComponents
Layoutcontainer, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
Typographytext, heading, link, code, inlineCode, kbd, visuallyHidden, prose
Inputsbutton, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup
Data displayavatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
Feedbackalert, progress, spinner, skeleton, toasts, empty
Navigationbreadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
Overlaysmodal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
Sectionshero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
Stylingstyles, stylesheet, theme, themeScript

Two names differ from what you might expect: the switch is toggle, because switch is a reserved word and cannot be an import binding; and the styled anchor is exported as both link and textLink, so it can sit alongside javascript-to-html’s link. table, input, select and progress have the same escape hatch: dataTable, textInput, selectField, progressBar.