All ArticlesUI Frameworks•November 9, 2026•11 min read

Crafting Accessible and Composable Design Systems with Tailwind CSS v4 and Semantic Tokens

Tailwind CSS v4's CSS-first engine makes semantic token architecture a first-class citizen — but only if you get the layering right. This guide builds a three-tier token system, an ARIA-correct primitive library, and a CI contrast gate that keeps accessibility from regressing.

A creative workspace featuring UI design plans, pencils, a smartphone, and other office tools.Photo by Pixabay on Pexels

Why Tailwind CSS v4 Changes the Design System Calculus

Tailwind v3 forced a choice: keep design tokens in tailwind.config.js as a JavaScript object, or maintain a parallel CSS custom property layer and hope the two stayed in sync. The v4 rewired this. Configuration now lives in CSS via the @theme directive, every theme value is emitted as a real CSS custom property, opacity modifiers are resolved with color-mix(), and the build runs on the Rust-based Oxide engine plus Lightning CSS.

That shift matters for design systems because semantic tokens become the natural unit of authoring. You no longer translate colors.brand.600 into --brand-600 in a second file. You declare tokens once, in CSS, in the cascade — which means theming, scoping, and per-subtree overrides all work the way CSS intended.

This article covers the architecture that survives production: a strictly layered token hierarchy, @theme inline for theme-switchable semantics, accessible primitives built on WAI-ARIA patterns, and an automated contrast gate in CI.

The Three-Tier Token Architecture

Design systems fail when components reach directly for primitives. bg-indigo-600 in a button is not a design decision — it is a hardcoded value pretending to be one. The fix is a strict three-tier hierarchy:

TierExampleOwnerConsumed by
Primitive--color-indigo-600Brand / designNothing directly
Semantic--color-accentDesign systemComponents
Component--button-bgComponent authorThat component only

Enforce it socially and mechanically: primitives live in @theme but are documented as internal, and your lint rules reject bg-{color}-{shade} in packages/ui.

Defining Primitive Tokens with @theme

Use OKLCH. It is perceptually uniform, so a lightness ramp produces an actually even ramp, and it can express P3 colors that sRGB hex cannot.

css
/* app/styles/tokens.css */
@import "tailwindcss";

/* `static` forces every variable to be emitted. Semantic aliases reference
   primitives from :root/[data-theme] blocks that Tailwind's content scanner
   never sees, so on-demand emission would silently drop them. */
@theme static {
  /* Tier 1: primitives — never used in component class strings */
  --color-gray-0:   oklch(1 0 0);
  --color-gray-50:  oklch(0.985 0.002 247);
  --color-gray-100: oklch(0.967 0.003 247);
  --color-gray-300: oklch(0.869 0.006 247);
  --color-gray-500: oklch(0.640 0.014 247);
  --color-gray-700: oklch(0.450 0.017 247);
  --color-gray-800: oklch(0.320 0.017 247);
  --color-gray-900: oklch(0.220 0.015 247);
  --color-gray-950: oklch(0.150 0.012 247);

  --color-indigo-400: oklch(0.680 0.170 268);
  --color-indigo-500: oklch(0.580 0.200 268);
  --color-indigo-600: oklch(0.510 0.210 268);
  --color-indigo-700: oklch(0.440 0.190 268);
  --color-indigo-800: oklch(0.380 0.165 268);

  --color-red-500: oklch(0.580 0.210 27);
  --color-red-600: oklch(0.520 0.200 27);
  --color-red-700: oklch(0.460 0.180 27);

  /* Non-color primitives that semantic layers build on */
  --radius-control: 0.5rem;
  --radius-surface: 0.75rem;
  --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
  --font-sans: "InterVariable", ui-sans-serif, system-ui, sans-serif;
}

Semantic Tokens That Survive Theming with @theme inline

This is the single most-misunderstood part of v4, and it is worth being precise about.

CSS custom properties are substituted at computed-value time on the element where they are declared. If you write --color-accent: var(--color-indigo-600) inside a normal @theme block, Tailwind emits that declaration on :root and resolves it there. A descendant that overrides --color-indigo-600 will not change the inherited --color-accent, because the parent already computed it.

@theme inline fixes this: the utility is generated with the referenced variable inlined, so substitution happens at the element that uses the utility.

css
@theme inline {
  /* Tier 2: semantic — safe to use in components */
  --color-surface:        var(--ds-surface);
  --color-surface-raised: var(--ds-surface-raised);
  --color-surface-sunken: var(--ds-surface-sunken);
  --color-on-surface:     var(--ds-on-surface);
  --color-on-surface-muted: var(--ds-on-surface-muted);
  --color-border:         var(--ds-border);

  --color-accent:        var(--ds-accent);
  --color-accent-hover:  var(--ds-accent-hover);
  --color-accent-active: var(--ds-accent-active);
  --color-on-accent:     var(--ds-on-accent);

  --color-danger:       var(--ds-danger);
  --color-danger-hover: var(--ds-danger-hover);
  --color-on-danger:    var(--ds-on-danger);

  --color-focus: var(--ds-focus);
}

/* Light theme is the authoring default on :root, so no data attribute is
   required for the base case — and no flash-of-wrong-theme on first paint. */
:root {
  color-scheme: light;

  --ds-surface:        var(--color-gray-0);
  --ds-surface-raised: var(--color-gray-0);
  --ds-surface-sunken: var(--color-gray-100);
  --ds-on-surface:        var(--color-gray-900);
  --ds-on-surface-muted:  var(--color-gray-700);
  --ds-border:            var(--color-gray-300);

  --ds-accent:        var(--color-indigo-600);
  --ds-accent-hover:  var(--color-indigo-700);
  --ds-accent-active: var(--color-indigo-800);
  --ds-on-accent:     var(--color-gray-0);

  --ds-danger:       var(--color-red-600);
  --ds-danger-hover: var(--color-red-700);
  --ds-on-danger:    var(--color-gray-0);

  --ds-focus: var(--color-indigo-600);
}

[data-theme="dark"] {
  color-scheme: dark;

  --ds-surface:        var(--color-gray-950);
  --ds-surface-raised: var(--color-gray-900);
  --ds-surface-sunken: oklch(0.12 0.010 247);
  --ds-on-surface:       var(--color-gray-50);
  --ds-on-surface-muted: var(--color-gray-300);
  --ds-border:           var(--color-gray-800);

  --ds-accent:        var(--color-indigo-400);
  --ds-accent-hover:  oklch(0.740 0.150 268);
  --ds-accent-active: oklch(0.800 0.120 268);
  --ds-on-accent:     var(--color-gray-950);

  --ds-danger:       oklch(0.700 0.170 27);
  --ds-danger-hover: oklch(0.760 0.140 27);
  --ds-on-danger:    var(--color-gray-950);

  --ds-focus: oklch(0.800 0.130 268);
}

Now bg-surface, text-on-surface, border-border, and outline-focus all exist as utilities, and dropping data-theme="dark" on any subtree — a sidebar, a modal, a preview pane — re-resolves correctly.

Wiring the Custom dark: Variant

Because the theme lives on a data attribute rather than a class, redefine the variant so you can still use dark: for one-offs:

css
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

/* Custom utilities join the same variant/ordering pipeline as core ones */
@utility focus-ring {
  outline: 2px solid var(--ds-focus);
  outline-offset: 2px;
}

Accessible UI Primitives: Anatomy of a Composable Button

Tokens alone do not make an accessible system. Primitives must encode behavior: focus visibility, disabled semantics, loading announcements, and keyboard operability. Use class-variance-authority for the variant API and tailwind-merge (v3+, which understands Tailwind v4 syntax) so consumers can override without specificity wars.

tsx
// packages/ui/src/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";

const buttonStyles = cva(
  [
    "inline-flex items-center justify-center gap-2 rounded-control font-medium select-none",
    "transition-[background-color,color,box-shadow] duration-150 ease-out-quint",
    "motion-reduce:transition-none",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
    "disabled:pointer-events-none disabled:opacity-50",
    "aria-disabled:pointer-events-none aria-disabled:opacity-50",
    "forced-colors:border forced-colors:border-[ButtonBorder]",
  ],
  {
    variants: {
      intent: {
        primary: "bg-accent text-on-accent hover:bg-accent-hover active:bg-accent-active",
        neutral:
          "bg-surface-raised text-on-surface border border-border hover:bg-surface-sunken",
        ghost: "bg-transparent text-on-surface hover:bg-surface-sunken",
        danger: "bg-danger text-on-danger hover:bg-danger-hover",
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 text-sm",
        lg: "h-12 px-6 text-base",
      },
      block: { true: "w-full", false: "" },
    },
    defaultVariants: { intent: "primary", size: "md", block: false },
  },
);

export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonStyles> {
  isLoading?: boolean;
  loadingLabel?: string;
  iconStart?: ReactNode;
  iconEnd?: ReactNode;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  {
    className,
    intent,
    size,
    block,
    isLoading = false,
    loadingLabel = "Working",
    iconStart,
    iconEnd,
    children,
    disabled,
    type = "button", // never submit a form by accident
    ...rest
  },
  ref,
) {
  const isInert = disabled === true || isLoading;

  return (
    <button
      ref={ref}
      type={type}
      className={twMerge(buttonStyles({ intent, size, block }), className)}
      disabled={isInert}
      aria-busy={isLoading || undefined}
      {...rest}
    >
      {isLoading ? (
        <svg
          aria-hidden="true"
          viewBox="0 0 24 24"
          className="size-4 animate-spin motion-reduce:animate-none"
        >
          <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="3" opacity="0.25" />
          <path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
        </svg>
      ) : (
        iconStart
      )}
      <span>{children}</span>
      {isLoading ? <span className="sr-only">{loadingLabel}</span> : iconEnd}
    </button>
  );
});

Note the deliberate choices: type="button" by default, aria-busy rather than removing the accessible name, focus-visible rather than focus so mouse users do not see rings, and motion-reduce: so vestibular-sensitive users get a static spinner.

Composing Form Fields with Correct ARIA Relationships

Errors are where accessibility most often collapses. The label must be programmatically associated, the error must be announced, and the input must be marked invalid — three separate obligations.

tsx
// packages/ui/src/text-field.tsx
import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";

export interface TextFieldProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "id"> {
  label: string;
  description?: string;
  error?: string;
  id?: string;
  trailing?: ReactNode;
}

export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
  { label, description, error, id, className, required, trailing, ...rest },
  ref,
) {
  const generatedId = useId();
  const inputId = id ?? generatedId;
  const descriptionId = `${inputId}-description`;
  const errorId = `${inputId}-error`;

  const describedBy =
    [description ? descriptionId : null, error ? errorId : null]
      .filter((value): value is string => value !== null)
      .join(" ") || undefined;

  return (
    <div className="flex flex-col gap-1.5">
      <label htmlFor={inputId} className="text-sm font-medium text-on-surface">
        {label}
        {required && (
          <>
            <span aria-hidden="true" className="text-danger"> *</span>
            <span className="sr-only"> (required)</span>
          </>
        )}
      </label>

      <div className="relative">
        <input
          ref={ref}
          id={inputId}
          required={required}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          className={twMerge(
            "h-10 w-full rounded-control border border-border bg-surface px-3 text-sm text-on-surface",
            "placeholder:text-on-surface-muted",
            "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
            "disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-on-surface-muted",
            "aria-invalid:border-danger aria-invalid:outline-danger",
            trailing ? "pr-10" : "",
            className,
          )}
          {...rest}
        />
        {trailing && (
          <div className="pointer-events-none absolute inset-y-0 right-3 flex items-center">
            {trailing}
          </div>
        )}
      </div>

      {description && (
        <p id={descriptionId} className="text-xs text-on-surface-muted">
          {description}
        </p>
      )}
      {error && (
        <p id={errorId} role="alert" className="text-xs font-medium text-danger">
          {error}
        </p>
      )}
    </div>
  );
});

Guardrails: Automated Contrast Enforcement in CI

Semantic tokens are only accessible if the pairs clear WCAG thresholds. WCAG 2.2 SC 1.4.3 requires 4.5:1 for body text (3:1 for large), and SC 1.4.11 requires 3:1 for non-text UI components and focus indicators. A human reviewing sixteen color pairs in two themes will miss something. Parse the compiled CSS instead.

ts
// scripts/check-contrast.ts
import { readFile } from "node:fs/promises";
import { wcagContrast } from "culori";

const MIN_TEXT = 4.5; // WCAG 2.2 SC 1.4.3
const MIN_UI = 3;     // WCAG 2.2 SC 1.4.11

const PAIRS = [
  { fg: "--ds-on-surface", bg: "--ds-surface", min: MIN_TEXT },
  { fg: "--ds-on-surface-muted", bg: "--ds-surface", min: MIN_TEXT },
  { fg: "--ds-on-surface", bg: "--ds-surface-sunken", min: MIN_TEXT },
  { fg: "--ds-on-accent", bg: "--ds-accent", min: MIN_TEXT },
  { fg: "--ds-on-accent", bg: "--ds-accent-hover", min: MIN_TEXT },
  { fg: "--ds-on-danger", bg: "--ds-danger", min: MIN_TEXT },
  { fg: "--ds-accent", bg: "--ds-surface", min: MIN_UI },
  { fg: "--ds-border", bg: "--ds-surface", min: MIN_UI },
  { fg: "--ds-focus", bg: "--ds-surface", min: MIN_UI },
] as const;

function extractBlock(css: string, selector: string): string {
  const start = css.indexOf(selector);
  if (start === -1) throw new Error(`Selector not found in compiled CSS: ${selector}`);

  const open = css.indexOf("{", start);
  if (open === -1) throw new Error(`Malformed block for selector: ${selector}`);

  let depth = 0;
  for (let i = open; i < css.length; i += 1) {
    if (css[i] === "{") depth += 1;
    else if (css[i] === "}") {
      depth -= 1;
      if (depth === 0) return css.slice(open + 1, i);
    }
  }
  throw new Error(`Unbalanced braces after selector: ${selector}`);
}

function parseDeclarations(block: string, into: Map<string, string>): Map<string, string> {
  for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
    into.set(match[1]!, match[2]!.trim());
  }
  return into;
}

function makeResolver(decls: Map<string, string>): (name: string) => string {
  const cache = new Map<string, string>();

  const resolve = (value: string, seen: Set<string>): string =>
    value.replace(/var\((--[\w-]+)\)/g, (_match, name: string) => {
      if (seen.has(name)) {
        throw new Error(`Circular token reference: ${[...seen, name].join(" -> ")}`);
      }
      const raw = decls.get(name);
      if (raw === undefined) throw new Error(`Unresolved token reference: ${name}`);
      seen.add(name);
      const resolved = resolve(raw, seen);
      seen.delete(name);
      return resolved;
    });

  return (name: string): string => {
    const cached = cache.get(name);
    if (cached !== undefined) return cached;

    const raw = decls.get(name);
    if (raw === undefined) throw new Error(`Token not declared: ${name}`);

    const resolved = resolve(raw, new Set([name]));
    cache.set(name, resolved);
    return resolved;
  };
}

async function main(): Promise<void> {
  const cssPath = process.argv[2] ?? "dist/assets/index.css";
  const css = await readFile(cssPath, "utf8").catch((cause: unknown) => {
    throw new Error(`Cannot read compiled CSS at ${cssPath}. Run the build first.`, { cause });
  });

  // With `@theme static`, primitives are emitted on :root too, so a single
  // base map resolves both primitive and semantic layers.
  const base = parseDeclarations(extractBlock(css, ":root"), new Map());
  const darkOverrides = parseDeclarations(extractBlock(css, '[data-theme="dark"]'), new Map());

  const themes: Record<string, Map<string, string>> = {
    light: base,
    dark: new Map([...base, ...darkOverrides]),
  };

  const failures: string[] = [];

  for (const [theme, decls] of Object.entries(themes)) {
    const resolve = makeResolver(decls);
    for (const pair of PAIRS) {
      const fg = resolve(pair.fg);
      const bg = resolve(pair.bg);
      const ratio = wcagContrast(fg, bg);
      const verdict = ratio >= pair.min ? "PASS" : "FAIL";
      console.log(`${verdict}  [${theme}] ${pair.fg} on ${pair.bg}: ${ratio.toFixed(2)}:1 (min ${pair.min})`);
      if (ratio < pair.min) {
        failures.push(`[${theme}] ${pair.fg} on ${pair.bg} = ${ratio.toFixed(2)}:1, required ${pair.min}:1`);
      }
    }
  }

  if (failures.length > 0) {
    console.error(`\n${failures.length} contrast violation(s):`);
    for (const failure of failures) console.error(`  - ${failure}`);
    process.exitCode = 1;
  }
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
});

Pair this with @axe-core/playwright in the component test suite so ARIA regressions (missing labels, duplicate IDs, invalid roles) fail the same pipeline.

Build Configuration and Operational Pitfalls

Use the first-party Vite plugin; it replaces the PostCSS chain and is materially faster.

ts
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

For a monorepo whose UI package is not resolved through node_modules, register sources explicitly:

css
@import "tailwindcss";
@source "../../packages/ui/src/**/*.{ts,tsx}";
@source not "../../packages/ui/src/**/*.stories.tsx"; /* v4.1+ */

Pitfalls Checklist

  • `@theme` inside `@layer` — @theme must be top level; nesting it silently breaks emission.
  • Non-`inline` semantic tokens — theming works on :root but not on subtrees. Always @theme inline for anything aliasing var().
  • Missing `@theme static` — primitives referenced only from :root are invisible to the content scanner and can be dropped.
  • `@layer utilities` for custom classes — no longer works in v4. Use @utility. @apply still functions, but prefer composing class strings; it makes overrides impossible for consumers.
  • Fragmenting `tailwind-merge` — if you extend the theme with new namespaces, pass a matching config to twMerge or consumers will get conflicting classes.
  • Border default flip — v4 defaults border-color to currentColor, and ring defaults to 1px (was 3px). Audit any focus ring relying on the old default.
  • Vue/Svelte/Astro scoped styles — add @reference "../styles/tokens.css"; at the top of the component <style> block so @apply and theme() resolve.
  • Browser floor — v4 requires Safari 16.4+, Chrome 111+, Firefox 128+ (custom properties in @property, color-mix()). Document it; do not discover it in production.
  • Color-only meaning — SC 1.4.1. Pair every aria-invalid red border with iconography and text.
  • `prefers-reduced-motion` and `forced-colors` — handle both; add a global reduced-motion reset and a forced-colors: variant on anything whose affordance depends on a background fill.

Sources & Technical References

3 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles