[§] /docs/rules/tokens-and-colour
Tokens and colour
Two tiers of tokens, one naming pattern, a palette of neutrals plus one accent, and contrast you measure.
TL;DR
- Two tiers: primitives hold values, semantic tokens give them a job.
- Components read semantic tokens only. Themes redefine semantic tokens only.
- Names follow
--{category}-{role}-{variant}-{state}, in kebab-case. - Palette: about 90% neutrals, 8% one accent, 2% semantic colours.
- Body text 4.5:1, large text 3:1. Build ramps in OKLCH.
Name things by what they do, not by what they look like. --color-text-soft survives a rebrand.
--grey-600 does not, because the day the soft text turns warm, the name lies.
This page sets the token tiers, the naming pattern for CSS variables, how they plug into Tailwind v4, and how to name components, files and classes.
Two tiers, one direction
[01]
Primitive
--color-v-ink: #f5f5f5 · the raw value
[02]
Semantic
--accent: var(--color-v-ink) · a job
[03]
Component (optional)
--button-bg · only when a component needs a knob
[04]
Usage
bg-accent, var(--color-accent)
Two tiers, one direction: table (6 rows)
| Tier | Example | Value | Who may read it |
|---|---|---|---|
| Primitive | --color-v-ink | #f5f5f5 | Semantic tokens only |
| Primitive | --color-v-bg | #080808 | Semantic tokens only |
| Primitive | --color-c-4 | #00b3ff | Semantic tokens, colour experiences |
| Semantic | --accent | var(--color-v-ink) | Components, patterns |
| Semantic | --text-soft | var(--color-v-dim) | Components, patterns |
| Component | --button-height-md | 40px | That component only |
- MUST keep components off primitives. A button reads
--accent, never--color-v-inkor a raw hex. - MUST switch themes by redefining semantic tokens only. Primitives never change between light and dark.
- SHOULD number primitive ramps 50, 100, 200 to 900, 950, as Tailwind does, with 50 lightest.
- SHOULD add component tokens only when a value is shared by several parts of one component or needs overriding from outside.
- SHOULD NOT create a semantic token used in exactly one place. Use the primitive through an existing role, or add the role properly.
The naming pattern
Every CSS variable follows one shape, read left to right from general to specific:
The naming pattern: copy the txt (8 lines)
--{category}-{role}-{variant}-{state}
--color-text category + role
--color-text-soft + variant
--color-bg-raised + variant
--color-border-strong + variant
--color-accent-hover + state
--color-danger-text role + partUse a fixed list of categories and roles, so names are guessable:
The naming pattern: table (11 rows)
| Category | Roles and variants | Examples |
|---|---|---|
color | bg, surface, text, border, accent, focus, success, warning, danger | --color-bg, --color-surface-raised, --color-text-soft, --color-border-strong |
font | sans, display, mono | --font-display |
text | size steps xs to 7xl | --text-sm, --text-2xl |
leading | tight, snug, normal, relaxed | --leading-snug |
tracking | tight, normal, wide | --tracking-tight |
space | scale steps 1 to 32 (× 4px) | --space-4 is 16px |
radius | xs, sm, md, lg, xl, full | --radius-md |
shadow | 1 to 4 by elevation | --shadow-2 |
z | named layers | --z-modal |
duration | instant, fast, base, slow | --duration-fast |
ease | out, in-out, in | --ease-out |
Rules for the names themselves:
- MUST use lowercase kebab-case:
--color-text-soft, not--colorTextSoftor--color_text_soft. - MUST put state last:
--color-accent-hover, never--color-hover-accent. - MUST NOT put a value or a hue in a semantic name: no
--color-accent-white, no--space-16px. - SHOULD use
softandstrongfor the two variants either side of a default, and stop there. Three tones of one role is enough. - SHOULD name paired foregrounds
-fg:--color-accentand--color-accent-fg(the text that sits on it).
Tokens in Tailwind v4
Tailwind v4 reads tokens from @theme and turns each namespace into utilities. The namespace decides
the utility, so the naming pattern above maps straight across.
Tokens in Tailwind v4: table (9 rows)
@theme namespace | Utilities it creates |
|---|---|
--color-* | bg-*, text-*, border-*, fill-*, ring-* |
--font-* | font-* |
--text-* | text-* (size) |
--leading-* | leading-* |
--radius-* | rounded-* |
--shadow-* | shadow-* |
--ease-* | ease-* |
--breakpoint-* | sm:, md: and so on |
--spacing | one base unit; p-4 is calc(var(--spacing) * 4) |
Primitives go in @theme. Semantic tokens are plain variables on :root that change per theme, then
exposed to Tailwind with @theme inline, so the utility points at the live variable:
Tokens in Tailwind v4: copy the css (32 lines)
@import 'tailwindcss';
/* 1. Primitives: fixed values. Mono first, one loud layer for colour moments. */
@theme {
--color-v-bg: #080808;
--color-v-surface: #0e0e0e;
--color-v-ink: #f5f5f5;
--color-v-dim: #8a8a8a;
--color-v-steel: #2c2c2c;
--color-c-2: #ff00a8; /* colour stage only */
--color-c-4: #00b3ff; /* colour stage only */
}
/* 2. Semantic tokens: jobs. A light theme would redefine only these. */
:root {
--bg: var(--color-v-bg);
--surface-raised: var(--color-v-surface);
--text: var(--color-v-ink);
--text-soft: var(--color-v-dim);
--border: var(--color-v-steel);
--accent: var(--color-v-ink); /* the accent is white: the mono stage is pure black and white */
}
/* 3. Expose them as utilities: bg-bg, text-text-soft, border-border, bg-accent. */
@theme inline {
--color-bg: var(--bg);
--color-surface-raised: var(--surface-raised);
--color-text: var(--text);
--color-text-soft: var(--text-soft);
--color-border: var(--border);
--color-accent: var(--accent);
}This guide's own app/global.css is a worked example of the split. The primitives live in @theme:
--color-v-* for the mono stage (#080808 ground, #f5f5f5 ink), --color-w-* for the Win95
opening, and --color-c-1 to --color-c-6 for the colour finale. Semantic tokens such as --surface,
--text-soft, --rule and --accent sit on :root. The accent is white, because the mono stage is
pure black and white; colour only arrives inside colour experiences. The site drops the color-
prefix on semantic names because it has only a handful. Past about 20, use the full pattern.
- MUST use
@theme inlinefor tokens whose value is another variable, or the utility freezes the light value. - MUST NOT use arbitrary values (
bg-[#00b3ff],mt-[13px]) in components. Add a token or use the scale. - SHOULD remove Tailwind's default palette (
--color-*: initial;inside@theme) once your own is complete, sobg-blue-500cannot sneak in.
Components and files
One rule per kind of name. Match what the framework already does, so nothing needs explaining.
Components and files: table (12 rows)
| Thing | Convention | Example |
|---|---|---|
| Component | PascalCase, a noun | PriceCard, DatePicker |
| Component file | kebab-case, matches the component | price-card.tsx |
| Props type | Component name + Props | PriceCardProps |
| Hook | use + PascalCase, file in kebab-case | useMediaQuery in use-media-query.ts |
| Event prop | on + noun + verb | onValueChange, onOpenChange |
| Handler inside a component | handle + event | handleSubmit |
| Boolean prop | the HTML word when one exists | disabled, open, required |
| Other boolean | is, has, can + adjective | isPending, hasError |
| Constant | SCREAMING_SNAKE_CASE | MAX_UPLOAD_MB |
| CSS variable | kebab-case, pattern above | --color-text-soft |
| Data attribute for state | data- + state name | data-state="open", data-disabled |
| Next.js route file | fixed names | page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx |
- MUST keep one exported component per file, unless the others are its parts (
Card,CardHeader,CardBody). - MUST prefix compound parts with the parent name:
DialogTitle, notTitle. - SHOULD use kebab-case for every file and folder name. It avoids case-sensitivity bugs between macOS and Linux builds.
- SHOULD NOT name by position or look: no
LeftPanel,BlueButton,BigText. Name the job:FilterPanel,PrimaryAction,PageTitle.
Classes without BEM
With Tailwind and components, the component is the block. You do not need .card__title--large,
because the file already scopes the styles and props replace modifiers.
Classes without BEM: copy the tsx (18 lines)
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn'; // clsx + tailwind-merge
const badge = cva('inline-flex items-center gap-1 rounded-full font-medium', {
variants: {
tone: {
neutral: 'bg-surface-raised text-text-soft',
accent: 'bg-accent text-white',
danger: 'bg-danger text-white',
},
size: { sm: 'h-5 px-2 text-xs', md: 'h-6 px-2.5 text-sm' },
},
defaultVariants: { tone: 'neutral', size: 'md' },
});
export function Badge({ tone, size, className, ...props }: React.ComponentProps<'span'> & VariantProps<typeof badge>) {
return <span className={cn(badge({ tone, size }), className)} {...props} />;
}- MUST let
prettier-plugin-tailwindcssorder classes. It sorts by Tailwind's own order: layout and position first, then box model, typography, visuals, and variants such ashover:andmd:after the base classes. Never hand-sort. - MUST merge incoming
classNamelast withcn(), so callers can override without!important. - MUST express modifiers as props (
tone="danger"), never as extra class names the caller has to know. - MUST style state from attributes, not from state classes:
aria-expanded:rotate-180,data-[state=open]:bg-surface-raised, not.is-open. - SHOULD keep a class list under about 12 utilities. Past that, move variants into
cvaor split the element.
In plain CSS, keep the same idea: one class on the component root, children reached with :where()
so specificity stays at one class, and state read from attributes.
.price-card { padding: var(--space-6); border-radius: var(--radius-lg); }
.price-card :where(h3) { font-size: var(--text-xl); }
.price-card[data-state='selected'] { outline: 2px solid var(--color-accent); }Build the palette in three layers
Neutrals
background, surfaces, text, rules
Accent
actions, focus, the one number
Semantic
success · warning · danger
- Neutrals (about 90% of the page). A background, a raised surface, two text tones and a rule colour. Tint them slightly towards your subject instead of pure grey.
- One accent (about 8%). Links, the primary button, focus rings, the one number that matters. If everything is accented, nothing is.
- Semantic colours (about 2%). Success, warning, danger. Used only for those meanings.
This site's own tokens live in app/global.css: --v-* for the mono stages, --w-* for the Win95
act and --c-1 to --c-6 for the colour finale. Colour arrives gradually, and text on colour is
always #080808 or #ffffff, whichever passes 4.5:1.
[ex] in the wild · 17
Check contrast, every time
The quick brown fox reads comfortably, or it doesn't. Numbers decide, not taste.
- Body text: at least 4.5:1. Large text (24px+, or 19px bold): at least 3:1.
- Soft grey on a tinted ground is the most common failure on "clean" sites. Measure it.
- Dark mode is its own palette, not an inversion. Reduce saturation and lift the accent.
[ex] dark mode done well · 17
Generate ramps in OKLCH
- 50W 1.1B 19.2
- 100W 1.2B 16.9
- 200W 1.6B 13.4
- 300W 2.2B 9.6
- 400W 3.2B 6.6
- 500W 4.8B 4.4
- 600W 7.0B 3.0
- 700W 10.5B 2.0
- 800W 15.0B 1.4
--brand-500: oklch(0.57 0.154 40);
OKLCH keeps lightness even to the eye, so a ramp built by stepping lightness at one hue looks balanced from 50 to 900. Check the contrast numbers before choosing which step carries text.
Why it works
Role names put the decision where it belongs. When the accent changes, one line in the semantic layer changes and every button follows. When a name follows a fixed pattern, people can guess it without opening the token file, which is the real test of a naming system.

































