Theme reference

Look up shared geometry, material, palette generation and motion values. For your first theme, start with Tokens or open the customizer.

Line weights and selection

Use semantic dimensions for visual edges. --ui-border-width defaults to 1px for ordinary borders and dividers. --ui-accent-line-width aliases it for emphasis edges and line selection. --ui-focus-width defaults to 2px for keyboard focus. Changing the border width therefore changes the emphasis width too, unless a theme deliberately sets them independently.

Selected navigation, tree, list, search, and choice rows share --ui-selection-surface, --ui-selection-text, and --ui-selection-radius. They derive from the accent, page surface, readable accent text, and medium radius. The default tint on neutral surfaces is 10%; selected navigation keeps its resting label weight. A Nav Item's variant="line" is a discrete square-edge treatment, not a radius override.

Choice rows use --ui-accent-subtle for hover and active-option feedback. Hover does not draw a keyboard focus ring. Focus stays on the owning field for an active-descendant combobox, or on the row when the row itself receives focus. Forced-color mode preserves a visible active-option indicator.

List rows, surface navigation and tree highlights use --ui-selection-border for a faint continuous inset edge. Its default mixes 20% accent into the page surface, giving pale fills definition on softly toned panels without changing row size. Keyboard focus retains its stronger independent ring; framed list cards and explicit line navigation retain their existing edges.

Colored Cards, Callouts and card Sections derive highlights from their actual surface hue, with a darker lightness step in light themes and a lighter one in dark themes. A bounded chroma increase keeps pale highlights visibly in their color family instead of reading grey. The faint edge follows the same hue. The surface supplies a scoped internal row recipe without redeclaring public theme tokens. Nested neutral panels restore the global brand recipe; transparent Sections inherit the surrounding context. Action buttons retain their own intent colors and keyboard focus stays independent. Per-row surface hooks remain the most specific override.

:root {
  --ui-border-width: 1px;
  --ui-focus-width: 2px;
  --ui-radius-md: 0.5rem;
}

/* A deliberate independent emphasis weight at a theme boundary. */
[data-theme="dark"] {
  --ui-accent-line-width: 2px;
}

A component hook can still override its own edge or surface. Glyph strokes, circular shapes, shadow lighting, and positioning corrections are not surface-border roles; they retain their own geometry. The shared focus role never doubles as the selected-state marker.

Derived values

Everything else in tokens.css is computed from the contract: the intent tones (--ui-accent-solid, *-soft, *-strong-surface), the interaction roles (--ui-action-*, --ui-control-*), the type and space steps either side of the contract's own, the elevation names, and the icon sizes.

Do not restate a derived value in a theme. Setting --ui-accent gives you --ui-accent-solid, --ui-action-primary-surface and the rest for free, and they keep agreeing when the accent changes.

Derived action insets

--ui-action-inset-sm, --ui-action-inset-md and --ui-action-inset-lg derive from the corresponding spacing steps. They control boxed text actions' horizontal content padding while height remains on the shared control scale. A theme can derive these values from its control sizes to give actions more room without changing fields' editing insets.

Optional semantic theme recipe

Import @threadlabs/looma/theme-system.css after tokens.css and your palette to express a theme through shared type, geometry, material and motion decisions. It is opt-in: themes without it retain the base token defaults. The CSS recipe uses custom properties, with no component selectors or runtime script.

@import "@threadlabs/looma/tokens.css";
@import "@threadlabs/looma/theme-light.css";
@import "@threadlabs/looma/theme-system.css";

:root {
  --ui-font-size: 16px;
  --ui-font-display: Georgia, serif;
  --ui-control-roominess: 2.75;
  --ui-spacing: 1.05;
  --ui-scale-ratio: 1.2;
  --ui-space-curve: 1;
  --ui-radius-md: 0.5rem;
  --ui-outer-depth: 0.7;
  --ui-inner-depth: 0.4;
  --ui-edge-light: 0.25;
  --ui-overlay-translucency: 0.1;
  --ui-motion-fast: 0.15s;
}

Use unlayered declarations, as above, to override the recipe's theme layer. Lengths, font families, weights, easing functions and durations use CSS syntax. The recipe recomputes at :root, [data-theme] and [data-contrast] boundaries; a class alone does not recompute inherited derived values.

Typography, geometry and spacing inputs

These defaults describe the shared recipe with the base tokens. The bounds in this table are the documentation customizer's authoring limits. Hand-written CSS is not validated by that authoring tool; the CSS recipe only guards size ratios at 1 and spacing/curve/depth at 0 where needed.

PropertyDefaultCustomizer range and effect
--ui-font-sanssystem-ui, sans-serif in generated themesBody/interface font stack; System font uses the operating system's native face.
--ui-font-displaysystem-ui, sans-serif in generated themesDisplay/heading font stack.
--ui-font-size16pxSets the document root’s font size. The customizer accepts 14–24px; type steps, choice marks, controls and rem-based page layout derive from that root once.
--ui-font-medium500Emphasis weight, 400–700; body and heading weights retain their separate roles.
--ui-control-roominess2.5Unitless height multiplier, 2–4 times base text size. Derives --ui-control-size; it is not a length.
--ui-rhythm1Unitless compact-to-airy multiplier, 0.75–1.5. Coordinates vertical Stack gaps with bounded line-height ratios. Horizontal navigation and control-row gaps follow spacing alone.
--ui-spacing1Unitless space multiplier, 0.75–1.5. Changes gaps and horizontal action insets without changing control heights.
--ui-scale-ratio1.2Small/medium/large control ratio, 1–1.5; 1 deliberately makes the sizes equal.
--ui-space-curve1Spacing curve, 0.5–2; 1 is linear and 2 bends the interior steps quadratically, keeping the endpoints fixed.
--ui-radius-md0.5rem in base tokens; 8px in generated themesCorner seed, 0–32px or equivalent. Derives small, large, action, field and overlay radii; explicit round shapes stay round.
--ui-border-width1pxLine weight, 0.5–3px or equivalent; derives emphasis edges. Generated themes give focus a separate minimum of 2px.
Control and spacing formulas

For base text size f, roominess q, ratio r, spacing s, rhythm t and curve c, the medium height is h = f × q; control heights are h/r, h, h×r. The spacing unit is u = f × s / 4, so changing control roominess does not change page rhythm. Stack multiplies its chosen gap by bounded rhythm t; an explicit --ui-stack-gap remains exact. Horizontal row gaps and padding use the spacing steps directly. The first and last spacing steps stay at u and 8u; interior steps use u × (1 + 7 × (k/7)^c), with k = 1, 2, 3, 5 for steps 2–5. Step 0.5 is half of step 1. Changing the control ratio does not change spacing. Spacing follows base text independently of control roominess.

Line heights are always unitless multipliers of each role’s own font size. Rhythm derives --ui-line-height-tight = clamp(1.1, 1.2 + (t − 1) × 0.2, 1.3) and --ui-line-height = clamp(1.35, 1.4 + (t − 1) × 0.4, 1.6). --ui-line-height-md follows the latter; --ui-line-height-relaxed is that ratio times 1.15. No px or rem line heights are generated. Use Rhythm to coordinate text and layout; use Spacing to refine gaps without changing text ratios.

Pointer capability does not change the visible size ladder. Small, medium and large retain the theme’s proportions on touch. Hit-area guidance is a separate, context-dependent decision: --ui-control-min-block-size is an adjustable comfort preference for components that offer expanded targets, not a mandatory visible height or universal accessibility rule. Small secondary controls can remain deliberately compact. Invisible extensions must not steal neighbouring controls. Single-line editing/action insets (--ui-control-inset-block-sm, -md, -lg) fit their box and typography; multiline content can still grow. Horizontal --ui-action-inset-sm, -md, -lg follow spacing steps 2–4, with a minimum equal to the matching vertical inset so increasing roominess cannot pinch the label horizontally. Choice marks follow type rather than the row size.

Choosing units

The base is a pixel value: :root { --ui-font-size: 18px; } sets the document root to 18px. Normal text is 1rem, a 2.5rem control is 45px, and 2rem of layout space is 36px. Derived type and geometry use the root, rather than multiplying the authored base value again. The size samples show the actual rendered control height in both rem and px; no mental conversion is needed.

UnitUse
pxThe root base; deliberately fixed details such as thin borders and focus strokes; hit-area dimensions chosen for the control’s context; pointer coordinates. These are CSS pixels and still follow browser zoom.
remType roles, text-related gaps, padding and layout dimensions that should grow with the root. Corners may also use rem when their shape should scale with the interface.
emInline details that should follow their own text size rather than the document base.
UnitlessLine height, size ratios, roominess and spacing multipliers.

Base text size is explicitly px-based. Other length editors can switch px/rem while preserving the resolved quantity, including minimum and maximum values. Their ranges convert through the current root; generated CSS keeps the documented pixel bounds, so relative lengths grow with the root until those limits apply. At an 18px root, 9px becomes 0.5rem; increasing the root to 20px then makes that relative value 10px. Switching units alone does not resize it. A 10px root is not needed for arithmetic: the tool shows the resolved values while normal text remains 1rem.

The --ui-font-size seed is set on :root, not on a nested mode boundary. Mode boundaries change the palette without establishing a second rem root. Old recipes that stored relative text-size values must choose an explicit px base (for example, 1.0625rem at the former 16px root becomes 17px). There is no compatibility conversion that guesses the original root.

Material and light inputs

Fraction inputs run from 0 to 1 unless a different range is listed. They represent separate aspects of one material: depth controls elevation/recession, finish controls reflection, and transmission controls the decorative background plane.

PropertyDefaultEffect and dependencies
--ui-outer-depth0.60–2 authoring range. Raised and floating shadow distance/blur; 0 is flat. Light position supplies shadow direction.
--ui-inner-depth0.30–2 authoring range. Recessed field and pressed-control shadows; 0 removes recession.
--ui-glassiness0Frosted backdrop blur, saturation and a light-catching rim on participating surfaces. Visible transmission needs a scene behind the decorative plane.
--ui-surface-separation0Decorative rim/offset around participating surfaces; scaled by popup purpose and rendered size. It can appear with glassiness at 0.
--ui-overlay-translucency0How much of the scene decorative floating material transmits; larger values make the frame more transparent. Content remains nearly opaque, with slight transmission at the maximum.
--ui-finish0Matte (0) through polished (1). Reflection strength also depends on light strength and the generated readable reflection colors.
--ui-edge-light0Illuminated boundary, independent of depth. Bright edge strength also depends on light strength.
--ui-glow0Soft tone-derived light beyond a surface. Radius depends on glow; visible strength also depends on light strength.
--ui-light-x0.5Source position left (0) to right (1), relative to each surface.
--ui-light-y0Source position top (0) to bottom (1); (0.5, 0) is top center.
--ui-light-spread0.5Concentrated spot (0) through broad reflection (1). Requires finish and illumination to show a reflection.
--ui-light-strength0.65Shared illumination response. Does not set elevation or remove depth shadows at 0.

The low end of glassiness and glow stays gentle; their upper range deliberately allows a richer frosted material and a vivid bloom. The response is curved: g + 2g⁴, so 30% remains close to its quiet setting while 100% reaches three times the former linear response. This is an ornamental range, not a readability constraint. Content planes stay nearly opaque and reflection colours remain readable; reduced-transparency preferences still remove the effects.

Finish at 0 removes reflections even when the light is strong. Glow and edge light can still respond independently. Glassiness and separation create the ornamental outer treatment. Raising overlay translucency mainly reveals the scene through the rim, with slight content transmission at its maximum. A feature's visible effect depends on the participating surface and its surroundings. Standard Cards keep their quiet treatment; treatment="expressive" opts into the theme's material.

Derived familyPublic outputs
Elevation--ui-shadow-xs, -sm, -md, -lg, -xl; --ui-raised; --ui-elevation-tooltip, -overlay, -dialog
Control response--ui-action-edge, --ui-action-shadow, --ui-control-inset, --ui-pressed
Material planes--ui-overlay-surface, --ui-material-content, --ui-material-glass, --ui-material-edge
Reflections--ui-surface-highlight, --ui-neutral-highlight, --ui-accent-highlight, --ui-danger-highlight, --ui-success-highlight, --ui-warning-highlight, --ui-info-highlight; --ui-raised-highlight uses the surface highlight
Glow--ui-material-glow, --ui-neutral-glow, --ui-accent-glow, --ui-danger-glow, --ui-success-glow, --ui-warning-glow, --ui-info-glow

Generated themes also supply bounded reflection colors --ui-surface-reflection, --ui-neutral-reflection and --ui-{accent,danger,success,warning,info}-reflection. Their brightest stops preserve readable foregrounds. Reduced-transparency and forced-color preferences disable ornamental reflection/glow; participating components also remove backdrop blur and decorative transmission.

Motion inputs and outputs

PropertyDefaultCustomizer range and effect
--ui-motion-fast120msFeedback pace, 0–600ms; accepts ms or s.
--ui-motion-easecubic-bezier(0.2, 0, 0.2, 1)Shared acceleration/settling; CSS easing keywords or a cubic Bézier curve. Customizer x coordinates are 0–1, y coordinates −1–2.

The recipe sets --ui-motion-press to the fast duration, --ui-motion-base to 1.5 times it, and --ui-motion-ease-out to the chosen curve. Feedback, reveal and layout roles derive as fast, base and 1.5 times base, with corresponding --ui-motion-feedback-ease, --ui-motion-reveal-ease and --ui-motion-layout-ease. Activation scale (--ui-press-scale, 0.98) is independent of depth. Reduced motion removes press/reveal timing and activation scale; components retain their own reduced-motion handling. Explicit timing or scale overrides should also respect that preference.

Palette generation decisions

The documentation customizer adds a generation step to the CSS recipe. Four six-digit hex seeds (action, canvas, surface, text) feed the paired light/dark palette. Seed defaults below are the customizer's Minimal starting point; other starting points supply their own values. Resolved outputs may be repaired for the requested mode and contrast.

Recipe fieldPublic outputDefault seed
action--ui-accent#5b55d6
canvas--ui-surface#f6f6f8
surface--ui-surface-raised#ffffff
text--ui-text#1a1a1a

These generation decisions retain one identity across both modes:

Recipe fieldDefaultRange and meaning
warmth0.50–1; cool through neutral to warm surfaces and ink. Leaves the action hue independent.
color-intensity0.50–1; quiet through vivid chroma across surfaces, action and status colors. 0.5 retains original seed intensity before readability repair.
palette-relationshippreservepreserve keeps seed hues; neighboring relates quiet hues to the action hue +30°; complementary uses the action hue +180°, before warmth shifts.

These three fields are recipe-only generator decisions, with no corresponding CSS custom properties. Edit the JSON recipe and regenerate to produce the resolved colors. The generator uses Oklab mixing and OKLCH hue/chroma adjustments, reduces out-of-gamut chroma while preserving hue/lightness, and repairs readable surfaces, text, action foregrounds, borders, focus and reflection peaks. Resolved colors may differ from requested seeds. Generated CSS includes both palettes and mode boundaries; use data-theme to choose a mode or leave it unset on the root to follow the system.

The existing intent colors and readable foregrounds remain public. Custom CSS, component overrides and arbitrary content still need contrast checks on their actual surfaces. A direct supported role or component hook can override a derived value without replacing the recipe; the recipe never selects private inputs or labels to achieve a theme.

Recipe files, fonts and migration

The customizer's schema-version-1 JSON stores string decisions in values. Unknown decision names are rejected, omitted decisions take defaults, and the optional presets mapping records Palette/Material provenance. An optional fonts mapping associates body-font and/or display-font with a curated Google Fonts family, while values retains the matching CSS font stacks. The System font choice needs no download; selected Google faces load in previews and add a Google Fonts @import before rules in downloaded and locally saved CSS. Advanced custom stacks are for already loaded or self-hosted faces; a stack alone does not download a font.

Migrate an older recipe

Before 1.0, renamed decisions have no compatibility aliases. Replace recipe density with spacing and CSS --ui-density with --ui-spacing. Replace the old length-valued recipe control-size with numeric control-roominess: divide the desired medium height by the base text size (for example, 40px / 16px = 2.5). With the shared recipe, author --ui-control-roominess; --ui-control-size remains a derived public height role. Relative geometry inputs keep their units; generated guards retain the same documented pixel limits as absolute inputs. Recipe text-size is now an explicit px base for the document root.

Motion and navigation

Motion uses three roles: --ui-motion-feedback for control state and caret rotation, --ui-motion-reveal for panels and drawers, and --ui-motion-layout for occupied space such as a docked sidebar. Each has a matching -ease token. They derive from the existing fast/base durations and ease/ease-out curves, so a theme can set one seed or tune a role once. Reduced-motion preferences disable component transitions even when a containing group overrides a duration.

Nav Item and Disclosure's variant="navigation" share the --ui-nav-* semantic tokens: icon size, gap, padding, minimum row height, and hover surface. Both use the shared --ui-selection-radius for corners. Compact density uses the --ui-nav-compact-* padding and height values, not a different icon column. Nav Item's leading and trailing slots center their content on those columns; Disclosure's leading and indicator slots use the same columns.

Disclosure groups

Use a unique name for each independent disclosure group. Add requiredOpen to keep one member open, including on initialization and after removing the open member. Unnamed disclosures and groups without this option remain freely collapsible. fill works within a bounded flex column and animates the flexible space transfer without scrolling its headers away. Custom indicators stay mounted and rotate a half-turn when opened; provide the closed-state icon.

Sidebar's --ui-sidebar-collapsed-width hook defaults to zero. A separate rail can occupy that width while the sidebar's content remains inert and clipped. Its fixed-width content canvas avoids wrapping navigation text during collapse.

Typography

Font sizes derive from a rem base, so a host scales them from the root font size. The optional shared recipe gives --ui-font-size-xs a 12px floor. --ui-control-min-block-size defaults to 44px as an adjustable touch-area comfort preference. It does not enlarge the shared visible control geometry; choose target dimensions in context rather than treating this default as law.

A theme sets one root size, --ui-font-size; every type step derives from that resolved root, so the scale moves as a whole:

StepMultipleUsed for
--ui-font-size-2xs0.6875keyboard hints and micro-labels
--ui-font-size-xs0.75captions and avatar-group counts
--ui-font-size-sm0.875controls, labels, badges, trees, and help text
--ui-font-size-md1body copy, field labels, the editor, and menus
--ui-font-size-lg, -xl, -2xl1.125, 1.25, 1.5headings

Neighbouring steps are at least two pixels apart at the default size: a label one pixel smaller than the text beside it reads as a mistake, not as hierarchy.

FormField and Combobox labels use the body step (16px at the default root), while field help stays on the supporting step (14px). A compact theme can set --ui-field-label-font-size: var(--ui-font-size-sm) to retain smaller labels. In the customizer’s Typography view, Field label size defaults to Follow body text. Choose Set a separate size to author an independent size. The recipe stores this as label-size, and CSS/recipe exports preserve it.

The component-specific --ui-form-field-label-font-size and --ui-combobox-label-font-size hooks take precedence on an individual component.

Choice marks follow typography through derived --ui-choice-mark-size, --ui-choice-track-height and --ui-choice-track-width. The default ratios are 1.125, 1.5 and 2.5 times the base font size. Checkbox and radio marks keep the same size across sm/md/lg rows; those rows align with shared control heights. The switch thumb and travel derive from its track, including component hooks.