Skip to main content

Design Systems and Design Tokens

What you learn in this chapter

So far, you have seen that the button uses colors like bg-primary. In this chapter, you learn where that color comes from, and why you use bg-primary instead of a hard-coded bg-blue-500. This is the starting point of a design system.

  • What a design system is
  • Design tokens (the "named values" for color, spacing, and corner radius)
  • The difference between primitive tokens and semantic tokens
  • OKLCH colors, and token definitions with Tailwind v4's @theme
  • How light / dark themes work

What a design system is

A design system is "a set of rules" for building a consistent UI. It turns color, spacing, font size, corner radius, component shapes, and more into rules so that a whole team or project can use them consistently.

Why is it needed? Without a design system, things tend to end up like this.

Without a design system

Button A: bg-blue-500 / Button B: bg-blue-600
Button C: bg-sky-500 / Button D: bg-[#3b82f6]

- "They should be the same blue, but they are subtly different"
- "Rewriting everything for dark mode support…"
- "Grepping every file to change the brand color…"

When you specify colors directly (hard-code them) case by case, similar-but-different colors pile up, and every change means fixing every place. A design system solves this problem with "meaningful names".

info

This book uses Button as its subject, but the ideas you learn here apply to all components, such as Card, Input, and Dialog. Button is the entry point.

What design tokens are

A design token is a value with a name attached to the smallest unit of design (color, spacing, corner radius, and so on).

Hard-coded: bg-blue-500 (a specific blue)
↓ turn it into a meaningful name
Design token: bg-primary ("the color for primary actions")

bg-primary represents the meaning "the color used for primary actions". Regardless of whether the actual color is blue or black, the component side just writes bg-primary. The color itself is managed in one place (the token definition).

The two layers of tokens: primitive and semantic

Design tokens have two stages.

① Primitive token (the raw value): oklch(0.205 0 0) = "dark gray"

② Semantic token (the meaning): --primary = "the color for primary actions"

③ A component references it: bg-primary → <Button>
  • Primitive token: a "raw color value" like oklch(0.205 0 0). Its purpose is not yet decided.
  • Semantic token: a name given a role / meaning, like --primary (primary action) or --destructive (destructive operation).

A component references only semantic tokens. This way, when you want to "change the primary color", you just change the single value of --primary, and it is reflected across all buttons.

info

Why is semantic important? When you write bg-blue-500, you are tied to "blue", but when you write bg-primary, you are tied to the meaning "the lead color". Even if you later change the lead color to green, you do not change a single character of the component's code. This is the key to making dark mode support and brand changes easy.

OKLCH colors

shadcn/ui and Tailwind CSS v4 express colors in a format called OKLCH.

--primary: oklch(0.205 0 0);
/* the 3 values of oklch(lightness chroma hue):
lightness = 0.205 (0 = black 〜 1 = white)
chroma = 0 (0 = achromatic gray)
hue = 0 (0〜360: the kind of color red, blue…) */

OKLCH expresses a color with three components: "lightness, chroma, and hue". Compared to a hexadecimal like #3b82f6, its advantage is that you can adjust brightness in a way that is closer to human perception. For example, "make it just a little darker while keeping the same color" is easy, which suits color design for light / dark.

For example:

--destructive: oklch(0.577 0.245 27.325);
/* lightness 0.577 / chroma 0.245 / hue 27 (reddish) → the red used for warnings and deletion */

Defining tokens in Tailwind v4

In the environment setup chapter, you wrote @import "tailwindcss" in src/index.css. You define tokens in this CSS file. Let's look at shadcn/ui's standard definition (an excerpt).

@import "tailwindcss";

/* ① link Tailwind's utility names with CSS variables */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
}

/* ② the actual colors for light mode (OKLCH) */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0); /* white */
--foreground: oklch(0.145 0 0); /* almost black */
--card: oklch(1 0 0); /* card background (white) */
--card-foreground: oklch(0.145 0 0); /* text on the card (almost black) */
--primary: oklch(0.205 0 0); /* dark gray */
--primary-foreground: oklch(0.985 0 0); /* almost white */
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); /* red */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}

The mechanism has three stages.

  1. In :root, define the actual OKLCH values on CSS variables (such as --primary)
  2. With @theme inline, link those variables to Tailwind's utility names (--color-primary)
  3. Then classes like bg-primary / text-primary-foreground become usable
:root's --primary → @theme's --color-primary → bg-primary class → <Button>
(the OKLCH value) (registered with Tailwind) (used in the component)

The reason the Button in the final-form chapter could write bg-primary text-primary-foreground is that this definition exists.

info

When you run the npx shadcn@latest init introduced in the environment setup chapter, this :root / @theme token definition is written into src/index.css automatically. You do not need to write it by hand, but understanding "what is written" keeps you from getting lost when you want to adjust a color.

The name / foreground naming convention

Token colors follow a convention of a name and name-foreground pair.

TokenUse
--background / --foregroundThe page background / standard text
--primary / --primary-foregroundPrimary buttons and actions
--secondary / --secondary-foregroundSecondary actions
--card / --card-foregroundThe background of surfaces such as cards / the text on them
--muted / --muted-foregroundA subdued background / weak text
--accent / --accent-foregroundHover and accents
--destructiveThe warning color for deletion, errors, and the like
--borderThe border color
--inputThe border of input fields
--ringThe color of the focus ring

The rule is simple.

  • name … the "background" color that something sits on top of
  • name-foreground … the color of the "text / icons" that sit on top of that background

That is why Button's default was bg-primary text-primary-foreground. It is a combination of the lead color as the background and a readable text color on top.

info

--destructive has no -foreground pair. This is because recent shadcn/ui uses text-white directly for the text color of delete buttons (the destructive in the final-form chapter was like this). The convention has a few such exceptions.

Light / dark themes

The true value of tokens becomes clear with theme switching. You define the same variables in .dark too, changing only the values.

:root {
--background: oklch(1 0 0); /* white */
--primary: oklch(0.205 0 0); /* dark gray */
}

.dark {
--background: oklch(0.145 0 0); /* almost black */
--primary: oklch(0.922 0 0); /* light gray */
}
Light (:root) Dark (.dark)
--primary: oklch(0.205 0 0) oklch(0.922 0 0)
(a dark color) (a light color)
↓ both are referenced by the same class
<Button className="bg-primary">
→ without changing the class at all, the color switches with the theme

Whether you add the dark class to <html> or <body> switches the actual color of bg-primary. You do not change a single character of the component's classes. This is the greatest advantage of semantic tokens.

This becomes the foundation of the Button

Recall the Button styles you saw in the final-form chapter.

variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
// ...
}

Every color that appears here is a semantic token defined in this chapter. Button references styles not by "blue" or "gray" but by the meanings primary (the lead), secondary (the supporting role), destructive (warning), and accent (emphasis).

So this Button:

  • Automatically follows the theme (light / dark)
  • Even when you change the brand color, it is reflected across all variants just by changing a token
  • Naturally matches the colors of other components (Card, Input…)

It behaves in line with a design system.

info

The /90 in hover:bg-primary/90 means "90% opacity". It thins out the token's color directly to make the hover color, so you do not need to define a separate hover color. This is another advantage of using tokens.

Chapter summary

  • A design system is a set of rules for building a consistent UI
  • A design token is a value with a "meaningful name" attached to color or spacing
  • Reference goes in the order primitive (the raw value) → semantic (the meaning) → component
  • In Tailwind v4, you define tokens with the :root / .dark CSS variables + @theme inline
  • Colors are expressed in OKLCH (lightness, chroma, hue), which makes light / dark adjustment easy
  • The reason Button can use bg-primary and the like is that these tokens are the foundation

From the next chapter, you learn "how to manage variants declaratively" with cva, on top of this foundation.