Skip to content
Highlight

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

  1. Use a @highlight/ui component before styling anything by hand. Button, Input, Select, Menu, Dialog, Sheet, Popover, Field and Cell already carry size, radius, focus and every state. Pass variant/tone/size; don't restyle them with className.
  2. 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.
  3. Both themes, always. Check light and dark before calling it done.
  4. 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.

TokenUse
bg-backgroundThe page or panel itself
bg-background-raisedSits on the page: a card, a field, a list item
bg-background-subtleChrome attached to content (a code block's head); the hover for a raised row
bg-background-overlayFloats over the page: menu, popover, select list, tooltip, toast. Pair with shadow-lg/shadow-xl, never a border
bg-background-inverse + text-on-inverseA dark-on-light swap for emphasis. Not tooltips: they sit on the overlay
bg-background-selected + text-on-selectedA 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.

TokenUse
text-foregroundBody text, titles
text-foreground-secondarySupporting text, metadata, descriptions
text-foreground-placeholderPlaceholders, timestamps, the quietest text that still carries meaning
text-foreground-disabledDisabled 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:

UtilityWhat it is
bg-danger (-hover, -active) + text-on-dangerThe solid, and the text on it
bg-danger-subtle (-hover, -active)The wash
text-danger-subtleStep 11: the coloured text to reach for, readable on the page or on the tone's wash
text-dangerStep 12: near-black/near-white with a tint, for text that must be the strongest on a wash
border-dangerThe 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 border is 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-1 is a full pixel. A bare border already has the right colour, so don't add border-border. Write a hand-drawn hairline as var(--border-hairline), never 0.5px.
  • border-strong for a firmer edge, border-emphasis for a structural rule (under a table head), border-marker for the current item (an active tab's underline). Tones: border-danger etc.
  • Dividers use the same border/divide-y hairline. Never border-border/5 or border-l-border/10.
  • Focus: base.css draws a 3px blue outline (1px offset) on every focusable element. Never remove it without a replacement. A field whose wrapper takes focus uses focus-within:border-[color:var(--focus-ring-color)] (or the ring- equivalent) with hover:border-hover at rest. Use ring-inset when a parent's overflow would 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.

FamilyStepsUse
type-ui-*xs 12, sm 13 (default), md 14, lg 18Controls, 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 lgCode
type-h3 / h2 / h1 / display22 / 28 / 36 / 48Section, page section, page titles; display is the brand face

Pick the step by what the reader does with the text:

  • Read, md 14: messages, paragraphs, a dialog's body, an empty state. A heading inside content stays 14 and takes font-semibold.
  • Act or scan, sm 13: controls, labels, rows, menus, tabs, table cells.
  • Note, xs 12: 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.

BetweenpxUtility
A name and its own description (title and subtitle, label and note)2gap-0.5
A label and its control, an icon and its label6gap-1.5
Items in a row8gap-2
Rows12gap-3
Blocks, and a section heading and its first item16gap-4
Settings in a list24gap-6
Sections48gap-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

UtilitypxUse
rounded / rounded-md8Checkboxes, small toggles
rounded-lg12Small tags, inner tiles
rounded-xl16Cards, list items
rounded-2xl20Panels, popovers, menus
rounded-3xl24Sheets, large surfaces
rounded-fullpillPills, 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 uses rounded-[var(--radius-size-sm)] corner-control etc. List and menu rows take --radius-sm with corner-control.
  • Add corner-smooth to rounded surfaces. Use corner-control on a control's corner (well under half its height), and corner-squircle when 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 arbitrary ease- 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.

LegacyReplace with
text-primary, text-light, text-white (theme text)text-foreground
text-secondary-old, text-light/80, text-foreground/80text-foreground-secondary
text-tertiary, text-light/60, text-foreground/40–/60text-foreground-placeholder
bg-secondary-old, bg-primary, bg-bg-layer-1bg-background / -raised / -overlay by role
bg-light-5, bg-white/10, bg-foreground/[2%], bg-hover, bg-tertiarybg-element (-hover) or bg-background-subtle
border-light-10, border-white/10, border-border/40border (or border-strong)
text-red-*, text-green-*, light-*/dark-*/primary-*/warning-* numbered coloursThe tone utilities above
cn() from @renderer/chattwMerge 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.