FoundationsUpdated Sep 16, 2026
Color
Every color is a CSS variable in @highlight/ui, exposed as a Tailwind utility. Semantic tokens alias steps of the system scales, so light and dark are two values of the same token and components never branch on theme.
Semantic first. Hierarchy comes from named steps, not from opacities or a second hue.
- Text is
text-foreground, thentext-foreground-secondary(step 11) for captions and secondary rows.text-foreground-placeholderandtext-foreground-disabledsit below 4.5:1 on purpose and never carry content. Notext-foreground/70: an opacity is a guess that drifts per background. - Backgrounds are
bg-background(the page),bg-background-raised(content on it),bg-background-subtle(one step off, like a code block head) andbg-background-overlay(popups, menus, dialogs). A control's own fill is not a background: it isbg-element. - Borders are a bare
border, whose colour is already an alpha step, so no opacity. Neverborder-foreground. The Border page has the rest. - Brand is loud. One primary action per view, and selection. Never as body text.
- System steps (
bg-blue-9,text-orange-11) are for hues no semantic token covers: status, avatars, illustration. Pick the step by its role, not by eye. blackandwhiteare alpha ramps that do not flip with the theme. Use them over photos and solid fills; elsewhere use the semantic tokens.
// Text: named steps, not opacities<p className="text-foreground">Primary</p><p className="text-foreground-secondary">Secondary</p><input className="placeholder:text-foreground-placeholder" /><span className="text-foreground-disabled">Unavailable</span> // Backgrounds and borders. A bare border already takes the default colour, border-border<div className="bg-background" /> // the page<div className="bg-background-raised border rounded-xl" /> // inline content<div className="bg-background-overlay shadow-lg" /> // a popup: the shadow is its edge // Brand only where it means something<button className="bg-brand text-on-brand">Primary action</button> // System steps when no semantic token fits: a hue's solid, its quiet fill, its text<span className="bg-blue-9 text-white-12" /><span className="bg-orange-3 text-orange-11" />Reach for these first. Each one aliases the closest ramp step per theme, so a background stays a background in light and dark.
bg-backgroundThe page
- light
- gray-1
- dark
- gray-1
bg-background-raisedWhat sits on it: a card, a panel, a field
- light
- #ffffff
- dark
- gray-2
bg-background-subtleOne step off the page: a code block head, a side panel
- light
- gray-2
- dark
- gray-3
bg-background-overlayPopups, menus, dialogs; takes shadow-lg, no border
- light
- #ffffff
- dark
- gray-3
text-foregroundBody text, step 12
- light
- gray-12
- dark
- gray-12
text-foreground-secondaryStep 11: captions, secondary rows
- light
- gray-11
- dark
- gray-11
text-foreground-placeholderBelow 4.5:1 on purpose; needs a label
- light
- gray-10
- dark
- gray-10
text-foreground-disabledBelow 4.5:1 and exempt
- light
- gray-8
- dark
- gray-8
borderThe default border; already alpha
- light
- color-mix(in oklab, var(--gray-a-6) var(--border-ink), transparent)
- dark
- color-mix(in oklab, var(--gray-a-6) var(--border-ink), transparent)
The fill, edge and mark of a control. These name a job rather than a colour, so one rule holds in both themes instead of an opacity guess per backdrop.
These sit between the ramps and the components. A ramp step has no meaning on its own; a token gives one step a job, and components read only the job. That is what lets hover:bg-element-hover be one rule that holds in light and dark, for every tone, where hover:bg-foreground/15 is an opacity guess that has to be retuned per backdrop.
- Fills climb
element→element-hover, withelement-activefor selected or open. There is no pressed fill: the press is the scale. - Edges are a border at rest (a bare
border) and a 3:1 boundary under the pointer (border-hover).border-strongis the hairline an outline button carries. - Marks:
text-indicatoris a checked control's check, a step darker than the focus blue so a hairline glyph still clears contrast.
bg-elementA filled control at rest
- light
- gray-a-3
- dark
- gray-a-3
hover:bg-element-hoverThat fill under the pointer
- light
- gray-a-4
- dark
- gray-a-4
bg-element-activeSelected or open
- light
- gray-a-5
- dark
- gray-a-5
bg-element-disabledA control that is unavailable
- light
- gray-a-3
- dark
- gray-a-3
border-hoverThe border becoming a 3:1 boundary
- light
- gray-9
- dark
- gray-9
border-strongThe hairline an outline button carries
- light
- color-mix(in oklab, var(--gray-a-7) var(--border-ink), transparent)
- dark
- color-mix(in oklab, var(--gray-a-7) var(--border-ink), transparent)
border-emphasisStructure: under a table or section head
- light
- gray-11
- dark
- gray-11
border-markerThe current item: active tab, selected row
- light
- gray-12
- dark
- gray-12
border-disabledExempt from 3:1; never the only signal
- light
- color-mix(in oklab, var(--gray-a-5) var(--border-ink), transparent)
- dark
- color-mix(in oklab, var(--gray-a-5) var(--border-ink), transparent)
text-indicatorA checked mark: a menu row’s check
- light
- info
- dark
- blue-9
Five families with one shape: a solid and its hover, a subtle fill and its hover, step-12 text, an edge, and the text on the solid. That shape is what lets a component cross emphasis with intent instead of listing every combination.
Every tone carries the same parts, named by the property that reads them: bg-danger (the solid, with -hover and -active), bg-danger-subtle (the quiet fill, likewise), text-danger (step-12 text) and text-danger-subtle (step 11), border-danger (the edge) and text-on-danger (the text on the solid). Because the shape repeats, a component takes emphasis and intent as two props rather than one flat list, and a destructive action can sit quietly in a menu row or loudly in a dialog without changing component.
- A solid is a fill that carries text, so it comes off the ramp rather than from step 9. Danger and info darken so white holds; brand, success and warning stay light and take dark text.
- A subtle fill is steps 3 to 5, the same climb the elements make. Step-12 text always clears AA on it.
- The ring is an outline in
--focus-ring-color, 3px wide and offset 1px, drawn on every focusable element by the base layer. The offset is an empty gap, so it holds on any background, and as an outline it never touches the element's own shadow. Most controls need nothing; a toned one takesfocus-ring-dangerorfocus-ring-success. - Fixed backgrounds (
on-light,on-dark) are built from the alpha ramps and never swap with the theme, and take the tone's shape:bg-on-light,bg-on-light-subtle,text-on-light,border-on-light, andtext-on-light-inverseon the solid. Use them over a photo or a brand fill; where the system owns the background,.darkon an ancestor is the better tool.
// A control names the job, never the step. One rule, correct in both themes.<button className="bg-element text-foreground hover:bg-element-hover"/> // A field: background, a border at rest, a 3:1 boundary under the pointer<input className="bg-background-raised border hover:border-hover"/> // Emphasis crosses with intent, so each tone answers all four levels<button className="bg-danger text-on-danger"/> // solid<button className="bg-danger-subtle text-danger"/> // quiet<button className="border border-danger text-danger"/> // outline<button className="text-danger hover:bg-danger-subtle"/> // ghost // Every focusable element gets the ring from base.css, an outline in --focus-ring-color.// A shadow on the element stays through focus; only a tone needs a utility<button className="shadow-md"/><button className="focus-visible:focus-ring-danger"/> // a destructive control // Over a photo or a brand fill, where the system does not own the background<button className="bg-on-dark text-on-dark-inverse"/>bg-brandHighlight yellow, the solid
- light
- brand-9
- dark
- brand-9
bg-brand-subtleStep 3, the quiet fill
- light
- brand-3
- dark
- brand-3
text-brandStep 12, legible on the subtle fill
- light
- brand-12
- dark
- brand-12
border-brandStep 7, the outline edge
- light
- brand-7
- dark
- brand-7
text-on-brandText that clears AA on the solid
- light
- black-12
- dark
- black-12
bg-dangerDarkened so it carries white text
- light
- #cc100f
- dark
- #cb2d25
bg-danger-subtleStep 3, the quiet fill
- light
- red-3
- dark
- red-3
text-dangerStep 12, legible on the subtle fill
- light
- red-12
- dark
- red-12
border-dangerStep 7, the outline edge
- light
- red-7
- dark
- red-7
text-on-dangerText that clears AA on the solid
- light
- white-12
- dark
- white-12
bg-successLifted green, so it takes dark text
- light
- #4ad769
- dark
- #3cd960
bg-success-subtleStep 3, the quiet fill
- light
- green-3
- dark
- green-3
text-successStep 12, legible on the subtle fill
- light
- green-12
- dark
- green-12
border-successStep 7, the outline edge
- light
- green-7
- dark
- green-7
text-on-successText that clears AA on the solid
- light
- black-12
- dark
- black-12
bg-warningOrange step 9, takes dark text
- light
- orange-9
- dark
- orange-9
bg-warning-subtleStep 3, the quiet fill
- light
- orange-3
- dark
- orange-3
text-warningStep 12, legible on the subtle fill
- light
- orange-12
- dark
- orange-12
border-warningStep 7, the outline edge
- light
- orange-7
- dark
- orange-7
text-on-warningText that clears AA on the solid
- light
- black-12
- dark
- black-12
bg-infoDarkened blue, carries white text
- light
- #0062d9
- dark
- #0067d5
bg-info-subtleStep 3, the quiet fill
- light
- blue-3
- dark
- blue-3
text-infoStep 12, legible on the subtle fill
- light
- blue-12
- dark
- blue-12
border-infoStep 7, the outline edge
- light
- blue-7
- dark
- blue-7
text-on-infoText that clears AA on the solid
- light
- white-12
- dark
- white-12
Six families that carry no meaning, only a difference from the other five. Red, orange, green and blue are spoken for by status, so they never label a topic.
bg-yellowThe solid
- light
- yellow-9
- dark
- yellow-9
bg-yellow-subtleThe quiet fill
- light
- yellow-3
- dark
- yellow-3
text-yellowStep 12 on the subtle fill
- light
- yellow-12
- dark
- yellow-12
bg-tealThe solid
- light
- #51cae1
- dark
- #44cbe3
bg-teal-subtleThe quiet fill
- light
- teal-3
- dark
- teal-3
text-tealStep 12 on the subtle fill
- light
- teal-12
- dark
- teal-12
bg-indigoThe solid
- light
- indigo-9
- dark
- indigo-9
bg-indigo-subtleThe quiet fill
- light
- indigo-3
- dark
- indigo-3
text-indigoStep 12 on the subtle fill
- light
- indigo-12
- dark
- indigo-12
bg-purpleThe solid
- light
- #9540be
- dark
- #9b3fc8
bg-purple-subtleThe quiet fill
- light
- purple-3
- dark
- purple-3
text-purpleStep 12 on the subtle fill
- light
- purple-12
- dark
- purple-12
bg-pinkThe solid
- light
- #cd0039
- dark
- #d01144
bg-pink-subtleThe quiet fill
- light
- pink-3
- dark
- pink-3
text-pinkStep 12 on the subtle fill
- light
- pink-12
- dark
- pink-12
bg-brownThe solid
- light
- #7e6442
- dark
- #816745
bg-brown-subtleThe quiet fill
- light
- brown-3
- dark
- brown-3
text-brownStep 12 on the subtle fill
- light
- brown-12
- dark
- brown-12
Colours with one job each: a highlighted passage, a selection, a placeholder, the dim behind a modal, a link, and the inverse surface for a dark-on-light swap.
bg-highlightA highlighted passage
- light
- gray-3
- dark
- gray-3
border-highlight-lineThe rule beside it
- light
- gray-8
- dark
- gray-8
bg-selectionText selection, brand step 5
- light
- brand-5
- dark
- brand-5
bg-skeletonContent that has not arrived; alpha
- light
- gray-a-4
- dark
- gray-a-4
bg-backdropThe dim behind a modal; black in both themes
- light
- black-8
- dark
- black-8
bg-backdrop-subtleA menu’s backdrop: only a hint
- light
- black-7
- dark
- black-7
bg-background-selectedA selected row
- light
- brand-3
- dark
- brand-3
text-on-selectedText on a selected row
- light
- gray-12
- dark
- gray-12
text-linkBlue step 11
- light
- blue-11
- dark
- blue-11
hover:text-link-hoverThe link under the pointer, blue step 12
- light
- blue-12
- dark
- blue-12
decoration-link-underlineFirms to the link colour on hover
- light
- blue-8
- dark
- blue-8
text-code-textInline code
- light
- red-11
- dark
- red-11
bg-code-backgroundBehind inline code
- light
- gray-3
- dark
- gray-3
bg-background-inverseA dark-on-light swap; tooltips use the overlay
- light
- gray-12
- dark
- gray-12
text-on-inverseText on the inverse surface
- light
- gray-1
- dark
- gray-1
text-on-inverse-secondarySecondary text there; alpha
- light
- white-11
- dark
- black-11
Radix-style 12-step scales generated from the brand yellow and a pure neutral. A step means the same thing in every hue.
- Backgrounds
- 1App background2Subtle background
- Elements
- 3Element background4Hovered element5Active element
- Borders
- 6Subtle border7Border8Strong border, focus ring
- Solids
- 9Solid10Hovered solid
- Text
- 11Low-contrast text12High-contrast text
grayPure neutral
blackFixed black alpha; does not flip with the theme
whiteFixed white alpha; does not flip with the theme
brandHighlight yellow, generated from #F9F33D
redorangeyellowgreentealblueindigopurplepinkbrownRamps live in packages/ui/src/styles/tokens.color.css and semantic aliases in tokens.semantic.css and base.css; this page reads both at build time.
/* tokens.color.css: System 12-step ramps, light in :root and dark under .dark */:root { --gray-2: oklch(98.1% 0 0); --brand-9: oklch(91.5% 0.1866 107.5); }.dark { --gray-2: oklch(21.3% 0 0); --brand-9: oklch(94.0% 0.1866 107.5); } /* tokens.semantic.css and base.css: semantic tokens alias the closest step per theme */:root { --background: var(--gray-1); --background-raised: oklch(100% 0 0); --brand: var(--brand-9); }.dark { --background-raised: var(--gray-2); } /* tailwind.config.ts (from @highlight/ui): relative colour syntax keeps opacity modifiers working */colors: { background: 'oklch(from var(--background) l c h / <alpha-value>)' } /* .dark on <html> switches the theme; .dark.low-contrast lifts the darkest surfaces */