Components
On this page
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-htmlPut 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:
- the close button on a dismissible alert
- closing a menu on an outside click or Escape
- the theme toggle
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.
| Group | Components |
|---|---|
| Layout | container, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio |
| Typography | text, heading, link, code, inlineCode, kbd, visuallyHidden, prose |
| Inputs | button, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup |
| Data display | avatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure |
| Feedback | alert, progress, skeleton, toasts, empty |
| Navigation | breadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle |
| Overlays | modal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible |
| Sections | hero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup |
| Styling | styles, 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.