Usage — when to reach for what
docs/reference.md is the catalog (every class, generated). This is
the decision guide: the rules a kitchen-sink demo can't tell you. It is
hand-written and stable — treated as contract like
theming.md, not auto-generated.
The one principle everything below follows:
Color is rationed. Structure carries meaning. Reach for layout, type weight, and the hairline before reaching for a hue. The accent is a spotlight, not a paint bucket.
Density: the unset default and its two presets
data-density has two presets over an unset middle default. There is no
data-density="default" value — unset is the design target; you opt
into a preset on <html> or any subtree:
| Value | Use for |
|---|---|
| (unset) | general app & content — the design target |
compact |
data-dense admin: tables, dashboards, toolbars |
comfortable |
marketing / landing / reading-first pages |
Scope it, don't globalize blindly: a dashboard with one marketing-style
hero can set compact on <html> and comfortable on the hero section.
data-densityis the global preset; per-component density verbs differ by family. Some components carry their own local density modifier, and the verb is not uniform: it's--denseonui-table/ui-dotgridbut--compactonui-prose/ui-legend/ui-report. Soui-prose--denseandui-table--compactboth no-op. When in doubt, reach for the globaldata-densitypreset; use the local modifier only where it exists (check the base'smodifiersin classes.json).
Badge vs chip vs status dot
All three are small. They are not interchangeable:
| Use | When |
|---|---|
| status dot | a single piece of state on something else (row online, build ok). Smallest possible signal; pair with text for a11y, never color-only. |
| badge | a label classifying the thing it sits on (count, tone, "BETA"). Static, not actionable. ui.badge({ tone }). |
| chip | a discrete, often removable/selectable token the user manipulated (a filter, a tag input value). Interactive affordance implied. |
| tag | a static keyword/category label (ui.tag), like a badge but reads as a non-interactive tag. Use chip for anything the user selects/removes; tag for a fixed label. |
Rule of thumb: state → dot, classification → badge, user-controlled value
→ chip, fixed keyword → tag. (ui-property is a workbench primitive — a
key/value spec row scoped to @ponchia/ui/css/workbench.css, not a general
content label; don't reach for it to tag prose.)
Tone vocabulary varies by family — by design. Colour is rationed, so not
every component carries every tone. The status tones
(--success/--warning/--danger) plus --info ride the status families —
ui-alert, ui-toast, ui-meter, ui-dot, and ui-badge all carry --info.
The neutral --muted is not a status tone: it rides several neutral
bases (ui-badge, ui-num, ui-eyebrow, ui-mark, ui-annotation,
ui-connector, ui-crosshair) but is deliberately absent from the status
families ui-dot/ui-alert/ui-toast/ui-meter. Because the CSS is a
plain class list, a hand-written unknown modifier (e.g. ui-dot--muted,
ui-meter--muted) silently no-ops — CSS can't warn. The ui.* builders are
safer on both ends: TypeScript rejects an out-of-set tone at author time, and at
runtime they console.warn (then drop the tone) so a JS caller sees the mistake
rather than shipping a bare, untoned element. The authoritative
per-component tone list is each base's modifiers array in
@ponchia/ui/classes.json — read it rather than
extrapolating a tone you saw on one component onto another.
Tone is a colour channel, so it collapses under Windows High-Contrast Mode.
A ui-badge--success and a ui-badge--danger render identically once HCM
remaps colours (the tinted background + tone border are flattened to one system
colour). Status dots and the meter fill re-assert a distinct system colour, but
the badge/chip/tag tints cannot carry five differentiable tones — so treat badge
tone as decorative emphasis and make sure the badge's text label carries the
status word ("Failed", not a bare red pill). Same WCAG 1.4.1 reasoning the dots
follow.
Numbers: ui-num vs the table state classes
- Inside
.ui-table, a numeric cell is.is-num(+.is-pos/.is-negfor P&L tone). These are table-localis-*hooks, not inclsby design. - Anywhere else (a card, a stat, inline figures) use the
ui-numprimitive —ui.num({ tone }). Same tabular/aligned/tone intent, freed from the table. Do not hand-roll right-align +text-green; that's the duplicationui-numexists to kill.
The positive/negative vocabulary is spelled three ways — match the mechanism to the host, they are not interchangeable:
| Where | Positive / negative | Kind |
|---|---|---|
ui-num primitive (anywhere) |
ui-num--pos / ui-num--neg (ui.num({ tone: 'pos' })) |
modifier |
Inside .ui-table |
is-pos / is-neg |
state hook (table-scoped) |
ui-delta trend |
ui-delta--up / ui-delta--down (ui.delta({ dir })) |
direction |
They share the same tone tokens but don't cross over: ui-num is-pos
no-ops (the is-pos rule is scoped to table cells), and ui-delta--pos doesn't
exist. Use the modifier on ui-num, the is-* state inside a table, and
--up/--down on ui-delta (which also flips with ui.delta({ invert }) when
up is the bad direction — latency, error rate, cost).
Prose vs primitives, and prose inside a card
ui-prosestyles raw, unclassed semantic HTML (MDX / CMS / LLM output). Use it for body content you don't control the markup of.- Do not wrap app UI in
ui-proseto "get nice spacing" — compose primitives instead; prose deliberately restyles bare<h2>,<table>,<a>and will fight your components. - Prose inside a card: put
ui-proseon an inner wrapper, not on.ui-carditself, so card padding/border stays the card's and prose rhythm stays the content's. One responsibility per element.
Centred width: ui-center vs ui-container
Both cap a centred column, but they are different primitives with different box models — pick by intent:
ui-center— a reading measure.--center-maxis the inner content width (content-box); the--center-gutterpadding adds outside it. Use it to hold prose/body to a comfortable line length.ui-container— a page frame.--container(/--container-narrow/--container-wide) is the total max width (border-box). Use it as the outer wrapper that aligns a page's sections to a shared edge.
Rule of thumb: measure of text → ui-center; page-level frame → ui-container.
Don't nest one inside the other expecting the caps to compose — they measure
different boxes.
Container queries: ui-cq
ui-cq is the one primitive that changes how other primitives respond. Add it
to a wrapper and its descendants adapt to that box's inline size, not the
viewport — so the same ui-grid / ui-statgrid / ui-app-metrics collapses to
one column inside a slim panel even when the window is wide (island-safe; it
nests). Two thresholds are built in: ui-grid drops to a single column at
34rem and ui-statgrid/ui-app-metrics at 30rem, measured on the ui-cq
box. Note rem in a container query resolves against the root font size, not
a fixed device pixel size. At the default 16px root these are 544px and
480px; they follow the browser text-size preference. And be aware ui-grid already collapses on its own via an
intrinsic auto-fit minmax, so ui-cq barely changes it — the primitive that
genuinely needs ui-cq to collapse by container (not viewport) is
ui-statgrid/ui-app-metrics. The container is named bronto (hardcoded — there
is no --cq-name knob; an author-set one is ignored), so an outer query never
accidentally matches an inner grid. ui-cq is inert until applied, so adding it
never shifts an existing layout. Reach for it whenever a layout must respond to
its container (a resizable pane, a sidebar widget, an embedded card) rather than
the page.
Static reports
Use the opt-in @ponchia/ui/css/report.css layer for static, PDF-first
reports. A report composes ui-report + existing primitives: ui-statgrid
for KPIs, ui-alert for persistent notices, ui-table for evidence,
ui-timeline for events, ui-meter for measured values, and ui-prose only
for narrative body content you do not fully control.
Do not turn every report block into a card. Use ui-report__summary,
ui-report__finding, and ui-report__evidence for document structure; use
ui-card only when the block is genuinely a repeated card item. bronto ships
no chart component: for a chart, theme Vega-Lite (@ponchia/ui/vega, see
vega.md) or hand-author a token-themed inline SVG painted from the
data-viz palette tokens. Always wrap it in a ui-report__figure with a caption,
a .ui-legend key, and fallback data. Full LLM/static report cookbook:
reporting.md.
A tool: @ponchia/ui/css/tool.css instead of the default bundle
An application that draws on its own surface (a canvas, an editor, a
workbench) renders none of the site and app chrome: the theme toggle, the
content-site shell, data tables and the admin service shell. Import
@ponchia/ui/css/tool.css instead of @ponchia/ui. It is the default
bundle in the same cascade order without the navigation, site, table and
app leaves. Everything else, including the dot-matrix glyphs, motion and the
feedback, overlay and disclosure primitives, is unchanged. Add one of the four
back as its own leaf (@ponchia/ui/css/table.css) if a screen needs it.
Buttons: variant and size
- primary is the bare
ui-button. There is no--primaryand no--accent: the unmodified class already paints the accent fill, and the variants below all step down from it. Aim for one per view. Consumers reach for a--primaryor--accentmodifier often enough that it is worth stating plainly: neither exists. Writing one is harmless and invisible — the unknown class does nothing and the button still looks right, which is why the mistake survives review.bronto-ui-checkis what catches it. - ghost — secondary actions; the default for "another button here".
- subtle — tertiary / low-stakes (toolbar, inline).
- danger — destructive confirmation.
- Size: default everywhere;
--smfor dense tooling (toolbars, pagination, table row actions),--lgfor a hero CTA only,--densewhen a bar's height is the constraint (a pane title bar).--denselowers only the visual floor — coarse pointers still get the full--tap-target, so a control you shrink for a mouse is never shrunk for a finger. - Loading is not a class: set
aria-busy="true"(+disabled); the spinner is CSS. This is the ARIA-driven contract — see reference.md → "Composition & state".
Rows: ui-row vs table vs menu item
Three shapes look alike and are not interchangeable:
ui-tablewhen the data has columns and a header. A table promises that the third cell means the same thing on every line.ui-menu__itemwhen the list is a menu: it dismisses on choice, and it is reached throughui-menu-host.ui-rowfor everything else — a search result, a file in an explorer, an outline entry, a backlink, a commit. A full-width clickable line that persists.
<button class="ui-row ui-row--ruled" type="button" aria-current="true">
<span class="ui-row__mark" aria-hidden="true">◆</span>
<span class="ui-row__title">apps/server/src/collab/room.ts</span>
<span class="ui-row__meta">4m</span>
</button>
The one rule worth knowing: __title is what truncates. It takes the slack
and gives it back first; __meta never shrinks, because a half-rendered number
is worse than no number.
Pick the right attribute, and it is probably not aria-selected. That one
is only valid on a row whose role accepts it — option inside a listbox, or
row / tab / gridcell / treeitem. On a bare <button> it is invalid ARIA
and axe rates it critical; this project shipped that mistake in its own demo
and the a11y gate caught it before release.
aria-current="true"— the row is the current one. The common case, and valid on any element.aria-selected="true"— only when the row really is anoptionin alistbox, or another role that accepts it..is-selected— when neither fits.
All three paint the same, so the visual state cannot disagree with the announced
one. Rows carrying a severity should use ui-severity-row (css/state.css)
instead, which adds the tone gutter.
ui-menu__item composes ui-row, which is why they cannot drift.
Empty state vs invite
Both use ui-empty-state, and the slots are the same three parts — a quiet
__glyph, one sentence of full ink in __lead, a quieter __hint. What
differs is the job:
- Plain
ui-empty-statereports absence: a dashed card saying this region has no rows today. Use it for a list, a table, a results pane. --inviteoffers the next action: no dashed box (there is nothing to outline — the surface itself is what you are being invited into) and it centres in whatever block space it is given. Use it for a surface the user is meant to fill.
<div class="ui-empty-state ui-empty-state--invite">
<span class="ui-empty-state__glyph" aria-hidden="true">+</span>
<p class="ui-empty-state__lead">Nothing pinned yet</p>
<p class="ui-empty-state__hint">Drop a file here, or press ⌘K</p>
</div>
Without the slots, every empty surface in an app re-invents these three parts
under a different name and they drift — one has a glyph, the next has two
sentences at the same weight, a third is a bare <p>.
Small bodies: body state and band
A node, panel or card body of 200–400px has four things to say besides its content: nothing is here, it is loading, it failed, it is out of date. A page-level empty state or a boxed alert spends a third of such a body on its frame, so a small body uses two compact shapes:
ui-body-statereplaces the content. It fills the body and centres one short sentence, with an optionalui-empty-state__hintor an action. Useui.bodyState()for empty and loading, the plain state (putaria-busy="true"on a loading region, and aui-dotspinnerorui-skeletoninside if you like);ui.bodyState({ state: 'error' | 'stale' })leads with a tone dot.ui-alert--bandsits above content the body still shows: a full-bleed line of small type, the tone as a tint. Useui.alert({ tone, band: true })as the body's first child.
| State | Content gone | Content still shown |
|---|---|---|
| empty | ui.bodyState() |
— |
| loading | ui.bodyState() + aria-busy |
ui-skeleton rows in place |
| error | ui.bodyState({ state: 'error' }) |
ui.alert({ tone: 'danger', band: true }) |
| stale | ui.bodyState({ state: 'stale' }) |
ui.alert({ tone: 'warning', band: true }) |
<div class="node-body ui-cq">
<p class="ui-alert ui-alert--warning ui-alert--band" role="status">
Last synced 2 h ago
</p>
…the content, still readable…
</div>
<div class="node-body ui-cq">
<div class="ui-body-state ui-body-state--error" role="alert">
<p>Could not load the run</p>
<button class="ui-button ui-button--subtle ui-button--dense" type="button">Retry</button>
</div>
</div>
Make the body a ui-cq container: below 15rem the body state tightens to the
smallest type and padding. The body itself should be a flex column or have a
height, so the state can fill it.
Link vs link--cta
Plain ui-link for in-flow links. ui-link--cta is the accent
action link with an arrow — a navigational
call to action, not a substitute for a button (no form submit, no
destructive action).
Feedback: alert vs toast vs tooltip
| Surface | Lifetime / trigger |
|---|---|
| alert / callout | persistent, in-flow, part of the page (form errors, page-level notice). |
| toast | transient, out-of-flow, system-initiated. Danger toasts route to an assertive live region; everything else polite. |
| tooltip | supplemental, hover/focus, never essential info (it's not announced reliably; don't hide required content in it). |
For dismissible in-flow alerts, mark the host with data-bronto-dismissible,
put data-bronto-dismiss on the close button, and wire dismissible() from
@ponchia/ui/behaviors. With no attribute value, the button removes the nearest
dismissible host; with a selector value, it removes the nearest matching
ancestor and dispatches a cancelable bronto:dismiss event first.
The CSS ui-tooltip is hover/focus-only and CSS can't wire it to assistive
tech for you — associate the bubble with its trigger yourself, or it conveys
nothing to a screen reader: give .ui-tooltip__bubble an id, point the
trigger's aria-describedby at it, and keep the bubble role="tooltip". For a
tooltip that must stay visible near a viewport edge or inside a scroll
container, use initPopover (a real focus-managed panel) instead.
Meter vs progress
Both are a thin horizontal bar; they mean different things.
ui-progress— task progress: how far an operation has run. Can be indeterminate (ui-progress--indeterminate). The fill is always accent.ui-meter— a measured static value: coverage, disk, capacity, a KPI against a target. Never indeterminate. Tone the fill by threshold (ui.meter({ tone })→ accent/success/warning/danger/info); the unset default is neutral. Drive the width with the shared--valueknob — a unitless number 0–100 (style="--value: 72", not72%: a%is invalid against the registered<number>type and the fill drops to empty). The class string paints a 0-width, unannounced bar on its own, so set the value and its ARIA together withattrs.meter(72)(orattrs.progress) from@ponchia/ui/classes— it returnsrole="meter"+aria-valuenow/min/max+ the--valuestyle, normalized to your{ min, max }. Spread it:<div class={ui.meter({ tone })} {...attrs.meter(72)}>.Indeterminate progress is the one exception: call
attrs.progress()with no argument. ARIA requiresaria-valuenowbe omitted for an indeterminate bar — emitting0would announce "0%", indistinguishable from a real stalled-at- zero bar — so the helper returns justrole="progressbar"+aria-busy="true"(noaria-valuenow, no--value). Pair it with the class:<div class={ui.progress({ indeterminate: true })} {...attrs.progress()}>. The segmented.ui-dotbaruses the same accessibility contract throughattrs.dotbar(value)orattrs.dotbar()for the indeterminate sweep.
Rule of thumb: something is happening → progress; something measures this much → meter.
Steps, timeline, kbd, input icons
ui-steps— a stepper for a multi-step flow. Use an<ol>. State is ARIA-driven (the framework rule): the active step isaria-current="step"(no class); completed steps takeui-steps__item--done. Markers are auto-numbered by CSS counter.--doneis a visual state only — it isn't announced, so if "completed" must reach AT, add visually-hidden text (e.g.<span class="ui-visually-hidden">completed</span>) or anaria-labelon the step.ui-timeline— a vertical event list on a hairline spine (<ol>ofui-timeline__item, optionalui-timeline__time).aria-currenton an item marks the live/most-recent event.ui-kbd— an inline keyboard-key glyph. Wrap a<kbd>; for a shortcut, use one per key (<kbd>⌘</kbd> <kbd>K</kbd>).ui-input-icon— a leading (or--endtrailing) icon inside one control. This is distinct fromui-input-group, whose addon sits adjacent to the control. Wrap the input; the icon is decorative (aria-hidden) and the input keeps its full width. Don't hand-roll an absolute overlay.
Navigation: the landmarks and names the classes don't carry
The navigation classes are styling only — the ARIA scaffolding is yours, and without it these widgets are unlabelled or unannounced:
ui-breadcrumb— wrap it in<nav aria-label="Breadcrumb">and mark the last (current) crumb witharia-current="page".ui-pagination— wrap it in<nav aria-label="Pagination">; give the current pagearia-current="page"; label icon-only prev/next controls (aria-label="Previous page"). Disable a control with nativedisabled(a<button>) for full inertness, oraria-disabled="true"for a control that stays focusable/announced (e.g. a disabled<a>). CSS dims both and makesaria-disabledpointer-inert, but only nativedisabledis keyboard-inert on its own — to stop anaria-disabledcontrol from activating on Enter/Space, wireinitDisabledGuard()once near your root (see Behaviors).ui-tabs—initTabsadds the full APG wiring (roles, roving tabindex,aria-selected, panelhidden, focusable panel). If you wire tabs yourself, name theui-tabs__list(role="tablist"+ anaria-label) and pair each tab with its panel viaaria-controls/aria-labelledby. Every tab needs a matching panel — a tab with noaria-controlstarget is an orphan that announces as selected but reveals nothing.Icon-only buttons (
ui-button--iconand any glyph-only control) carry no text node, so they're nameless to AT — give them anaria-label(<button class="ui-button ui-button--icon" aria-label="Delete">). Preferui-button__labelwhen the same control also appears with its word visible, or when a test reads the button by its text. Wrap the text in the slot and let--icondecide whether it is painted — the markup does not change, the accessible name survives, and noaria-labelcan drift out of sync with the visible wording:// The mask comes from renderGlyph(..., { render: 'mask' }) — there is no // --glyph-* token. const mask = renderGlyph('trash', { render: 'mask' }); el.innerHTML = `<button class="ui-button ui-button--icon">` + `<span class="ui-icon" style="--icon-mask: ${mask}"></span>` + `<span class="ui-button__label">Delete</span>` + `</button>`;Drop
--iconand the same markup renders glyph + word. The slot also ellipsis rather than wrapping, so a labelled button in a width-constrained bar shrinks instead of pushing its neighbours out.ui-button--denseis for bars whose height is the constraint — a pane title bar, a packed toolbar, a table row's actions. It lowers only the visual floor, to the WCAG 2.5.8 24px minimum. The coarse-pointer block still floats it to the full--tap-target, so a control you shrink for a mouse is never shrunk for a finger.ui-sitenav/ui-app-nav— signal the current link witharia-current="page"(both honour it;ui-app-navalso accepts the visual-only.is-active, but preferaria-current).ui-skiplink— keep it the first focusable element and point itshrefat theidof your main landmark.
App shell: the service frame
ui-app-shell is the default cross-service identity frame: a CSS-only
two-column app shell (sidebar rail + main column) that collapses to a single
column with a horizontal rail below 880px — no behavior required. Use it for
ops tools, admin apps, internal services, generated dashboards, and any workflow
surface that should feel like part of the same system. The nesting matters; the
rail is ui-app-rail and the content side is ui-app-main:
<div class="ui-app-shell">
<aside class="ui-app-rail">
<span class="ui-app-rail__brand">Acme</span>
<nav class="ui-app-nav" aria-label="Primary">
<span class="ui-app-nav__section">Main</span>
<a href="/overview" aria-current="page">Overview</a>
<a href="/jobs">Jobs</a>
<a href="/integrations">Integrations</a>
<a href="/settings">Settings</a>
</nav>
<div class="ui-app-rail__account">…</div>
</aside>
<main class="ui-app-main">
<header class="ui-app-topbar"><h1 class="ui-app-topbar__title">Overview</h1></header>
<div class="ui-app-content">
<section class="ui-app-panel">
<div class="ui-app-panel__head"><h2 class="ui-app-panel__title">KPIs</h2></div>
<div class="ui-app-metrics">…<div class="ui-app-metric">…</div></div>
</section>
</div>
</main>
</div>
Knobs: --app-rail sets the rail width (default 14rem); ui-app-shell--full
drops the rail for a single-column app. ui-app-nav honours aria-current="page"
(preferred) and the visual-only .is-active.
Service composition checklist:
- Start with
ui-app-shell,ui-app-rail,ui-app-nav,ui-app-main,ui-app-topbar, andui-app-content. - Put brand and account identity in the rail; do not rebuild that frame per app.
- Use
ui-app-toolbarfor filters/actions above a workflow surface. - Use
ui-app-panelfor grouped operational sections, not nested cards. - Use
ui-statgrid/ui-stat,ui-table,ui-field,ui-alert, andui-progressinside panels; add opt-instate.cssforui-statelifecycle labels. - Keep nav current state on
aria-current="page"so the visual cue and AT cue match. - Reach for opt-in
workbench.cssonly when the service needs panes, inspectors, or selected-object bulk actions.
Menus: data-bronto-menu + initMenu
A dropdown menu is a native <details data-bronto-menu> styled as
ui-menu-host → ui-menu (with ui-menu__item / ui-menu__sep /
ui-menu__label). It opens/closes natively, but initMenu() adds the close
affordances a menu needs: outside-click close, Escape, and closing on item
activation. Without the behavior the menu opens but never dismisses itself.
Clipping limitation. The menu panel is positioned in normal flow (not the
top layer), so an ancestor with overflow: hidden/auto or a transform
clips it — the same edge case the tooltip has. If your menu lives inside a
scroll container or a clipped card and the panel gets cut off, either lift the
ui-menu-host out of the clipping ancestor, or reach for initPopover (which
escapes to the browser top layer via the native popover attribute) for that
control instead.
Avatar: it's an unlabelled blob until you name it
ui-avatar is a presentation box. Give it an accessible name yourself: an
image avatar needs real alt text (alt="" only if it's purely decorative
beside a visible name); an initials avatar needs an accessible name on the
element (e.g. aria-label="Ada Lovelace") because the initials alone don't
convey identity to AT. Keep initials to ~2 characters — the box is
overflow: hidden and silently clips a third.
Modal: use native <dialog>
Prefer the native <dialog> path — you get top-layer, backdrop and
focus-trap free (wire it with initDialog for open-triggers + focus-return).
Use a native <dialog> with initDialog() for focus, stacking, and close
behavior. Application frameworks own the mount/cleanup lifecycle. The removed
controlled-modal path is covered by the 0.10 migration.
Carousel & lightbox: one primitive, two skins
ui-carousel is a scroll-snap track of __slides wired by initCarousel
(prev/next, keyboard, a __thumb strip, the __status counter, ARIA).
Because the track is native horizontal scroll, touch and trackpad
swipe — with momentum — are the browser's; the behavior only keeps the JS
index in sync with the scroll both ways. Add data-bronto-carousel-loop
to wrap at the ends.
A lightbox is the same carousel inside a native
<dialog class="ui-lightbox">, opened with data-bronto-open (wired by
initDialog). Do not hand-roll an overlay: the <dialog> gives the
top layer, the backdrop, the focus-trap, Escape, and focus-return for
free — exactly the parts a from-scratch lightbox gets wrong. The inline
carousel crops (cover); the lightbox shows the whole image (contain).
This is deliberately not an auto-playing marketing slider (no timers, no infinite-clone track). It's a gallery: the user drives it.
Display glyphs: when (and when not)
@ponchia/ui/glyphs is a 71-glyph dot-matrix icon set — navigation
(arrow-*, chevron-*), actions (check, close, plus, minus,
search, menu, gear), status (info, warning, bell, lock) and
common marks (home, user, heart, star, spark, circle-family marks) — rendered on the
.ui-dotmatrix primitive, so they re-skin with the same --field-dot*
tokens as every other dot surface. The default and solid renderers emit
dot-matrix DOM, not an icon font; the dense .ui-icon renderer uses an
internal SVG data URL as a CSS mask so it can stay one DOM node.
Two rendering modes — pick by size. The dots need physical room to
read, so the default dot look is for display sizes (~40px up: hero
marks, empty states, status bursts, section headers, large buttons). For
small/inline use pass solid: true (or data-bronto-glyph-solid):
that fuses the cells into a square, gapless pixel glyph that stays crisp and
legible down to ~16px — so the same set doubles as real inline UI icons,
not just decoration. (Below the dot fragments into dot-soup; solid does not.)
One caveat on ink: solid cells inherit the dot palette (--field-dot-hot,
~40% alpha), so at small sizes a solid glyph reads as a soft grey, not full
ink. When you want a crisp, full-strength small icon (toolbar, button affordance),
use the one-node mask renderer instead — renderGlyph(name, { render: 'mask' })
paints the glyph in currentColor on a .ui-icon, so it tracks text colour at
any size.
renderGlyph(name, { label }) returns an SSR-safe string: decorative
(aria-hidden) by default, or role="img" + aria-label when you pass a
label — which is how it conveys meaning to assistive tech. Prefer the
data-bronto-glyph placeholder + initDotGlyph() when the markup is
easier dropped than inlined. Size with --dotmatrix-dot (and a tight
--dotmatrix-gap) for an intrinsic dot, or let it stretch to its container.
It is still a pixel-grid aesthetic, not a hairline vector set — but it now
spans both inline-icon and display use from one source.
Animation is opt-in via anim (renderGlyph(name, { anim: 'reveal' }))
or data-bronto-glyph-anim: reveal powers the cells on in a scan (a
dot-matrix booting up), pulse makes the glyph breathe for a live/attention
state. It's decorative only — disabled under prefers-reduced-motion,
and the meaning still lives in the static frame + label, never in the
motion. Don't animate to convey information a reduced-motion user would miss.
Tune the scan speed with --dotmatrix-reveal-step (delay per cell, default
3ms). Use pulse sparingly — one attention target per view: it loops
indefinitely, and animation that runs in parallel with other content is the
consumer's WCAG 2.2.2 (Pause/Stop/Hide) responsibility (reduced-motion is not
a substitute).
A few sharp edges to know:
- Inline icon recipe.
renderGlyphreturns a<span>, so it's valid inline and inside a<button>. For an inline UI icon, rendersolidand size the dot, e.g. inside a button:`<button class="ui-button">${renderGlyph('search', { solid: true, dot: '1.2px', label: 'Search' })}<span>Search</span></button>`— the button'sdisplay: inline-flex; gapaligns icon + text. For icon-in-prose, set--dotmatrix-dotto ~`0.08emandvertical-align: -0.15em` on the span. solidwins.solid: trueimplies glyph-only and forces--dotmatrix-gap: 0/ square cells, so agrid: trueorgappassed alongside it is ignored.- Directional glyphs are physical, not logical.
arrow-left/right,chevron-left/rightare fixed bitmaps; in an RTL context flip them yourself (e.g. swap the name, ortransform: scaleX(-1)), the framework won't. - Cost / icon-at-scale. The default (cell) render is a 16×16 grid = 256
cells (DOM nodes), regardless of
solid/grid;anim: 'reveal'adds a per-cell--i. That's the dot-matrix display look — but for many icons (one in every row of a long table) userenderGlyph(name, { render: 'mask' }): it returns a single.ui-iconelement masked by the bitmap (one node, not 256), sizes tosize/--icon-size(default1em) and inheritscurrentColor— a normal inline icon. Pickrender: 'mask'for icons, the cell render for display marks/animation.
Colorways: when to reach for a skin
@ponchia/ui/css/skins.css adds data-bronto-skin="amber-crt | phosphor-green | e-ink" — a root-level colorway (apply on <html>, like
data-theme) that re-points the one accent to a different single hue.
- Use a skin when you want a distinct, on-brand look (a phosphor/CRT or e-ink feel) for the whole page or app — it's the supported, contrast-gated way to recolour without leaving the design system.
- Use a raw
--accentoverride when you just need your brand hue: it's one declaration (see "Re-brand obligations" below) — but then contrast is yours, whereas the shipped skins are pre-gated. - Don't put
data-bronto-skinon a subtree — it's root-level by design (the derived accent family only recomputes at:root); a subtree skin no-ops. For a one-section recolour, scope a raw--accentoverride instead. - It's opt-in: a separate stylesheet, never in
dist/bronto.css. No skin imported → zero cost. Full detail in theming.md → "Display colorways".
Data-viz colours: charts, not chrome
@ponchia/ui/css/dataviz.css (opt-in) adds a Tier-4 chart palette for
dashboards: --chart-1..8 (categorical), --chart-seq-* (sequential),
--chart-div-* (diverging), and --chart-pattern-1..8 (dot-matrix fills), plus
the same eight hues as categorical identity: --cat-N, --cat-N-tint and
--cat-N-ink for tags, participants and user-chosen tints.
- Use it for categories, never for chrome or status. A build gate fails if
var(--chart-*)orvar(--cat-*)appears in component CSS. Style buttons and badges with the accent/status tiers. - No slot is the accent. The order is fixed (blue, orange, aqua, yellow, magenta, green, violet, red) and adjacent slots are gated for separation under simulated protanopia/deuteranopia and in normal vision.
- Always pair colour with pattern (
--chart-pattern-N) and/or a direct label — never colour alone (WCAG 1.4.1):background: var(--chart-3); background-image: var(--chart-pattern-3); background-size: var(--chart-pattern-size); - In JS (Chart.js, canvas, SVG): import resolved hex from
@ponchia/ui/charts.json({ hues, light, dark }), or read the live page with@ponchia/ui/rendererwhen it can change skin or theme. Cap a chart at ~8 series. Full detail in theming.md → "Data-viz palette".
SVG annotations: subject, connector, note
@ponchia/ui/css/annotations.css (opt-in) adds Bronto-styled SVG annotations
for reports and chart figures. It is a visual grammar, not a charting or
authoring engine.
- Compose each callout from
ui-annotationplus a subject (ui-annotation__subject), connector (ui-annotation__connector), and note (ui-annotation__note,ui-annotation__title,ui-annotation__label). - Use
ui.annotation({ variant, tone, motion })when building class strings in JS. The default is a callout in the accent tone; motion is always opt-in. - Use
@ponchia/ui/annotationswhen you want deterministic SVG path strings for circle, rect, threshold, bracket, band, slope, comparison, cluster, axis, timeline, line, elbow, or curve annotations. UsenotePlacement()for a single bounded note when you need a conservative first placement pass. - Status tones are only for status-bearing callouts; otherwise use
accentfor the main insight andmutedfor secondary labels. - Keep annotated charts sparse. Dense figures need a scrollable SVG, a simplified mobile SVG, or complete caption/fallback text.
- Annotation text must be visible or represented in the figure caption, SVG
<desc>, or fallback table. Full detail in annotations.md.
Forms: the contracts the markup alone won't tell you
- Disabled — pick one mechanism. Use the native
disabledattribute for a genuinely inert control (ui-input,ui-select,ui-textarea,ui-switch/ui-check/ui-segmentedwrapping a native input,ui-range,ui-file,ui-button): the browser greys it, blocks activation, and skips it in tab order, and bronto styles the disabled cue. Usearia-disabled="true"only when the control must stay focusable/announced — bronto then addspointer-events: nonetoui-button/ui-linkso the pointer can't activate it. That is pointer-inert, not keyboard-inert: CSS can't stop Enter/Space, so wireinitDisabledGuard()(Behaviors) to block keyboard activation across everyaria-disabledcontrol. Either way you still own removing it from the submit logic. - Read-only ≠ disabled. A
readonlyinput keeps its value in form submission and stays focusable/selectable;disableddoes neither. Bronto gives a read-only field a quiet muted fill so it doesn't read as a live editable field — reach forreadonlywhen the value matters but mustn't be edited,disabledwhen it should be inert and skipped. - Combobox (
data-bronto-combobox) reads its options from the DOM atinitCombobox()time — re-run it after you replace the option list (or adddata-bronto-combobox-live). The selected option's text label is shown in the input while thebronto:changeevent carries the option'sdata-valuecode — so put the human label in the<li>text and the code indata-value. The.ui-combobox__empty("No matches") is hidden until a filter empties the list. Two intentional single-select APG deviations: ArrowDown on a closed list filters rather than preselecting the first option, and Tab closes without committing a merely-highlighted option (Enter/click commits). - Validation is opt-in via
data-bronto-validateon the form plusinitFormValidation(); it surfaces messages into aui-error-summaryyou provide. The summary's title is the legible sans, not the display face — it's meant to be read.
Branded file input: keep the native control operable
Prefer the visible native control: <input class="ui-file" type="file"> styles
its file-selector button without hiding the input. If the product needs a
button-shaped label, keep the native input focusable inside the label and expose
its focus on the visible wrapper. Never use display: none on the input: that
removes it from keyboard navigation.
<label class="ui-button upload-button">
Choose file
<input class="ui-visually-hidden" type="file" name="document" />
</label>
.upload-button:focus-within {
outline: 3px solid var(--focus-ring);
outline-offset: var(--focus-offset);
}
Sortable table: the button is part of the contract
Put a real button in each sortable header, mark the table, then initialize the
behavior. Use data-sort="num" for numeric columns and data-sort-value on a
cell when its displayed text is not a canonical sortable value.
<table class="ui-table" data-bronto-sortable>
<thead><tr>
<th><button class="ui-table__sort" data-sort type="button">Name</button></th>
<th class="is-num"><button class="ui-table__sort" data-sort="num" type="button">Score</button></th>
</tr></thead>
<tbody><tr><td>Ada</td><td class="is-num" data-sort-value="9.5">9,5</td></tr></tbody>
</table>
import { initTableSort } from '@ponchia/ui/behaviors';
const cleanup = initTableSort();
Reveal: ui-reveal needs JS, ui-scroll-reveal doesn't
ui-scroll-reveal is scroll-driven and zero-JS — reach for it in a static
or LLM-authored report. ui-reveal is the JS variant: it starts hidden and you
toggle is-visible (e.g. from an IntersectionObserver you own) to play it in.
With scripting disabled it degrades to fully visible, but if scripting is on
and nothing toggles is-visible, the content stays hidden — so only use
ui-reveal when you are wiring that toggle.
View transitions (ui-vt). ui-vt names an element via --ui-vt-name so it
morphs across a document.startViewTransition() or a cross-document navigation.
The name must be unique per document at the moment a transition runs: applying
ui-vt with one shared --ui-vt-name across every card in a list (the obvious
loop) makes the browser skip the transition — and it is not silent: it logs a
console error and rejects vt.ready (with an InvalidStateError). If you
don't vt.ready.catch(…), that surfaces as an unhandled promise rejection. Give
each element its own name (--ui-vt-name: card-7) or only mark the single element
that actually morphs.
Loading affordances need a role you supply
ui-spinner, ui-dotspinner, ui-skeleton, and an indeterminate ui-progress
are decorative animations — bronto can't know their semantics. Give the busy
region aria-busy="true" (or role="status" with an aria-live text label like
"Loading…"), and mark a purely decorative spinner aria-hidden="true". Without
one of these a screen reader announces nothing while the user waits.
Popover: prefer the native top layer
initPopover() shows a .ui-popover in the browser top layer when the panel
carries the native popover attribute (never clipped by overflow/stacking);
without it, it falls back to an is-open class that a clipping ancestor can cut
off. Add popover to the panel for the robust path — the is-open form is a
fallback, not the default to copy. Placement flips to the roomier vertical side,
keeps the panel on-screen, and scrolls tall content inside the available viewport
space.
It is a non-modal dialog by design: the panel gets role="dialog" and focus
moves into it, but there is no focus trap and the rest of the page stays
interactive — Tab moves out of the panel (it does not cycle), and it closes on
Escape or outside-click. Don't assume <dialog>-modal semantics; if you need a
trap and an inert backdrop, use a real modal (<dialog> + initDialog). The
controlled-modal path was removed in 0.10. And the
is-open fallback is a plain stacked element, so it sits
under any open native <dialog>'s top layer — another reason to prefer the
native popover attribute when a popover and a dialog can be open together.
Two tiers: CSS-native vs behavior-required
Not every component works with JavaScript off — know which tier you are shipping before you rely on the no-JS path.
CSS-native — fully operable with JS off. Safe in static or LLM-authored HTML, print/PDF, and before any hydration:
| Component | How it works without JS |
|---|---|
Tooltip (ui-tooltip) |
:hover / :focus-within for short local labels |
| Accordion | native <details> / <summary> |
Segmented control (ui-segmented) |
:has(input:checked) over a radio group |
Scroll-reveal (ui-scroll-reveal) |
scroll-driven animation, zero JS |
Behavior-required — a CSS skin that needs its init* to be interactive.
These are JS widgets wearing the Bronto look; without the behavior they are inert
(and a couple are worse than inert — see the tabs row):
| Component | Behavior | With the behavior absent |
|---|---|---|
Disclosure trigger ([data-bronto-disclosure]) |
initDisclosure |
a button with stale aria-expanded; the controlled panel never toggles |
Tabs (ui-tabs) |
initTabs |
author panels visible — ship hidden panels and if initTabs never runs the content is unreachable |
Combobox (ui-combobox) |
initCombobox |
a plain text input beside an unfiltered list |
Command palette (ui-command) |
initCommand |
a static, unfiltered list |
Splitter (ui-splitter) |
initSplitter |
fixed panes at the authored --splitter-pos; no keyboard/pointer resize or aria-valuenow updates |
Table sort/select ([data-bronto-sortable]) |
initTableSort |
a static table (still readable) |
Popover (ui-popover) |
initPopover |
no placement/ARIA — prefer the native popover attribute |
Carousel (ui-carousel) |
initCarousel |
a native scroll-snap track (usable, no controls) |
Native dialog/lightbox (<dialog>, ui-lightbox) |
initDialog |
closed markup stays closed; data-bronto-open/close buttons do nothing. Do not use open as a modal fallback: it is non-modal and has no trigger/focus-return path |
Menu (data-bronto-menu) |
initMenu |
a button next to a list with no open/close, outside-click, or Escape |
Dismissible alert/callout (data-bronto-dismissible) |
dismissible |
the close affordance is just a button; nothing is removed |
| Toast | toast() |
nothing — it is imperative-only |
One cross-cutting guard, not tied to a single component:
| Concern | Behavior | With the behavior absent |
|---|---|---|
| Theme persistence before first paint | applyStoredTheme |
the token layer follows OS preference until client code sets data-theme, so a stored preference can flash |
| Theme toggle controls | initThemeToggle |
the button is inert and does not persist data-theme |
aria-disabled="true" controls |
initDisabledGuard |
dimmed + pointer-inert via CSS, but still keyboard-activatable on Enter/Space (native disabled is already fully inert) |
Rule of thumb: if a component needs ARIA-state sync, focus management, a keyboard model, or persisted/dynamic state, it is behavior-required — that is the exact boundary of what CSS alone cannot do.
When to add a behavior
The CSS is the framework; @ponchia/ui/behaviors is the sanctioned
home for the little JS that genuinely needs scripting (theme persistence,
disclosure, native-dialog glue, toast, combobox, form-validation,
table-sort, splitter resizing). Reach for it instead of reimplementing — every
initializer is SSR-safe, idempotent, and returns a cleanup. If you find yourself
writing focus management, ARIA value sync, or aria-expanded toggling by hand,
there is probably already a behavior for it.
Re-brand obligations (the short version)
Changing --accent is one declaration, but contrast is then yours:
the shipped palettes are gated (see contrast.md); your
custom accent is not. Verify primary-button label, --accent-text, and
the focus ring against their backgrounds. Full contract:
theming.md.
Dense labels
Use ui-chip--dense for a static label in a short pane header.
ui.chip({ dense: true }) returns that class. Buttons and links carrying it
retain pointer target floors; use an actual button for an action.
For complete tool/report layouts, use composition recipes.