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.
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.
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)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)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)`--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)`--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)--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)--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)--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)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)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)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)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)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-lgDesign systems, from the inside
text-h1Your first component
text-h2What a token actually is
text-h3Naming things by their job
text-body-lgLesson prose, set at sixteen with looser leading because somebody is going to read a paragraph of it.
text-bodyInterface default. Labels, values, descriptions.
text-body-smDense tables and secondary copy.
text-labelField label
text-captionMetadata and timestamps
text-microLEGEND · 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”.
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.
Two plain properties, because dark.
The default for a resting card or list item.
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.
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>}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.
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.
Eight tones times three variants. Subtle is the default because badges live in lists, and a list of solid fills is a Christmas tree.
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.