All posts
CSSMar 15, 2026 · 3 min read

Practical Tailwind Theming with Semantic Design Tokens

Why bg-fun-pink was a mistake, and how renaming Tailwind color tokens by role instead of appearance made this site's design system actually usable.

Practical Tailwind Theming with Semantic Design Tokens
On this page

For years this site had a Tailwind color called fun-pink. It was cyan. Nobody remembers why. Every time I styled something I had to remember that "pink means cyan", and every shade variant (fun-pink-dark, fun-pink-darker) compounded the lie. This post is about the fix: semantic tokens — naming colors by role, not by appearance.

Appearance names always rot

A color named by appearance makes two promises it can't keep:

  1. That the value will always look like the name (fun-pink#00c7ff broke this on day one)
  2. That the name tells you where to use it (it never does)

Role names keep both promises:

Old name (appearance)New name (role)ValueUsed for
fun-pinkaccent#00c7ffLinks, highlights, CTAs
fun-pink-darksurface#192742Cards, badges, raised panels
fun-graymuted#7b89a8Secondary text, borders
fun-gray-lightmuted-light#b2bbcfBody text on dark backgrounds
darkbg#000a1fPage background

Now bg-surface text-muted-light reads like a sentence, and changing the accent color is a one-line edit instead of a find-and-replace gamble.

Wiring it up in Tailwind

The tokens live in theme.extend.colors, so Tailwind's defaults stay available:

import type { Config } from "tailwindcss";
 
const config: Config = {
  content: ["./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        bg: "#000a1f",
        accent: "#00c7ff",
        "accent-soft": "#009ac5",
        surface: "#192742",
        "surface-deep": "#000f2e",
        muted: "#7b89a8",
        "muted-light": "#b2bbcf",
      },
    },
  },
};
 
export default config;

The rule that makes it stick

One rule, enforced in review: no raw hex values in JSX. The moment someone writes text-[#00c7ff], the token system stops being the source of truth. The only places a hex value may appear are the Tailwind config and a single site.ts constant (for the rare JS API that needs a literal color, like a progress bar library).

Migrating an existing codebase

The mechanical part is a scripted rename — fun-pinkaccent across the tree. The interesting part is what you find while doing it:

  • Off-token colors. A dozen one-off grays that were almost muted. Each one is a decision: promote it to a token or snap it to an existing one. I snapped almost all of them.
  • Same role, different values. Two slightly different "card background" colors used interchangeably. That's not a palette, that's an accident. Pick one.
  • Dead tokens. Colors referenced nowhere. Delete them; the config should list exactly what the design uses.

Where to stop

Semantic tokens are a sweet spot, not a destination. I deliberately did not add a second theme, CSS variables for runtime switching, or a darkMode config — this site has one dark theme, and speculative flexibility is how design systems become unmaintained. Add the machinery when you have the second theme in hand, not before.