OverviewUpdated Oct 21, 2026
DESIGN.md
The guiding star for how Highlight UI looks. It covers the foundations only. For which component to use and how,
read its page: apps/design-system/src/docs/<name>.tsx (when to use it, per example) and
apps/design-system/src/examples/<name>/*.tsx (working code). The list is apps/design-system/src/docs/component-index.ts.
Run the site with pnpm --filter @highlight/design-system dev (port 3010).
Workflows (Claude skills in .claude/skills/): design-system routes UI work, ui-token-migration,
new-ui-component and ui-visual-check (before/after in both themes). pnpm lint:tailwind runs
design/no-legacy-token, which flags the legacy table below.
Sources of truth, if this file and the code disagree the code wins:
packages/ui/src/styles/tokens.semantic.css (colour), tokens.shape.css (radius, hairline, icon stroke),
tokens.shadow.css, typography.ts, and packages/ui/tailwind.config.ts (how tokens become utilities).
Rules
- Use a
@highlight/uicomponent before styling anything by hand. Button, Input, Select, Menu, Dialog, Sheet, Popover, Field and Cell already carry size, radius, focus and every state. Passvariant/tone/size; don't restyle them withclassName. - Semantic tokens only. Never a ramp step (
bg-gray-3), a literal (#fff,bg-white), or an opacity on a token (text-foreground/60,bg-foreground/[2%]). Each token already resolves per theme. An opacity guess breaks in one of the two. - Both themes, always. Check light and dark before calling it done.
- Missing is a finding. If the system has no token, size or component for what you need, add it to the system or say so in the PR. Don't type a value into the screen.
Colour
Grounds. Pick by what the surface is, not by how light you want it.
| Token | Use |
|---|---|
bg-background | The page or panel itself |
bg-background-raised | Sits on the page: a card, a field, a list item |
bg-background-subtle | Chrome attached to content (a code block's head); the hover for a raised row |
bg-background-overlay | Floats over the page: menu, popover, select list, tooltip, toast. Pair with shadow-lg/shadow-xl, never a border |
bg-background-inverse + text-on-inverse | A dark-on-light swap for emphasis. Not tooltips: they sit on the overlay |
bg-background-selected + text-on-selected | A selected row |
Elements, the fill of a control rather than a ground (chip, toggle track, secondary button): bg-element,
-hover, -active, -disabled. They're alpha, so they read on any ground.
Text.
| Token | Use |
|---|---|
text-foreground | Body text, titles |
text-foreground-secondary | Supporting text, metadata, descriptions |
text-foreground-placeholder | Placeholders, timestamps, the quietest text that still carries meaning |
text-foreground-disabled | Disabled only. Under 4.5:1 by design, so never the only place a fact appears |
Tones: brand, danger, success, warning, info. They all have the same shape, so they read by property:
| Utility | What it is |
|---|---|
bg-danger (-hover, -active) + text-on-danger | The solid, and the text on it |
bg-danger-subtle (-hover, -active) | The wash |
text-danger-subtle | Step 11: the coloured text to reach for, readable on the page or on the tone's wash |
text-danger | Step 12: near-black/near-white with a tint, for text that must be the strongest on a wash |
border-danger | The tone's edge |
Category tones (yellow, teal, indigo, purple, pink, brown) have the same shape minus the border. Use them
only to tell labels apart. Red, orange, green and blue are taken by status.
Brand yellow is a fill, never a mark. It measures 1.23:1 on the light page, so it fills a primary action and never carries meaning alone: no yellow text, dots or icons on a light ground.
On is blue. A checkbox, toggle or other control that is set fills with --indicator, not brand. Selection in a
segmented control or a list stays neutral; the position already says it.
Alpha stacks. Hover, selected and the elements are alpha, so they add up. Secondary text on a row that is both
selected and hovered falls under 4.5:1; use text-foreground there.
Fixed grounds (on-light, on-dark) never swap with the theme. Use them for a control over a photo or brand fill.
Where the ground belongs to the theme, use Button's tone="inverse": black in light, white in dark.
Links: text-link, hover:text-link-hover, decoration-link-underline.
Borders and focus
- A bare
borderis the hairline in--border: one device pixel, so 0.5px on 2x and 1px at 1.5x and below, where the resting edges also drop to 60% ink (tokens.resolution.css).border-1is a full pixel. A bareborderalready has the right colour, so don't addborder-border. Write a hand-drawn hairline asvar(--border-hairline), never0.5px. border-strongfor a firmer edge,border-emphasisfor a structural rule (under a table head),border-markerfor the current item (an active tab's underline). Tones:border-dangeretc.- Dividers use the same
border/divide-yhairline. Neverborder-border/5orborder-l-border/10. - Focus:
base.cssdraws a 3px blue outline (1px offset) on every focusable element. Never remove it without a replacement. A field whose wrapper takes focus usesfocus-within:border-[color:var(--focus-ring-color)](or thering-equivalent) withhover:border-hoverat rest. Usering-insetwhen a parent'soverflowwould clip it. Toned rings:focus-ring-danger.
Typography
Use a role, not a size. type-* sets size, leading and tracking together. Weight is separate: compose font-medium
or font-semibold on top. Never write a font size, line height, tracking or weight value in a screen.
| Family | Steps | Use |
|---|---|---|
type-ui-* | xs 12, sm 13 (default), md 14, lg 18 | Controls, labels, rows, menus, dialog titles (lg) |
type-prose-* | xs, sm, md, lg (same sizes, 1.5 leading) | Reading text: chat messages, notes, transcripts |
type-code-* | xs to lg | Code |
type-h3 / h2 / h1 / display | 22 / 28 / 36 / 48 | Section, page section, page titles; display is the brand face |
Pick the step by what the reader does with the text:
- Read,
md14: messages, paragraphs, a dialog's body, an empty state. A heading inside content stays 14 and takesfont-semibold. - Act or scan,
sm13: controls, labels, rows, menus, tabs, table cells. - Note,
xs12: a timestamp, a count, a setting's description, a group label.
A control's size changes its box, never its type: buttons and fields stay 13 at every height.
text-xs, text-sm, text-md and text-lg equal the UI roles and merge cleanly with a component's own text-*.
There is no text-base, text-xl or above: for those, use a heading role. Use tabular-nums where numbers line up.
Spacing
Every distance is a step of the scale: 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64. Each gap is larger than the gaps inside it, or the parts regroup.
| Between | px | Utility |
|---|---|---|
| A name and its own description (title and subtitle, label and note) | 2 | gap-0.5 |
| A label and its control, an icon and its label | 6 | gap-1.5 |
| Items in a row | 8 | gap-2 |
| Rows | 12 | gap-3 |
| Blocks, and a section heading and its first item | 16 | gap-4 |
| Settings in a list | 24 | gap-6 |
| Sections | 48 | gap-12 |
- A heading sits closer to what follows it than to what came before: the gap below is under half the gap above.
- Where text is the first or last thing against a container's edge, that side's padding is the horizontal padding less 4 (12 across, 8 down): the line's leading paints the rest. A control at the row's end sits as far from the side as from the top.
- A component never sets its own outer margin. Whatever places it decides the distance.
Shape
| Utility | px | Use |
|---|---|---|
rounded / rounded-md | 8 | Checkboxes, small toggles |
rounded-lg | 12 | Small tags, inner tiles |
rounded-xl | 16 | Cards, list items |
rounded-2xl | 20 | Panels, popovers, menus |
rounded-3xl | 24 | Sheets, large surfaces |
rounded-full | pill | Pills, dots, avatars; add corner-sharp |
- Controls never set a radius. Button, Input and Select.Trigger take
--radius-size-<size>, which grows slowly with the height: 8 / 9 / 10 / 12 / 20 / 24 at 20 / 24 / 28 / 32 / 40 / 48. A custom control of a given size usesrounded-[var(--radius-size-sm)] corner-controletc. List and menu rows take--radius-smwithcorner-control. - Add
corner-smoothto rounded surfaces. Usecorner-controlon a control's corner (well under half its height), andcorner-squirclewhen the radius is half the height (it reads as a pill otherwise). - Nest concentrically: inner radius = outer radius minus the gap.
Elevation
shadow-xs to shadow-xl. Each step carries its own edge (--shadow-edge, density-aware like the hairline), so
a shadowed surface takes no border.
xs/sm: resting cards.md: hover lift.lg: menus, popovers.xl: dialogs and the topmost layer.
Backdrops: bg-backdrop behind a modal, bg-backdrop-subtle behind a menu.
Motion
One duration and one curve for state: 150ms on cubic-bezier(0.22, 1, 0.36, 1). Colour is what usually moves. Never
animate a layout property, and no bounce.
- Hover and selected grounds fade in and out; nothing snaps on. Button's press is a 0.96 scale.
- Lists (Menu, Context Menu, Select, Combobox) have no intro: they land at once and fade out over 150ms on the state curve. Popovers and tooltips only fade. A tooltip waits 300ms before it opens and closes at once.
- Components turn their own transitions off under reduced motion. Don't add a block to do it again.
- Write a custom curve as
[transition-timing-function:cubic-bezier(…)]. An arbitraryease-class is ambiguous with the animation plugin, and Tailwind silently drops it.
Icons
Use Lucide. Strokes are a fixed 1.15px at every size, so don't set strokeWidth. Button sizes the icons in its slots.
To override, put size-* on the svg itself (<X className="size-4" />), not [&_svg]:size-4 on the button, which
loses to Button's own rule. Icon-only buttons need aria-label.
Legacy tokens: never in new code
These predate the system. Several are inverted: built for the old always-dark overlay, so text-primary and
text-light are near-white in light mode.
| Legacy | Replace with |
|---|---|
text-primary, text-light, text-white (theme text) | text-foreground |
text-secondary-old, text-light/80, text-foreground/80 | text-foreground-secondary |
text-tertiary, text-light/60, text-foreground/40–/60 | text-foreground-placeholder |
bg-secondary-old, bg-primary, bg-bg-layer-1 | bg-background / -raised / -overlay by role |
bg-light-5, bg-white/10, bg-foreground/[2%], bg-hover, bg-tertiary | bg-element (-hover) or bg-background-subtle |
border-light-10, border-white/10, border-border/40 | border (or border-strong) |
text-red-*, text-green-*, light-*/dark-*/primary-*/warning-* numbered colours | The tone utilities above |
cn() from @renderer/chat | twMerge from tailwind-merge |
When you touch a file that uses these, migrate the lines you change and check both themes. A surface that sits on
a legacy ground (for example an attachment menu still on bg-secondary-old) must pin its text to match, or migrate
both together.