Styling and theming
Home-UI has exactly three public styling surfaces:
- the
--hui-*custom properties, for colour, shape, type, spacing, motion and focus; ::part()hooks, for the few places a token cannot reach;- the class sheet, for markup the host renders itself.
Everything else inside a component’s shadow root is private and may change without notice. A host that styles an internal selector has not extended the API; it has taken a copy of an implementation detail. This page is the boundary in writing.
To build a theme by hand and copy the CSS, open the theme customiser. It turns a base colour, an accent, a radius and a font choice into the --hui-* declarations below, and measures the result against the contrast targets.
The token layers
Section titled “The token layers”Tokens are declared in three layers, resolved in this order:
:root { /* light */ }
:root:not(.light) { @media (prefers-color-scheme: dark) { /* dark, when the system asks */ }}
:root.dark { /* dark, whatever the system says */ }No class on <html> follows the operating system. class="dark" or class="light" pins the theme. The Go host stamps that class into the template before first paint, so there is no flash of the wrong theme. See Dark mode.
Tokens theme nothing until something applies them. Home-UI does not style the host’s <body>; the opt-in is .hui-surface:
<body class="hui-surface">Without it, a dark page gets correctly themed components on a white page, and the ghost and link buttons become white on white. That defect was ISS-001.
Overriding
Section titled “Overriding”Change a token globally on :root, or scope it to any ancestor:
Source
<div style="--hui-primary: oklch(0.55 0.2 250); --hui-primary-foreground: white"><hui-button>Scoped accent</hui-button></div><hui-button>Default accent</hui-button>Nothing was rebuilt for that: the second button reads the shipped token, the first reads an override that reaches it through the shadow boundary. That inheritance is the whole reason the theming API is custom properties - tokens inherit through a shadow boundary; rules do not. A document stylesheet cannot reach inside a component, but a custom property set on an ancestor is inherited by the shadow tree and any descendant that reads it.
The token reference
Section titled “The token reference”Every token the sheet declares, with its shipped light and dark values. The values are generated from tokens.css, not transcribed.
Surfaces
| Token | Light | Dark | Meaning |
|---|---|---|---|
--hui-background | oklch(1 0 0) | oklch(0.145 0.008 326) | The page surface, applied by .hui-surface. |
--hui-card | oklch(1 0 0) | oklch(0.212 0.019 322.12) | Raised surfaces: cards and alerts. |
--hui-card-foreground | oklch(0.145 0.008 326) | oklch(0.985 0 0) | Text on --hui-card. |
--hui-foreground | oklch(0.145 0.008 326) | oklch(0.985 0 0) | Body text on the page surface. |
--hui-popover | oklch(1 0 0) | oklch(0.212 0.019 322.12) | Overlay panels: dialogs, popovers, menus. |
--hui-popover-foreground | oklch(0.145 0.008 326) | oklch(0.985 0 0) | Text on --hui-popover. |
Interactive
| Token | Light | Dark | Meaning |
|---|---|---|---|
--hui-accent | oklch(0.96 0.003 325.6) | oklch(0.263 0.024 320.12) | Highlight fill for hovered items. |
--hui-accent-foreground | oklch(0.212 0.019 322.12) | oklch(0.985 0 0) | Text on --hui-accent. |
--hui-destructive | oklch(0.56 0.245 27.325) | oklch(0.704 0.191 22.216) | Destructive text, borders and the error icon. |
--hui-destructive-foreground | oklch(0.985 0 0) | oklch(0.205 0 0) | The label on a solid destructive fill. |
--hui-destructive-subtle | oklch(0.972 0.01 15.923) | oklch(0.316 0.079 20.417) | The destructive tint, as an opaque value. It is opaque rather than translucent so its contrast is fixed and measurable. |
--hui-destructive-subtle-foreground | oklch(0.56 0.245 27.325) | oklch(0.704 0.191 22.216) | The label on --hui-destructive-subtle. |
--hui-mark | oklch(0.9 0.11 95) | oklch(0.48 0.1 95) | The .hui-mark highlight behind a search hit. A warm highlighter yellow, distinct from --hui-accent; .hui-mark--warning uses the destructive tint instead. |
--hui-muted | oklch(0.96 0.003 325.6) | oklch(0.263 0.024 320.12) | Quiet fills: hover, skeletons, code inline. |
--hui-muted-foreground | oklch(0.542 0.034 322.5) | oklch(0.711 0.019 323.02) | Dimmed text: descriptions, captions, placeholders. |
--hui-primary | oklch(0.496 0.265 301.924) | oklch(0.438 0.218 303.724) | The default action fill. A fill, not text; use --hui-primary-text for text. |
--hui-primary-foreground | oklch(0.977 0.014 308.299) | oklch(0.977 0.014 308.299) | The label on --hui-primary. |
--hui-primary-text | oklch(0.496 0.265 301.924) | oklch(0.65 0.22 303.9) | Primary-coloured text, such as the link button. Separate from --hui-primary because nova’s dark primary fails as text. |
--hui-secondary | oklch(0.967 0.001 286.375) | oklch(0.274 0.006 286.033) | The secondary action fill. |
--hui-secondary-foreground | oklch(0.21 0.006 285.885) | oklch(0.985 0 0) | The label on --hui-secondary. |
Lines and focus
| Token | Light | Dark | Meaning |
|---|---|---|---|
--hui-border | oklch(0.922 0.005 325.62) | oklch(1 0 0 / 10%) | Decorative hairlines and separators. |
--hui-input | oklch(0.62 0.019 323.02) | oklch(0.556 0.019 323.02) | Control boundaries. Tuned to clear the 3:1 a control boundary needs. |
--hui-ring | oklch(0.62 0.019 323.02) | oklch(0.556 0.019 323.02) | The keyboard focus ring colour, drawn as a translucent shadow. |
Buttons
| Token | Light | Dark | Meaning |
|---|---|---|---|
--hui-button-ghost-hover | oklch(0.96 0.003 325.6) | oklch(0.263 0.024 320.12 / 50%) | The ghost button fill on hover: --hui-muted in light, half of it in dark. |
--hui-button-outline | oklch(1 0 0) | oklch(1 0 0 / 4.5%) | The outline button fill. The page colour in light; translucent white in dark, as nova draws it, so the button lifts off a card rather than cutting through to the page. |
--hui-button-outline-border | oklch(0.922 0.005 325.62) | oklch(1 0 0 / 15%) | The outline button edge. Decorative, like --hui-border: the label identifies the button, so it has no 3:1 floor. |
--hui-button-outline-hover | oklch(0.96 0.003 325.6) | oklch(1 0 0 / 7.5%) | The outline button fill on hover: --hui-muted in light, a stronger translucent white in dark. |
Charts
| Token | Light | Meaning |
|---|---|---|
--hui-chart-1 | oklch(0.811 0.111 293.571) | The first chart series, the palest step of the preset's violet. The same in both themes; CanvasText in forced colours. |
--hui-chart-2 | oklch(0.606 0.25 292.717) | The second chart series. |
--hui-chart-3 | oklch(0.541 0.281 293.009) | The third chart series. |
--hui-chart-4 | oklch(0.491 0.27 292.581) | The fourth chart series. |
--hui-chart-5 | oklch(0.432 0.232 292.759) | The fifth chart series, the deepest step. A sixth series starts again at --hui-chart-1. The five steps are one hue, so a chart never relies on them alone. |
Shape
| Token | Light | Meaning |
|---|---|---|
--hui-radius | 0.45rem | The base radius every derived radius multiplies. |
--hui-radius-2xl | calc(var(--hui-radius, 0.45rem) * 1.8) | Base × 1.8. Large surfaces. |
--hui-radius-lg | var(--hui-radius, 0.45rem) | The base. Buttons and alerts. |
--hui-radius-md | calc(var(--hui-radius, 0.45rem) * 0.8) | Base × 0.8. Inputs and items. |
--hui-radius-pill | 9999px | Fully rounded. Badges and avatars. |
--hui-radius-sm | calc(var(--hui-radius, 0.45rem) * 0.6) | Base × 0.6. Inline code, small chips. |
--hui-radius-xl | calc(var(--hui-radius, 0.45rem) * 1.4) | Base × 1.4. Cards and dialogs. |
Spacing
| Token | Light | Meaning |
|---|---|---|
--hui-space-2xl | 2rem | 2rem. Empty-state padding. |
--hui-space-2xs | 0.125rem | 0.125rem. Icon gaps inside a heading. |
--hui-space-lg | 1rem | 1rem. Alert and dialog body padding. |
--hui-space-md | 0.75rem | 0.75rem. Table cell padding. |
--hui-space-sm | 0.5rem | 0.5rem. Button rows, item gaps. |
--hui-space-xl | 1.5rem | 1.5rem. Card padding. |
--hui-space-xs | 0.25rem | 0.25rem. Label-to-description, badge gaps. |
Typography
| Token | Light | Meaning |
|---|---|---|
--hui-font-heading | "Roboto Slab", Georgia, "Times New Roman", serif | Card titles and headings. |
--hui-font-mono | "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace | Inline code and keyboard keys. |
--hui-font-sans | "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif | Body, labels and component text. |
--hui-leading-normal | 1.5 | 1.5. Body copy. |
--hui-leading-tight | 1.25 | 1.25. Headings and single-line controls. |
--hui-text-2xl | 1.5rem | 1.5rem. h1. |
--hui-text-base | 1rem | 1rem. Body copy. |
--hui-text-lg | 1.125rem | 1.125rem. Lead paragraphs and h3. |
--hui-text-sm | 0.875rem | 0.875rem. Controls and table cells. |
--hui-text-xl | 1.25rem | 1.25rem. h2. |
--hui-text-xs | 0.75rem | 0.75rem. Badges, captions, help text. |
--hui-weight-medium | 500 | 500. Buttons, labels, titles. |
--hui-weight-normal | 400 | 400. |
--hui-weight-semibold | 600 | 600. Headings. |
Elevation
| Token | Light | Meaning |
|---|---|---|
--hui-shadow-lg | 0 10px 15px -3px oklch(0 0 0 / 0.1), 0 4px 6px -4px oklch(0 0 0 / 0.1) | Overlay elevation. |
--hui-shadow-md | 0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1) | Raised elevation. |
--hui-shadow-sm | 0 1px 2px oklch(0 0 0 / 0.05) | Resting elevation. |
Motion
| Token | Light | Meaning |
|---|---|---|
--hui-duration-fast | 120ms | 120ms. Colour and border changes. |
--hui-duration-normal | 200ms | 200ms. Progress and larger transitions. |
--hui-ease | cubic-bezier(0.4, 0, 0.2, 1) | The shared easing curve. |
Icons
| Token | Light | Meaning |
|---|---|---|
--hui-icon-size | 1em | Icon size inside a component; default 1em. |
Focus
| Token | Light | Meaning |
|---|---|---|
--hui-focus-ring-offset | 0px | Gap between control and ring; 0 by default. |
--hui-focus-ring-width | 3px | Width of the keyboard focus ring. |
Measured contrast
Section titled “Measured contrast”Every required foreground pair clears WCAG 2.1 AA in both themes. These figures are computed from the shipped sheet with the same code the library’s own gate uses (packages/ui/scripts/measure-contrast.mjs), and the copy of that gate in the catalogue fails the build if a pair misses.
| Pair | Light | Dark | Target |
|---|---|---|---|
foreground on background | 19.81 | 18.98 | 4.5 |
card-foreground on card | 19.81 | 16.98 | 4.5 |
popover-foreground on popover | 19.81 | 16.98 | 4.5 |
primary-foreground on primary | 6.6 | 8.27 | 4.5 |
secondary-foreground on secondary | 16.11 | 14.26 | 4.5 |
muted-foreground on muted | 4.54 | 6.01 | 4.5 |
muted-foreground on background | 5.1 | 7.67 | 4.5 |
accent-foreground on accent | 15.76 | 14.87 | 4.5 |
destructive-foreground on destructive | 4.79 | 6.19 | 4.5 |
destructive on background | 5.01 | 6.85 | 4.5 |
destructive on card | 5.01 | 6.13 | 4.5 |
destructive-subtle-foreground on destructive-subtle | 4.6 | 4.61 | 4.5 |
foreground on mark | 14.75 | 6.25 | 4.5 |
muted-foreground on card | 5.1 | 6.86 | 4.5 |
primary-text on background | 7.07 | 5.55 | 4.5 |
primary-text on card | 7.07 | 4.96 | 4.5 |
primary on background | 7.07 | 2.23 | recorded, not enforced |
border on background | 1.26 | 1.25 | recorded, not enforced |
input on background | 3.67 | 4.15 | 3 |
ring on background | 3.67 | 4.15 | 3 |
Two rows are recorded rather than enforced. border is a decorative hairline. primary is a fill, not text: nova’s dark primary is a deep purple against a near-black page, and what identifies the button is its label at 8.27:1. Primary-coloured text is --hui-primary-text, which is enforced.
Per-component hooks
Section titled “Per-component hooks”::part() is the escape hatch for a style no token covers. A part is a promise: it is part of the public API and will not be renamed without notice. An internal selector is not.
| Element | Parts |
|---|---|
hui-app-shell | ::part(aside)::part(footer)::part(frame)::part(header)::part(main)::part(nav)::part(skip) |
hui-area-chart | ::part(active-dot)::part(announcer)::part(area)::part(axis-x)::part(axis-y)::part(figure)::part(grid)::part(legend)::part(line)::part(plot)::part(series)::part(table)::part(tooltip) |
hui-bar-chart | ::part(announcer)::part(axis-x)::part(axis-y)::part(figure)::part(grid)::part(legend)::part(mark)::part(plot)::part(series)::part(table)::part(tooltip) |
hui-button | ::part(button) |
hui-button-group | ::part(group) |
hui-calendar | ::part(day)::part(grid)::part(header)::part(label)::part(next)::part(prev)::part(viewport) |
hui-carousel | ::part(dot)::part(dots)::part(next)::part(previous)::part(track)::part(viewport) |
hui-checkbox | ::part(control) |
hui-combobox | ::part(empty)::part(input)::part(listbox)::part(panel) |
hui-command | ::part(dialog)::part(empty)::part(field)::part(listbox)::part(search-icon)::part(trigger) |
hui-date-picker | ::part(field)::part(input)::part(panel)::part(toggle) |
hui-dialog | ::part(body)::part(close)::part(description)::part(dialog)::part(footer)::part(header)::part(title)::part(trigger) |
hui-dropdown-menu | ::part(anchor)::part(panel) |
hui-input | ::part(control)::part(input) |
hui-input-otp | ::part(cell)::part(group) |
hui-line-chart | ::part(active-dot)::part(announcer)::part(axis-x)::part(axis-y)::part(figure)::part(grid)::part(legend)::part(line)::part(mark)::part(plot)::part(series)::part(table)::part(tooltip) |
hui-menubar | ::part(bar)::part(panel) |
hui-navigation-menu | ::part(list)::part(toggle)::part(viewport) |
hui-pagination | ::part(ellipsis)::part(status) |
hui-panel | ::part(actions)::part(body)::part(footer)::part(header)::part(heading)::part(progress) |
hui-path | ::part(bar)::part(edit)::part(input)::part(list)::part(menu)::part(menu-button)::part(nav)::part(overflow)::part(overflow-button)::part(overflow-separator) |
hui-path-node | ::part(anchor)::part(children)::part(node)::part(separator) |
hui-pie-chart | ::part(announcer)::part(centre-label)::part(figure)::part(legend)::part(mark)::part(plot)::part(table)::part(tooltip) |
hui-popover | ::part(anchor)::part(panel) |
hui-qr-code | ::part(code)::part(tile) |
hui-radial-chart | ::part(announcer)::part(centre-label)::part(figure)::part(legend)::part(mark)::part(plot)::part(table)::part(tooltip)::part(track) |
hui-radio-group | ::part(group) |
hui-search-field | ::part(clear)::part(field)::part(input)::part(search-icon) |
hui-select | ::part(chevron)::part(listbox)::part(panel)::part(placeholder)::part(trigger)::part(value) |
hui-sidebar | ::part(content)::part(footer)::part(header)::part(rail)::part(sidebar)::part(toggle)::part(toggle-bottom) |
hui-slider | ::part(control)::part(high)::part(low)::part(track) |
hui-switch | ::part(control)::part(thumb) |
hui-tabs | ::part(tablist) |
hui-textarea | ::part(control)::part(input) |
hui-toast | ::part(region) |
hui-toggle | ::part(control) |
hui-toggle-group | ::part(control)::part(group) |
hui-tooltip | ::part(anchor)::part(panel) |
hui-waveform | ::part(canvas)::part(figure) |
Per-component custom properties (for example --hui-spinner-size, --hui-scroll-area-height, --hui-aspect-ratio) are listed on the component’s own page under “CSS custom properties”. Prefer a custom property to a part wherever one exists.
Making a new palette
Section titled “Making a new palette”The palette is a published shadcn preset, regenerated by command rather than transcribed:
pnpm dlx shadcn@latest preset decode b5ZcnBEttqTo move to a different preset, decode it, set the tokens on :root (and the dark layer), then re-measure. The rule is not “use the preset’s values” but “every foreground pair clears 4.5:1, measured from the shipped sheet”. A preset value that misses is a value the library changes and records.
The nova reference
Section titled “The nova reference”Home-UI is the shadcn nova style at preset b5ZcnBEttq: base colour mauve, theme purple, IBM Plex Sans with Roboto Slab headings, --hui-radius: 0.45rem, Remix Icon. Six values differ from that preset, each because the preset’s own value misses a threshold. Each is measured by the contrast gate and recorded in the visual-style contract (UI-CONTRACT-006):
| Value | Why it differs |
|---|---|
--hui-destructive, light | nova measures 3.97:1 against its own tint, below AA |
--hui-destructive-subtle | made opaque, because an alpha fill’s ratio depends on the backdrop |
--hui-input, --hui-ring | darkened to clear the 3:1 a control boundary needs |
--hui-primary-text | a separate token, because nova uses one value for a fill and for link text |
| Pressed toggle | the state was not perceivable against a white surface |
| Slider unfilled track | --hui-muted was barely visible; it uses --hui-input |
Typography
Section titled “Typography”The library names faces and ships none:
--hui-font-sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;--hui-font-heading: "Roboto Slab", Georgia, "Times New Roman", serif;--hui-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;A host loads the web fonts if it wants them. A host that loads nothing degrades to the fallback stack rather than breaking - the same reasoning as .hui-surface: the host owns the page. The type scale, prose classes and heading sizes are on Typography.
Density, radius and focus
Section titled “Density, radius and focus”nova is one step more compact than stock shadcn: the default button and input are 2rem tall, sm is 1.75rem, lg is 2.25rem, and icon-sm a 1.75rem square. A card spaces its parts 16px apart rather than padding each by 24px, a field sets its label 8px above the control, and a table cell pads by 8px. The radius scale is multiplicative (--hui-radius-sm is the base × 0.6), so a host that changes --hui-radius gets proportional corners at any value rather than a scale that collapses or inverts. See Spacing and radius.
Focus is one treatment everywhere: a 3px translucent ring in --hui-ring on :focus-visible only, drawn with box-shadow rather than outline so it can be translucent and follow the border radius. In a composite, the ring belongs to the control, not to the focusable element inside it (ISS-006). See Focus.
Motion collapses under prefers-reduced-motion: reduce: transitions are removed, not merely shortened.
Forced colours
Section titled “Forced colours”Under forced-colors: active the sheet remaps the three boundary tokens to system colours:
@media (forced-colors: active) { :root { --hui-border: CanvasText; --hui-input: CanvasText; --hui-ring: Highlight; }}Components do not suppress the system focus indicator there, and a host should not override these three - doing so is what breaks forced-colours users.