C.W.K.
Stream
Lesson 01 of 07 · published

Theme Tokens — Semantic vs Literal

~13 min · tailwind, theme, design-tokens

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Naming a color blue-500 tells you what it is. Naming it brand tells you what it does. Both have a place. The art is knowing which goes where.

Literal tokens (built-in)

Tailwind ships a palette: blue-500, slate-900, cyan-400. These are literal — they describe a specific hex value. Use them for one-off accents, dev work, scratch UIs.

Semantic tokens (yours)

Semantic tokens are the names you add: brand, danger, surface, text-muted. These describe role, not appearance. The exact color can change (e.g. brand color rebrand) without touching every component that uses it.

The two-layer pattern

Most mature design systems use literals to define and semantics to use:

@theme {
  /* Literal palette */
  --color-pink-500: #FF8FBE;
  /* Semantic mapping */
  --color-brand: var(--color-pink-500);
}

This way you can swap --color-brand from pink-500 to violet-500 in one line and every bg-brand across the app updates.

The cwkPippa palette

cwkPippa's index.css uses semantic tokens almost exclusively in components (bg-bg, text-fg, text-brand) and pulls them from a small literal layer. The literal layer is the one place that changes when Dad picks a new accent color.

What makes a good semantic name

  • Role-based: danger, success, warning, info beat red, green, yellow, blue.
  • Surface-based: bg, bg-elevated, bg-card beat gray-950, gray-900, gray-850.
  • Hierarchy-aware: text, text-muted, text-subtle for primary/secondary/tertiary copy.
Naming buys flexibility, ambiguity costs it. A token called blue can't become red without breaking semantics. A token called brand can be any hue. Pick names that allow the color to change without changing the meaning.

Code

Two-layer token system in v4 syntax·css
@import "tailwindcss";

@theme {
  /* Layer 1 — literal palette (rarely touched after design system settles) */
  --color-pink-400: #FFA8CE;
  --color-pink-500: #FF8FBE;
  --color-pink-600: #D62E84;
  --color-slate-900: #0d0d12;
  --color-slate-800: #16161e;
  --color-slate-100: #e8e8ee;

  /* Layer 2 — semantic (what components actually use) */
  --color-bg: var(--color-slate-900);
  --color-bg-elevated: var(--color-slate-800);
  --color-fg: var(--color-slate-100);
  --color-brand: var(--color-pink-500);
  --color-brand-strong: var(--color-pink-600);
  --color-danger: #FF6B6B;
  --color-success: #51CF66;
}

[data-theme="light"] {
  /* Light mode swaps only semantic layer */
  --color-bg: #ffffff;
  --color-bg-elevated: #fafafd;
  --color-fg: #1a1a1f;
}
Components only touch semantic tokens·tsx
// All semantic — none of these need to change if the brand color changes.
function Banner() {
  return (
    <div className="bg-bg-elevated text-fg border-l-4 border-brand p-4 rounded">
      <h3 className="font-medium">New message</h3>
      <p className="text-muted text-sm">From: Dad</p>
      <button className="mt-2 px-3 py-1 bg-brand text-bg rounded hover:bg-brand-strong">
        Open
      </button>
    </div>
  );
}

// Compare to literal-only:
// className="bg-slate-800 text-slate-100 border-l-4 border-pink-500 ..."
// Same render today. Pain when the brand color changes tomorrow.

External links

Exercise

Take a small page (or your bootstrap project's App.tsx) where you've used literal Tailwind classes (bg-slate-900, text-white, etc.). Define five semantic tokens in @theme (bg, bg-elevated, fg, muted, brand). Refactor the page to use only those. Then change one literal color in @theme and verify every component updates.
Hint
If bg-brand doesn't apply, double-check the token name in @theme matches the utility class — --color-brand produces bg-brand, text-brand, border-brand, etc.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.