./fru-mark/blog/building-a-design-system-in-tailwind-v4

← All blogs

Building a design system in Tailwind v4

Tailwind v4 moved theming into CSS itself. Here is how I turned a Figma token set into a single @theme block that the whole portfolio reads from.

Mark Carrington
Software Engineer2 min read
  • Tailwind
  • Design systems
  • CSS

Every project I start eventually grows a colors.ts file, a second set of spacing constants, and a third place where the font sizes live. Tailwind v4 removes the need for most of that by letting the theme live in CSS, next to the rest of the stylesheet.

Why tokens belong in CSS

A JavaScript config is invisible to the browser. Anything that needs a theme value at runtime — a canvas chart, a third-party widget, an inline SVG — has to import the config and duplicate the lookup.

Custom properties do not have that problem. Once a token is a CSS variable it is readable from a stylesheet, from getComputedStyle, and from a style attribute, which means there is exactly one source of truth.

Mapping Figma variables to @theme

The mapping is mechanical once you agree on a naming scheme. A Figma variable called color/surface/raised becomes --color-surface-raised, and Tailwind generates bg-surface-raised from it automatically:

@theme {
  --color-surface-raised: #14161a;
  --color-accent: #6c5ce7;
  --radius-card: 1.25rem;
}

The thing worth being strict about is that no component gets to invent a value. If a design needs a colour that is not in the theme, the theme is wrong, not the component.

Keeping dark mode honest

I define the light values on :root and override them inside a media query. Because every component only ever references the token, dark mode costs nothing per component — the override happens once.

The trap is semi-transparent overlays. rgb(0 0 0 / 0.06) reads as a subtle divider on white and as nothing at all on near-black, so overlays need their own tokens rather than being computed inline.

What I would do differently

I spent too long trying to express every spacing value as a token. Spacing is the one axis where Tailwind's default scale is already good, and re-deriving it from Figma bought me nothing but a migration.

Colour, radius, shadow, and typography were worth tokenising. Spacing was not.