UI components
@ultimat3/ui — SolidJS components on the semantic tokens in Theming. SCSS modules, logical properties, no Tailwind, no CSS-in-JS.
As of 2026-09. Stable API — semver from here (Upgrading).
The catalog is generated — read it, don't ask
packages/ui/CATALOG.md documents every component with every prop, every type, and the token scales, parsed straight out of packages/ui/src/components/*.tsx. It ships inside the npm tarball, so it is on disk at node_modules/@ultimat3/ui/CATALOG.md in any app.
How many there are is a question for the file, never for this page. It carried 52 for two majors:
head -7 node_modules/@ultimat3/ui/CATALOG.md # the count and every name, on one line| Concern | Answer |
|---|---|
| Regenerate | bun run --filter @ultimat3/ui catalog |
| Drift | catalog.test.ts compares the committed file byte-for-byte against a fresh build; a prop change with no regen is a red x verify |
| Hand edits | refused by the same test — the file carries a GENERATED banner |
This page does not restate the catalog. It covers the four page composites, because they are the ones an agent picks wrong.
Which component to reach for is not the same question as how it must behave. The four-state decision, skeleton-versus-spinner, response tiers, optimistic writes, forms, motion, focus, toasts and images are Interface rules — one page, every rule marked either with the guard, type or gate step that refuses it, or as judgement nothing checks.
Form errors are translated on FormIssue.rule. formBinding's messageFor receives the same
rule and params (string.min, { min: 3 }) from the local parse and from the server's
X_INPUT_INVALID, whose problem document carries the structured issues list —
messageFor: (issue) => t(`form.${issue.rule}`, issue.params). See I18n.
The four page composites
A screen that hand-rolls a header grid is the bug these exist to prevent. All four hold no state — the route owns it.
AppShell
The frame: skip link, landmarks, optional sidebar. Off-canvas navigation is Drawer, not a prop here.
| Prop | Type | Default |
|---|---|---|
children | JSX.Element | required |
header | JSX.Element | omitted → no <header> |
sidebar | JSX.Element | omitted → no <nav>, and the grid collapses to one column |
footer | JSX.Element | omitted → no <footer> |
bottomNav | JSX.Element — a <BottomNav> | omitted → none; given, shown below md only, last, sticky to the bottom edge and in flow, so it reserves its own height |
sidebarLabel | string | t('ui.navigation') |
skipLabel | string | t('ui.skip') |
sidebarWidth | string | '16rem' |
stickyHeader | boolean | sticky unless explicitly false — undefined is sticky |
class | string | — |
Landmark order in the DOM: banner → navigation → main → contentinfo → the bottom bar's navigation. <main> is unconditional.
The sidebar's content is NavList and the bottom bar's is BottomNav (five links at most); both mark the current page by one rule, currentOf(items, href) — the longest covering href wins, so /runs/7 marks /runs. The bar's padding clears the home indicator through t.safe-area-padding(block-end) (viewport-fit=cover on the page).
The skip link is real, not decorative: <main> carries tabindex={-1} so focus actually moves there, and the link's href and the target's id are derived from one shellIds() call, so they cannot drift apart. It sits off-screen at translate: 0 -400% and slides in on :focus-visible.
PageHeader
| Prop | Type | Default |
|---|---|---|
title | string | required |
description | string | omitted |
actions | JSX.Element | omitted |
breadcrumbs | readonly BreadcrumbItem[] | omitted → no Breadcrumb |
level | 1 | 2 | 3 | 4 | 5 | 6 | 1 |
media | JSX.Element | omitted |
class | string | — |
Section
| Prop | Type | Default |
|---|---|---|
children | JSX.Element | required |
title | string | omitted → an unlabelled grouping, no aria-labelledby |
description | string | omitted |
actions | JSX.Element | omitted |
level | 1 … 6 | 2 |
as | 'section' | 'article' | 'aside' | 'section' |
class | string | — |
A title wires aria-labelledby to the heading's generated id automatically. The head block disappears entirely when both title and actions are absent, so an untitled Section costs no markup.
A level outside 1–6 throws X_UI_INVALID_VALUE rather than emitting an <h7>.
Toolbar
| Prop | Type | Default |
|---|---|---|
children | JSX.Element | required |
actions | JSX.Element | omitted → no trailing group |
label | string | required |
surface | boolean | falsey |
class | string | — |
label is required because role="toolbar" with no accessible name is an unnamed group. Arrow keys move through the focusable children via a roving tabindex, horizontal, direction-aware, not looping.
A control that navigates is a Link
Button has no href. A link that looks like a button is <Link appearance="button">; a pager on a server-rendered list is <Pagination hrefFor>. Neither needs an island.
<Link appearance="button" variant="secondary" size="sm" href="/posts/new">{t('posts.new')}</Link>
// `page` is a framework `Page` (a query's `.page()`): `nextCursor` is `string | null`, handed as is.
const list = listUrl(props.url, { sorts: ['title', 'createdAt'] });
<Pagination nextCursor={page.nextCursor} hrefFor={list.hrefFor} firstHref={list.firstHref} />listUrl(url, { sorts, ns?, limit? }) reads a list's state out of its URL — sort, cursor, limit, q, never throwing: an unknown sort or a hostile limit is the default — and writes it back as hrefs: hrefFor (the cursor, everything else kept), sortHrefFor and href(change) (the change, the cursor dropped), firstHref (undefined on the first page). Param names are sort, dir, after, limit, q; ns: 'runs' prefixes them all (runs.after) for two lists on a page, and then any change but a cursor drops every cursor on the page, since a cursor is bound to its filter and sort (X_CURSOR_INVALID). The same object feeds a DataTable's hrefFor, sortHrefFor and firstHref.
| Component | Mode | Props | Renders |
|---|---|---|---|
Link | text (default) | underline, tone: 'accent' | 'inherit' | <a> with the link classes |
Link | appearance="button" | variant, tone (any Tone), size, fullWidth, iconStart, iconEnd | <a> with Button's classes — one set of button styles, two elements |
Pagination | callbacks | onCursor, or page + totalPages + onPage; onFirst | <button>s; needs an island |
Pagination | links | hrefFor(cursor, direction); firstHref | <a rel="prev"> / <a rel="next">; Next with no cursor is a disabled button |
DataTable | callbacks / links | onCursor + onFirst, or hrefFor(cursor, direction) + firstHref | its pager, in that mode — the props are handed straight to Pagination |
A cursor that is null or absent is one state: no page that way. Previous is drawn only when a prevCursor is given or the pager is numbered — a keyset Page has no way back, so its pager draws Next and, given firstHref / onFirst, First. Next holds the trailing edge either way.
One mode per use, and the type holds it: hrefFor beside onCursor, onPage, page or totalPages does not compile, and neither does underline on a button-link or variant on a text link. Link mode pages by cursor only. Link also takes rel?: 'next' | 'prev', which joins the noopener noreferrer an external link always carries.
Rules an agent must not break
- SCSS modules only.
Foo.tsx+Foo.module.scss, always paired. The only inlinestyleallowed is a CSS custom property. - Logical properties.
margin-inline,inset-inline-start,text-align: start. Aleft/rightin a stylesheet is a bug. - No hardcoded strings. Label props, or the built-in keys in
src/i18n-keys.ts→ I18n. - No raw colours.
t.role('<role>')orvar(--color-*)→ Theming. - No prop spreading. Components declare explicit props; there is no
{...rest}. solid-jsis a type-only import inside the package — reactive access goes through one registered adapter, so the design system does not pin a renderer version.- Formatting logic lives in a pure
*-view.tsbeside the component (money-view.ts,date-time-view.ts), testable with no renderer. - Choosing from a list is three controls, never one with modes.
Select(short, closed),SearchableSelect(long, closed, value ≠ label — no-script it is the native<select>),Combobox(free text). There is no<Select searchable>: everySelectisland would carry the listbox. - A destructive question is
ConfirmDialog, neverconfirm(. Cancel is focused, the backdrop never dismisses,actionposts with scripting off,onConfirmstays open on a refusal; the confirm label is a verb with no default.guards/native-confirm.tsrefusesconfirm(in app code. - Shortcuts are one table per island, merged per page.
useHotkeys([{ keys, label, run }]);ShortcutSheetlists the merged table on?anduseCommandPaletteaddsmod+kto it — so the legend cannot drift from the bindings. The framework reserves no key. - The bottom sheet is
Drawer side="block-end" grabber. There is noSheet. Link tabs areTabswithhrefFor— a<nav>, neverrole="tab". - Charts: options on the shipped charts, not new charts. Signed bars and sparklines,
nullgaps,atfor true x,references,stackedareas,toneper series,Gaugethresholds/target,Metertarget. The two new names areHeatStripandLiveIndicator, which no chart's shape covers. A stat grid isGrid minColumn. - Which list, when. Records with columns:
DataTable. Thousands of one-shape rows:VirtualList(no script, every row renders; with script, under 100 mounted). Things inside things:Tree—hrefForis<details>and links at 0 B,onSelectis the island'srole="tree". What happened:Timeline(+InfiniteScroll+RelativeTimeis the activity feed; there is no feed component). Facts about one thing:DescriptionList, never a two-columnTable. - Dates are the native input first.
DatePicker/DateRangePickerare<input type="date">with aCalendarpopover only on a fine pointer with script. A date is aPlainDate; a window (TimeRangePicker,timeRangeUrl) is instants and takes a REQUIREDtimeZone. Presets are the app's.Slideris<input type="range">; a segmented control isRadio appearance="segmented"; there is no number stepper. - Reordering is
SortableListandBoard, a split isSplitPane— one Pointer Events gesture, never HTML5 drag. A drop callsonMoveonce and is a request: the row holds until it settles, a rejection restores it and says so.canDrop(item, group)is the one seam for a WIP limit or a status machine. Withaction, each row is a real<form>(Up/Down, or a targetselectper card). The handle alone hastouch-action: none, so a phone still scrolls from a row's body.SplitPaneis one column belowmd;onResize+valueis how a split persists — storage is the app's. - What a model wrote is
Markdown; code isCodeBlock; neverinnerHTML. Raw HTML prints as text, links only http/https/mailto/relative, a remote image prints its alt as a link unlessresolveImagevouches for it,baseLeveldefaults to 2. Fences go throughCodeBlock, highlighted only by thelanguagesthe caller passes (allLanguageson a server page; one named language in an island). Structured payloads areJsonView, changesDiffView(two texts or a unified patch), structured logsLogViewer, terminal outputTerminalView(SGR colours as roles). A rich-text editor isTextareaplus aMarkdownpreview.
Error codes
| Code | Means |
|---|---|
X_TOKEN_UNKNOWN | a design token role that does not exist; cause lists the ones that do |
X_THEME_INVALID | a theme that is not light or dark |
X_UI_RUNTIME_MISSING | a host capability the component needs is absent (IntersectionObserver, localStorage) |
X_UI_INVALID_VALUE | a formatting component got an unrenderable value — NaN money, an invalid date, a heading level off the scale |
Full rows: Error codes.