Skip to content
Highlight

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, then text-foreground-secondary (step 11) for captions and secondary rows. text-foreground-placeholder and text-foreground-disabled sit below 4.5:1 on purpose and never carry content. No text-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) and bg-background-overlay (popups, menus, dialogs). A control's own fill is not a background: it is bg-element.
  • Borders are a bare border, whose colour is already an alpha step, so no opacity. Never border-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.
  • black and white are alpha ramps that do not flip with the theme. Use them over photos and solid fills; elsewhere use the semantic tokens.
tsx
// 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-background

The page

light
gray-1
dark
gray-1
bg-background-raised

What sits on it: a card, a panel, a field

light
#ffffff
dark
gray-2
bg-background-subtle

One step off the page: a code block head, a side panel

light
gray-2
dark
gray-3
bg-background-overlay

Popups, menus, dialogs; takes shadow-lg, no border

light
#ffffff
dark
gray-3
text-foreground

Body text, step 12

light
gray-12
dark
gray-12
text-foreground-secondary

Step 11: captions, secondary rows

light
gray-11
dark
gray-11
text-foreground-placeholder

Below 4.5:1 on purpose; needs a label

light
gray-10
dark
gray-10
text-foreground-disabled

Below 4.5:1 and exempt

light
gray-8
dark
gray-8
border

The 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, with element-active for 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-strong is the hairline an outline button carries.
  • Marks: text-indicator is a checked control's check, a step darker than the focus blue so a hairline glyph still clears contrast.
bg-element

A filled control at rest

light
gray-a-3
dark
gray-a-3
hover:bg-element-hover

That fill under the pointer

light
gray-a-4
dark
gray-a-4
bg-element-active

Selected or open

light
gray-a-5
dark
gray-a-5
bg-element-disabled

A control that is unavailable

light
gray-a-3
dark
gray-a-3
border-hover

The border becoming a 3:1 boundary

light
gray-9
dark
gray-9
border-strong

The 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-emphasis

Structure: under a table or section head

light
gray-11
dark
gray-11
border-marker

The current item: active tab, selected row

light
gray-12
dark
gray-12
border-disabled

Exempt 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-indicator

A 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 takes focus-ring-danger or focus-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, and text-on-light-inverse on the solid. Use them over a photo or a brand fill; where the system owns the background, .dark on an ancestor is the better tool.
tsx
// 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-brand

Highlight yellow, the solid

light
brand-9
dark
brand-9
bg-brand-subtle

Step 3, the quiet fill

light
brand-3
dark
brand-3
text-brand

Step 12, legible on the subtle fill

light
brand-12
dark
brand-12
border-brand

Step 7, the outline edge

light
brand-7
dark
brand-7
text-on-brand

Text that clears AA on the solid

light
black-12
dark
black-12
bg-danger

Darkened so it carries white text

light
#cc100f
dark
#cb2d25
bg-danger-subtle

Step 3, the quiet fill

light
red-3
dark
red-3
text-danger

Step 12, legible on the subtle fill

light
red-12
dark
red-12
border-danger

Step 7, the outline edge

light
red-7
dark
red-7
text-on-danger

Text that clears AA on the solid

light
white-12
dark
white-12
bg-success

Lifted green, so it takes dark text

light
#4ad769
dark
#3cd960
bg-success-subtle

Step 3, the quiet fill

light
green-3
dark
green-3
text-success

Step 12, legible on the subtle fill

light
green-12
dark
green-12
border-success

Step 7, the outline edge

light
green-7
dark
green-7
text-on-success

Text that clears AA on the solid

light
black-12
dark
black-12
bg-warning

Orange step 9, takes dark text

light
orange-9
dark
orange-9
bg-warning-subtle

Step 3, the quiet fill

light
orange-3
dark
orange-3
text-warning

Step 12, legible on the subtle fill

light
orange-12
dark
orange-12
border-warning

Step 7, the outline edge

light
orange-7
dark
orange-7
text-on-warning

Text that clears AA on the solid

light
black-12
dark
black-12
bg-info

Darkened blue, carries white text

light
#0062d9
dark
#0067d5
bg-info-subtle

Step 3, the quiet fill

light
blue-3
dark
blue-3
text-info

Step 12, legible on the subtle fill

light
blue-12
dark
blue-12
border-info

Step 7, the outline edge

light
blue-7
dark
blue-7
text-on-info

Text 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-yellow

The solid

light
yellow-9
dark
yellow-9
bg-yellow-subtle

The quiet fill

light
yellow-3
dark
yellow-3
text-yellow

Step 12 on the subtle fill

light
yellow-12
dark
yellow-12
bg-teal

The solid

light
#51cae1
dark
#44cbe3
bg-teal-subtle

The quiet fill

light
teal-3
dark
teal-3
text-teal

Step 12 on the subtle fill

light
teal-12
dark
teal-12
bg-indigo

The solid

light
indigo-9
dark
indigo-9
bg-indigo-subtle

The quiet fill

light
indigo-3
dark
indigo-3
text-indigo

Step 12 on the subtle fill

light
indigo-12
dark
indigo-12
bg-purple

The solid

light
#9540be
dark
#9b3fc8
bg-purple-subtle

The quiet fill

light
purple-3
dark
purple-3
text-purple

Step 12 on the subtle fill

light
purple-12
dark
purple-12
bg-pink

The solid

light
#cd0039
dark
#d01144
bg-pink-subtle

The quiet fill

light
pink-3
dark
pink-3
text-pink

Step 12 on the subtle fill

light
pink-12
dark
pink-12
bg-brown

The solid

light
#7e6442
dark
#816745
bg-brown-subtle

The quiet fill

light
brown-3
dark
brown-3
text-brown

Step 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-highlight

A highlighted passage

light
gray-3
dark
gray-3
border-highlight-line

The rule beside it

light
gray-8
dark
gray-8
bg-selection

Text selection, brand step 5

light
brand-5
dark
brand-5
bg-skeleton

Content that has not arrived; alpha

light
gray-a-4
dark
gray-a-4
bg-backdrop

The dim behind a modal; black in both themes

light
black-8
dark
black-8
bg-backdrop-subtle

A menu’s backdrop: only a hint

light
black-7
dark
black-7
bg-background-selected

A selected row

light
brand-3
dark
brand-3
text-on-selected

Text on a selected row

light
gray-12
dark
gray-12
text-link

Blue step 11

light
blue-11
dark
blue-11
hover:text-link-hover

The link under the pointer, blue step 12

light
blue-12
dark
blue-12
decoration-link-underline

Firms to the link colour on hover

light
blue-8
dark
blue-8
text-code-text

Inline code

light
red-11
dark
red-11
bg-code-background

Behind inline code

light
gray-3
dark
gray-3
bg-background-inverse

A dark-on-light swap; tooltips use the overlay

light
gray-12
dark
gray-12
text-on-inverse

Text on the inverse surface

light
gray-1
dark
gray-1
text-on-inverse-secondary

Secondary 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 background
2Subtle background
Elements
3Element background
4Hovered element
5Active element
Borders
6Subtle border
7Border
8Strong border, focus ring
Solids
9Solid
10Hovered solid
Text
11Low-contrast text
12High-contrast text
gray

Pure neutral

black

Fixed black alpha; does not flip with the theme

white

Fixed white alpha; does not flip with the theme

brand

Highlight yellow, generated from #F9F33D

red
orange
yellow
green
teal
blue
indigo
purple
pink
brown

Ramps 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.

css
/* 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 */