All ArticlesUI Frameworks•October 28, 2026•11 min read

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

Tailwind CSS v4 turned design tokens into real CSS custom properties and moved configuration into the stylesheet — which finally makes semantic token layers, subtree theming, and accessibility state variants enforceable rather than aspirational. This guide builds a production three-tier token architecture, wires light/dark/high-contrast modes without JavaScript, and composes headless accessible primitives on top of it.

A person browsing a color selection app on a smartphone screen indoors with visible color options.Photo by Jonathan Meza on Pexels

Tailwind CSS v4 didn't ship a longer utility list — it shipped a different contract. Configuration moved into CSS, every theme token is now a real custom property on :root, and utilities are generated only from the namespaces you declare. For teams building a design system, that is the difference between a pile of class strings and an enforced, themeable, accessible component API.

This article builds that API end to end: a three-tier semantic token layer, a runtime theming strategy that works with and without JavaScript, custom variants that encode accessibility states, and composable primitives built on headless UI libraries.

Why Tailwind CSS v4 Rewrites the Design System Playbook

CSS-First Configuration Replaces tailwind.config.js

In v4, @import "tailwindcss" replaces the three @tailwind directives, and the @theme block replaces theme.extend. The critical architectural detail: theme namespaces generate utilities. Declare --color-brand-500 and you get bg-brand-500, text-brand-500, border-brand-500, ring-brand-500, and opacity modifiers like bg-brand-500/40 for free.

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

@theme {
  /* Wipe the default palette: every color must now be intentional. */
  --color-*: initial;
  --color-white: #fff;
  --color-black: #000;

  /* Spacing scale: v4 generates any integer against --spacing */
  --spacing: 0.25rem;

  --radius-control: 0.5rem;
  --radius-panel: 0.875rem;
}

Note the consequence of --color-*: initial: bg-slate-200 silently stops compiling. That is a feature — it turns accidental primitive leakage into a build-time signal — but it must land in the same PR as your semantic layer, not before it.

Native Cascade Layers, @property, and color-mix()

v4 targets modern engines (Safari 16.4+, Chrome 111+, Firefox 128+), which lets it rely on three platform primitives: real @layer ordering (theme, base, components, utilities), @property for typed, non-inherited internals like --tw-gradient-from, and color-mix() for opacity modifiers. Practically: bg-surface/50 works on any semantic token you define, with no rgb(... / <alpha-value>) plumbing.

Architecting a Three-Tier Semantic Token Layer

Tier 1 — Primitives: Private, Non-Semantic, Disposable

Primitives are the raw palette. They exist to be referenced, never used in component markup. Committing to oklch() here pays off later: you can hold perceptual lightness constant across hues so that a blue accent and a red danger token have comparable contrast against the same surface.

css
@theme {
  --color-*: initial;

  --color-slate-25: oklch(0.985 0.002 250);
  --color-slate-150: oklch(0.945 0.007 250);
  --color-slate-400: oklch(0.68 0.02 250);
  --color-slate-600: oklch(0.5 0.022 250);
  --color-slate-900: oklch(0.24 0.016 250);
  --color-slate-975: oklch(0.16 0.012 250);

  --color-azure-300: oklch(0.79 0.13 258);
  --color-azure-500: oklch(0.62 0.19 258);
  --color-azure-600: oklch(0.55 0.2 258);

  --color-rose-300: oklch(0.75 0.15 22);
  --color-rose-500: oklch(0.6 0.22 22);
}

Tier 2 — Semantic Roles: The Public Contract

Semantic tokens describe intent, not appearance. Components may only use these. The inline keyword is the load-bearing detail here.

css
/* `inline` is mandatory: generated utilities emit `var(--ui-*)` directly.
   Without it, `bg-surface` compiles to `background-color: var(--color-surface)`,
   whose computed value is resolved once on :root and inherited as a literal —
   so a subtree that re-maps `--ui-surface` would have no effect. */
@theme inline {
  --color-surface: var(--ui-surface);
  --color-surface-raised: var(--ui-surface-raised);
  --color-content: var(--ui-content);
  --color-content-muted: var(--ui-content-muted);
  --color-line: var(--ui-line);
  --color-accent: var(--ui-accent);
  --color-accent-hover: var(--ui-accent-hover);
  --color-on-accent: var(--ui-on-accent);
  --color-danger: var(--ui-danger);
  --color-on-danger: var(--ui-on-danger);
  --color-focus: var(--ui-focus);
}

With inline, .bg-surface becomes background-color: var(--ui-surface). Custom properties are substituted lazily at the point of use, so any element — a modal, a marketing band, an inverted footer — can re-declare --ui-surface and every descendant utility follows. That single keyword is what makes composable theming possible in v4.

Tier 3 — Component Contracts: Use Sparingly

Resist a third tier per component. Reservations like --button-bg multiply the surface area you must keep contrast-checked. A better rule: component tokens only for genuinely variable geometry (e.g. --radius-control, --panel-gutter), semantic roles for everything color-related.

Wiring Light, Dark, and High-Contrast Modes Without JavaScript

The token layer resolves at runtime in @layer base. color-scheme is not decoration — it drives native scrollbars, form controls, and the Canvas/ButtonText system colors used in forced-colors mode.

css
@layer base {
  :root {
    color-scheme: light;

    --ui-surface: var(--color-slate-25);
    --ui-surface-raised: var(--color-white);
    --ui-content: var(--color-slate-900);
    --ui-content-muted: var(--color-slate-600);
    --ui-line: var(--color-slate-150);
    --ui-accent: var(--color-azure-600);
    --ui-accent-hover: var(--color-azure-500);
    --ui-on-accent: var(--color-white);
    --ui-danger: var(--color-rose-500);
    --ui-on-danger: var(--color-white);
    --ui-focus: var(--color-azure-600);
  }

  /* Explicit user choice wins — and rescopes any subtree. */
  [data-theme="dark"] {
    color-scheme: dark;

    --ui-surface: var(--color-slate-975);
    --ui-surface-raised: var(--color-slate-900);
    --ui-content: var(--color-slate-25);
    --ui-content-muted: var(--color-slate-400);
    --ui-line: var(--color-slate-600);
    --ui-accent: var(--color-azure-300);
    --ui-accent-hover: var(--color-azure-500);
    --ui-on-accent: var(--color-slate-975);
    --ui-danger: var(--color-rose-300);
    --ui-on-danger: var(--color-slate-975);
    --ui-focus: var(--color-azure-300);
  }

  /* No-JS OS preference. Mirror the dark block here. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) { /* …same declarations… */ }
  }

  /* WCAG 2.2 SC 1.4.6 / user-requested stronger contrast. */
  @media (prefers-contrast: more) {
    :root {
      --ui-content-muted: var(--ui-content);
      --ui-line: var(--color-slate-600);
      --ui-accent: var(--color-azure-600);
    }
  }
}

If your baseline allows it, light-dark() collapses the duplicated blocks and the media query into one declaration per token (--ui-surface: light-dark(var(--color-slate-25), var(--color-slate-975))), with [data-theme] overrides adjusting color-scheme. Verify support in your browser matrix before adopting it as the only mechanism.

Encoding Accessibility States as Custom Variants

Accessibility state belongs in the utility layer, not in JavaScript-toggled classes. Define the variants once and compose them everywhere.

css
@custom-variant pointer-coarse (@media (pointer: coarse));
@custom-variant reduce-motion (@media (prefers-reduced-motion: reduce));
@custom-variant high-contrast (@media (prefers-contrast: more));
@custom-variant hcm (@media (forced-colors: active));

/* One predicate for native <details>, Radix state, and ARIA expansion. */
@custom-variant open {
  &:is([data-state="open"], [aria-expanded="true"], [open]) {
    @slot;
  }
}

/* Enforce a 44px minimum only on touch pointers (WCAG 2.5.8 target size). */
@utility tap-safe {
  @media (pointer: coarse) {
    min-block-size: 2.75rem;
    min-inline-size: 2.75rem;
  }
}

@utility focus-ring {
  &:focus-visible {
    outline: 2px solid var(--ui-focus);
    outline-offset: 2px;
  }
}

Tailwind already ships aria-*, data-*, has-*, not-*, forced-colors:, motion-safe:, motion-reduce:, contrast-more:, inert:, and starting:. Reach for @custom-variant only when you need a compound predicate.

Composable Accessible Primitives in Practice

The Polymorphic Button Contract

Three libraries do the heavy lifting: class-variance-authority for typed variant contracts, tailwind-merge for deterministic class conflict resolution, and Radix's Slot for asChild composition (so a <button> can become an <a> without losing styles or semantics).

tsx
// components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from "tailwind-merge";

const button = cva(
  [
    "inline-flex select-none items-center justify-center gap-2",
    "rounded-control font-medium",
    "transition-colors duration-150 motion-reduce:transition-none",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
    "forced-colors:border forced-colors:border-[ButtonText]",
    "disabled:pointer-events-none disabled:opacity-50",
    "aria-disabled:pointer-events-none aria-disabled:opacity-50",
    "tap-safe",
  ],
  {
    variants: {
      intent: {
        primary: "bg-accent text-on-accent hover:bg-accent-hover",
        neutral: "border border-line bg-surface-raised text-content hover:bg-surface",
        danger: "bg-danger text-on-danger hover:opacity-90",
        ghost: "bg-transparent text-content hover:bg-surface-raised",
      },
      size: {
        sm: "h-9 px-3 text-sm",
        md: "h-11 px-4 text-sm",
        lg: "h-13 px-6 text-base",
      },
    },
    defaultVariants: { intent: "primary", size: "md" },
  },
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof button> {
  asChild?: boolean;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  function Button({ className, intent, size, asChild = false, type, ...props }, ref) {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        ref={ref}
        className={twMerge(button({ intent, size }), className)}
        {...(asChild ? {} : { type: type ?? "button" })}
        {...props}
      />
    );
  },
);

Note type="button" is defaulted only for real buttons — injecting it through Slot onto a link would emit an invalid attribute.

Composing a Dialog That Actually Manages Focus

Never hand-roll a modal. Radix handles focus trapping, aria-modal, Escape, outside-click, scroll locking, and inert on background content. Your job is styling and the required title wiring.

tsx
const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(function DialogContent({ className, children, ...props }, ref) {
  return (
    <DialogPrimitive.Portal>
      <DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/50 motion-safe:transition-opacity motion-safe:duration-200 motion-safe:starting:opacity-0" />
      <DialogPrimitive.Content
        ref={ref}
        className={twMerge(
          "fixed left-1/2 top-1/2 z-50 w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2",
          "rounded-panel border border-line bg-surface-raised p-6 text-content shadow-xl",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
          "motion-safe:transition motion-safe:duration-200 motion-safe:starting:scale-95 motion-safe:starting:opacity-0",
          className,
        )}
        {...props}
      >
        {children}
        <DialogPrimitive.Close asChild>
          <Button intent="ghost" size="sm" className="absolute right-3 top-3" aria-label="Close dialog">
            ×
          </Button>
        </DialogPrimitive.Close>
      </DialogPrimitive.Content>
    </DialogPrimitive.Portal>
  );
});

DialogPrimitive.Title is not optional — without it, Radix cannot supply aria-labelledby and screen readers announce an unnamed dialog.

Merging Classes Safely

twMerge resolves conflicts for stock utilities. Semantic tokens that collide with utility prefixes (text-content vs text-sm) are handled correctly, but custom utilities need registration:

ts
export const twMerge = extendTailwindMerge({
  extend: { classGroups: { tap: ["tap-safe"], "focus-ring": ["focus-ring"] } },
});

Pitfalls, Migration Notes, and a Ship-Ready Checklist

Breaking changes that bite during the v4 upgrade:

  • `outline-none` now means `outline-style: none` — it removes the focus indicator. Use outline-hidden, which preserves the forced-colors outline.
  • Default border color is `currentColor`, not gray-200. Borders on legacy components inherit text color; set a base rule: @layer base { *, ::before, ::after { border-color: var(--ui-line); } }.
  • `ring` is 1px, not 3px — restore --default-ring-width: 3px if you depend on the old weight.
  • *`bg-gradient- → bg-linear-`; `flex-shrink- → shrink-`; `space-x-` uses a different selector.
  • `@apply` in Vue/Svelte/Astro `<style>` blocks requires @reference "../app.css"; at the top of the block.
  • Removing the palette before migrating produces silently missing styles. Migrate, then delete.

Operator checklist:

  1. No component file references a Tier-1 primitive — enforce with a Stylelint declaration-property-value-disallowed-list or an ESLint no-restricted-syntax rule over bg-{slate,azure,rose}-*.
  2. Every interactive element has a visible :focus-visible style with ≥3:1 contrast against its adjacent background (WCAG 2.2 SC 1.4.11).
  3. Body text ≥4.5:1, large text ≥3:1, verified in both modes with axe DevTools or APCA.
  4. Modal, menu, and disclosure primitives are tested with keyboard-only and VoiceOver/NVDA narration.
  5. Windows High Contrast Mode is tested with forced-colors: active emulation; no information conveyed by color alone.
  6. motion-reduce and prefers-contrast: more are exercised in CI via Playwright's emulateMedia.
  7. Target size ≥24×24 CSS px (SC 2.5.8), with 44×44 on coarse pointers.

The through-line: v4 gives you a token system that is genuinely part of the cascade. Treat semantic tokens as your public API, enforce the boundary mechanically, and let headless primitives own the hard parts of accessibility. Composable is not the opposite of accessible — it is how you make accessibility reusable.

Sources & Technical References

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles