fdb/docs

[§] /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

  1. [01]

    Primitive

    --color-v-ink: #f5f5f5 · the raw value

  2. [02]

    Semantic

    --accent: var(--color-v-ink) · a job

  3. [03]

    Component (optional)

    --button-bg · only when a component needs a knob

  4. [04]

    Usage

    bg-accent, var(--color-accent)

Primitives hold raw values. Semantic tokens give them a job. Components read only semantic tokens, so dark mode and rebrands happen in one file.
Two tiers, one direction: table (6 rows)
TierExampleValueWho may read it
Primitive--color-v-ink#f5f5f5Semantic tokens only
Primitive--color-v-bg#080808Semantic tokens only
Primitive--color-c-4#00b3ffSemantic tokens, colour experiences
Semantic--accentvar(--color-v-ink)Components, patterns
Semantic--text-softvar(--color-v-dim)Components, patterns
Component--button-height-md40pxThat component only
  • MUST keep components off primitives. A button reads --accent, never --color-v-ink or 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 + part

Use a fixed list of categories and roles, so names are guessable:

The naming pattern: table (11 rows)
CategoryRoles and variantsExamples
colorbg, surface, text, border, accent, focus, success, warning, danger--color-bg, --color-surface-raised, --color-text-soft, --color-border-strong
fontsans, display, mono--font-display
textsize steps xs to 7xl--text-sm, --text-2xl
leadingtight, snug, normal, relaxed--leading-snug
trackingtight, normal, wide--tracking-tight
spacescale steps 1 to 32 (× 4px)--space-4 is 16px
radiusxs, sm, md, lg, xl, full--radius-md
shadow1 to 4 by elevation--shadow-2
znamed layers--z-modal
durationinstant, fast, base, slow--duration-fast
easeout, in-out, in--ease-out

Rules for the names themselves:

  • MUST use lowercase kebab-case: --color-text-soft, not --colorTextSoft or --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 soft and strong for the two variants either side of a default, and stop there. Three tones of one role is enough.
  • SHOULD name paired foregrounds -fg: --color-accent and --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 namespaceUtilities 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
--spacingone 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 inline for 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, so bg-blue-500 cannot 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)
ThingConventionExample
ComponentPascalCase, a nounPriceCard, DatePicker
Component filekebab-case, matches the componentprice-card.tsx
Props typeComponent name + PropsPriceCardProps
Hookuse + PascalCase, file in kebab-caseuseMediaQuery in use-media-query.ts
Event propon + noun + verbonValueChange, onOpenChange
Handler inside a componenthandle + eventhandleSubmit
Boolean propthe HTML word when one existsdisabled, open, required
Other booleanis, has, can + adjectiveisPending, hasError
ConstantSCREAMING_SNAKE_CASEMAX_UPLOAD_MB
CSS variablekebab-case, pattern above--color-text-soft
Data attribute for statedata- + state namedata-state="open", data-disabled
Next.js route filefixed namespage.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, not Title.
  • 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-tailwindcss order classes. It sorts by Tailwind's own order: layout and position first, then box model, typography, visuals, and variants such as hover: and md: after the base classes. Never hand-sort.
  • MUST merge incoming className last with cn(), 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 cva or 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

90%
8%
2%

Neutrals

background, surfaces, text, rules

Accent

actions, focus, the one number

Semantic

success · warning · danger

Treat colour as a budget, not a palette. If the accent is spent everywhere, it stops meaning anything.
  1. 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.
  2. One accent (about 8%). Links, the primary button, focus rings, the one number that matters. If everything is accented, nothing is.
  3. 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

2.90:1 · Fails

The quick brown fox reads comfortably, or it doesn't. Numbers decide, not taste.

Body text needs 4.5:1 (AA). Dim grey on near-black is the usual trap: check it, don't eyeball it.
  • 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

40°
0.16
  1. 50
    W 1.1B 19.2
  2. 100
    W 1.2B 16.9
  3. 200
    W 1.6B 13.4
  4. 300
    W 2.2B 9.6
  5. 400
    W 3.2B 6.6
  6. 500
    W 4.8B 4.4
  7. 600
    W 7.0B 3.0
  8. 700
    W 10.5B 2.0
  9. 800
    W 15.0B 1.4

--brand-500: oklch(0.57 0.154 40);

Every step keeps the same hue and a planned lightness, so the ramp looks even. Numbers show contrast with white (W) and black (B); struck through means below 4.5:1 for body text. Notice yellows pass on black far longer than blues do.

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.

Sources

3 sources

On this page