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 <link> to one file, which the browser caches across pages; sitelo’s plugin serves it in dev and writes it into the build, under a content-hashed name you can serve immutable. { inline: true } returns a <style> holding the whole sheet instead — around 11 kB over the wire, no extra request, and nothing that can go missing from dist/:

import { styles } from 'sitelo/ui'

head(
  // the whole sheet in the page — no request at all
  styles({ inline: true }),
)

The sheet covers every component in the library, and a site uses a handful. pruneCss: true in sitelo.config.js writes only the rules the build can match: the plugin reads the su- classes off the pages it has just written — and off the scripts beside them, for the classes a toast or a step adds later — and drops every rule whose selector names one that nothing carries. Decided from the output rather than the imports, so button({ variant: 'soft' }) keeps .su-btn--soft and a plain button() does not. A linked sheet is pruned against the whole site and gets a new hash, with every <link> rewritten to match; an inlined one is pruned to its own page. A dozen components come to 2–3 kB gzipped. Dev serves the whole sheet; markup the build never sees, such as a server island’s, needs its classes named in keep:

export default {
  pruneCss: true,
  // or name classes the build never sees; `*` for a prefix
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

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()

Or change the whole look at once: styles({ preset: 'neumorphism' }) links a preset’s sheet straight after the core one, every component follows it, and theme() still works on top. It runs live on the Neumorphism page.

JavaScript, and how little of it there is

Most components need none. The modal and the drawer are <dialog> elements a button opens with a command, so the browser handles the backdrop, focus, Escape and a click outside. The accordion is <details name>. Menus are <details>. Tooltips are CSS. Tabs whose panels swap in place are a radio group: each tab is a <label>, and the panel that follows the checked radio is the one CSS shows.

Three 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))">
  <svg class="su-icon" …></svg>
</button>

sitelo serves those modules from /su/ (or /repo/su/ with --base /repo/) 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: 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 <dialog> and its trigger is any button whose commandfor points at its id:

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

button({ commandfor: 'confirm', command: 'show-modal' }, '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, on a radio group and CSS.
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, 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, stylesUrl, 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.

Extras

A second entry point, sitelo/ui-extras, holds the components that are not for everyone — textures and effects, a film grain to start. Each brings a stylesheet of its own, grainStyles() beside styles(), so a page links only what it uses, and sitelo/ui-extras/client carries their page-side calls. They are catalogued at sitelo UI extras.