Skip to content
Design system

Foundations

Three layers, in one direction. Generated ramps carry no meaning; roles give them a job; components only ever speak in roles. Change a role and everything downstream follows without a component being edited.

Palette

Raw material. Generated from one lightness curve and one chroma curve, so a step means the same thing in every hue — and gamut-clamped, so a ramp never promises a colour sRGB cannot show.

neutralthe only gray — no slate, no zinc
50
75
100
150
200
250
300
400
500
600
700
800
850
900
950
sky
50
100
200
300
400
500
600
700
800
900
950
red
50
100
200
300
400
500
600
700
800
900
950
amber
50
100
200
300
400
500
600
700
800
900
950
green
50
100
200
300
400
500
600
700
800
900
950
teal
50
100
200
300
400
500
600
700
800
900
950
violet
50
100
200
300
400
500
600
700
800
900
950

Roles

Where every decision lives. A component references these and never a ramp step, which is what makes a rebrand an edit to this list rather than a sweep through sixty files.

Surfaces — the ladder

page → card → popover → chip. Each rung is one step lighter than the one it sits on, so depth reads without a shadow doing all the work.

--backgroundvar(--neutral-75)
--cardoklch(1 0 0)
--popoveroklch(1 0 0)
--overlayoklch(0.145 0 0 / 0.28)
--foregroundvar(--ink-strong)
--card-foregroundvar(--ink-strong)
--popover-foregroundvar(--ink-strong)
Ink — say what the text IS

Hierarchy comes from these four, in this order, before it comes from weight. Reach for `--ink-muted` before you reach for a lighter font.

--ink-strongvar(--neutral-900)
--ink-bodyvar(--neutral-700)
--ink-mutedvar(--neutral-500)
--ink-disabledvar(--neutral-300)
--ink-brandvar(--primary)
--ink-on-solidvar(--neutral-50)
--icon-strongvar(--neutral-900)
--icon-bodyvar(--neutral-700)
--icon-mutedvar(--neutral-500)
--icon-disabledvar(--neutral-300)
--icon-brandvar(--primary)
--icon-on-solidvar(--neutral-50)
Brand

The seven-token tint shape every coloured family in this file follows: DEFAULT the solid fill (ramp 600) -hover that fill, one step down (700) -active pressed (800) -foreground ink ON the solid fill (neutral-50) -subtle the tinted background (50) -subtle-hover that tint, one step down (100) -border the border for -subtle (200) -ink text ON -subtle (700) Learn it once and every family is predictable. -subtle-hover exists because tinted controls are interactive too. Without it a `subtle` button had no hover at all: the only honest step from a 50 tint is the 100, and "hover is a different token, never an opacity" has to apply to tints as well as to fills or it is not a rule.

--primaryvar(--sky-600)
--primary-hovervar(--sky-700)
--primary-activevar(--sky-800)
--primary-foregroundvar(--neutral-50)
--primary-subtlevar(--sky-50)
--primary-subtle-hovervar(--sky-100)
--primary-bordervar(--sky-200)
--primary-inkvar(--sky-700)
--primary-washcolor-mix(in oklab, var(--primary) 10%, transparent)
Neutral interactives

`--secondary`, `--muted` and `--accent` are all neutral surfaces and all three exist because shadcn's components expect all three. They are deliberately NOT aliases of each other: secondary is a button fill, muted is a resting background, accent is a hover/selected highlight.

--secondaryvar(--neutral-100)
--secondary-hovervar(--neutral-200)
--secondary-foregroundvar(--neutral-900)
--mutedvar(--neutral-100)
--muted-hovervar(--neutral-150)
--muted-foregroundvar(--neutral-500)
--accentvar(--neutral-100)
--accent-hovervar(--neutral-150)
--accent-foregroundvar(--neutral-900)
--neutral-solidvar(--neutral-900)
--neutral-solid-hovervar(--neutral-800)
--neutral-solid-activevar(--neutral-700)
--neutral-solid-foregroundvar(--neutral-50)
Lines

`--border` is the hairline you use 95% of the time. `--border-strong` is for a divider that has to survive against a busy surface. `--input` is separate because form controls carry a heavier edge than cards do.

--bordervar(--neutral-200)
--border-strongvar(--neutral-300)
--inputvar(--neutral-300)
--ringvar(--primary)
Status · Success
--successvar(--green-600)
--success-hovervar(--green-700)
--success-activevar(--green-800)
--success-foregroundvar(--neutral-50)
--success-subtlevar(--green-50)
--success-subtle-hovervar(--green-100)
--success-bordervar(--green-200)
--success-inkvar(--green-700)
Status · Danger (+ the shadcn `destructive` aliases)
--dangervar(--red-600)
--danger-hovervar(--red-700)
--danger-activevar(--red-800)
--danger-foregroundvar(--neutral-50)
--danger-subtlevar(--red-50)
--danger-subtle-hovervar(--red-100)
--danger-bordervar(--red-200)
--danger-inkvar(--red-700)
--destructivevar(--danger)
--destructive-hovervar(--danger-hover)
--destructive-foregroundvar(--danger-foreground)
Status · Warning
--warningvar(--amber-600)
--warning-hovervar(--amber-700)
--warning-activevar(--amber-800)
--warning-foregroundvar(--neutral-50)
--warning-subtlevar(--amber-50)
--warning-subtle-hovervar(--amber-100)
--warning-bordervar(--amber-200)
--warning-inkvar(--amber-800)
Status · Info

Points at the SAME ramp as the brand, on purpose. With a blue brand there is no second blue far enough away to be worth having: an info family at hue 245 beside a brand at 248.7 is a distinction nobody can see, and the separation gate in the palette generator now fails that outright. An informational message and a brand message are the same register, so they share a colour. The role stays separate so it can diverge later without every <Alert tone="info"> being touched.

--infovar(--sky-600)
--info-hovervar(--sky-700)
--info-activevar(--sky-800)
--info-foregroundvar(--neutral-50)
--info-subtlevar(--sky-50)
--info-subtle-hovervar(--sky-100)
--info-bordervar(--sky-200)
--info-inkvar(--sky-700)
Editorial families

No status meaning. For categorising content — a lesson track, a tag, a concept group — where colour is an identity and not a verdict.

--violetvar(--violet-600)
--violet-hovervar(--violet-700)
--violet-foregroundvar(--neutral-50)
--violet-subtlevar(--violet-50)
--violet-subtle-hovervar(--violet-100)
--violet-bordervar(--violet-200)
--violet-inkvar(--violet-700)
--tealvar(--teal-600)
--teal-hovervar(--teal-700)
--teal-foregroundvar(--neutral-50)
--teal-subtlevar(--teal-50)
--teal-subtle-hovervar(--teal-100)
--teal-bordervar(--teal-200)
--teal-inkvar(--teal-700)
Selection

A selected row is a soft tint plus brand ink at the SAME font weight as its unselected neighbours. Bolding the label on select makes the row jump a pixel and the list twitch — and it reads as emphasis, not as state.

--selectedvar(--primary-subtle)
--selected-bordervar(--primary-border)
--selected-foregroundvar(--primary-ink)
Charts

Ordered for maximum separation between ADJACENT series, because series 1 and 2 are the pair a reader compares most. Categorical only — a sequential or diverging scale is a different job and should be derived per chart.

--chart-1var(--sky-600)
--chart-2var(--amber-500)
--chart-3var(--violet-600)
--chart-4var(--green-600)
--chart-5var(--red-600)
--chart-6var(--teal-600)
--chart-7var(--sky-300)
--chart-8var(--violet-300)
Sidebar (shadcn compat)

The sidebar gets its own surface set so it can sit a step off the page without every nav component reaching for a neutral ramp step directly.

--sidebarvar(--neutral-50)
--sidebar-foregroundvar(--ink-body)
--sidebar-primaryvar(--primary)
--sidebar-primary-foregroundvar(--primary-foreground)
--sidebar-accentvar(--primary-subtle)
--sidebar-accent-foregroundvar(--primary-ink)
--sidebar-bordervar(--neutral-200)
--sidebar-ringvar(--ring)

Type

Geist, two families. Each step carries its own leading, weight and tracking, so a heading is one class and there is no way to take three-quarters of it. Three weights total — 700 and above are removed from the theme.

text-display-lg

Design systems, from the inside

text-h1

Your first component

text-h2

What a token actually is

text-h3

Naming things by their job

text-body-lg

Lesson prose, set at sixteen with looser leading because somebody is going to read a paragraph of it.

text-body

Interface default. Labels, values, descriptions.

text-body-sm

Dense tables and secondary copy.

text-label

Field label

text-caption

Metadata and timestamps

text-micro

LEGEND · AXIS · BADGE

Shape

Every radius derives from one anchor, so retuning the system's feel is a one-line change. When a rounded thing sits inside another, the inner radius is the outer minus the padding — mismatched corners are the most common cause of “it looks off and I can't say why”.

xs
sm
md
lg
xl
2xl
3xl
4xl
full

Depth

Shadows are stacked at two to five per cent across many layers, with blur growing as the shadow falls. In dark mode most of this stops working — there is no light on a near-black page to occlude — so the ring inverts to a faint light edge and the surface ladder does the separating.

color

Two plain properties, because dark.

ring
hairline

The default for a resting card or list item.

thumb

A seated thumb — segmented control, switch, slider.

Motion

Durations and easings as tokens, because timing is a system decision. Entering earns a moment; leaving should get out of the way, so exits run at roughly two-thirds of entrances. All of it collapses under prefers-reduced-motion.

--duration-instant50msPressed states, active
--duration-fast120msColour, hover-out, focus ring
--duration-base180msHover-in, small reveals, tooltips
--duration-slow240msPopovers, dropdowns, accordions
--duration-slower320msDialogs, drawers, page-level transitions
--ease-outcubic-bezier(0.22, 1, 0.36, 1)Easings. -out things arriving: fast in, settles. The default for entrances. -in things leaving: eases away. Only for exits. -in-out symmetric, for something already in motion (a moving thumb). -spring a single overshoot, for an element that should feel physical. Use it on scale, not on position, and not more than once per screen — overshoot everywhere reads as a toy.
--ease-incubic-bezier(0.64, 0, 0.78, 0)
--ease-in-outcubic-bezier(0.65, 0, 0.35, 1)
--ease-springcubic-bezier(0.34, 1.56, 0.64, 1)
--travel-xs2pxA press nudge
--travel-sm4pxTooltip, small popover
--travel-md8pxDropdown, dialog
--travel-lg16pxSection reveal
--stagger30msStagger step for a list that enters as a group. Past ~6 items stop staggering and fade the group — a long stagger is a queue, not a flourish.

Code

Code is this product's main content type, so the code theme is part of the design system rather than a borrowed stylesheet. Brand rose appears nowhere in it — syntax that shares a hue with the interface makes the interface harder to find.

button.tsxtsx
import { cva } from 'class-variance-authority' // One class per role. Never a ramp step.const chip = cva('inline-flex items-center', {  variants: {    tone: {      brand: 'bg-primary-subtle text-primary-ink',      danger: 'bg-danger-subtle text-danger-ink',    }  },  defaultVariants: { tone: 'brand' },}) export function Chip({ tone, children }: ChipProps) {  return <span className={chip({ tone })}>{children}</span>}
plain
comment
punctuation
operator
keyword
string
number
boolean
function
type
constant
regex
tag
attribute
attribute-value
property
selector
unit
heading
link
invalid
deprecated
match

Components

Sixty-one components come from the shadcn registry and stay there, untouched, so updates land cleanly. Three are promoted — Button, Badge and Alert — because their variant vocabulary is the thing this system replaces.

Button

Seven variants across two axes: what it means, and how loud it is. Shape is a separate prop from size, so a square button and a wide one are the same scale.

Badge

Eight tones times three variants. Subtle is the default because badges live in lists, and a list of solid fills is a Christmas tree.

neutralbrandsuccesswarningdangerinfovioletteal
neutralbrandsuccesswarningdangerinfovioletteal
neutralbrandsuccesswarningdangerinfovioletteal
Alert

A complete tint family used as a set — subtle fill, matching border, family ink. Tone is a promise about what happens next, not a way to be noticed.