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.
Photo by Jonathan Meza on PexelsTailwind 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.
/* 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.
@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.
/* `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.
@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.
@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).
// 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.
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:
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: 3pxif 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:
- No component file references a Tier-1 primitive — enforce with a Stylelint
declaration-property-value-disallowed-listor an ESLintno-restricted-syntaxrule overbg-{slate,azure,rose}-*. - Every interactive element has a visible
:focus-visiblestyle with ≥3:1 contrast against its adjacent background (WCAG 2.2 SC 1.4.11). - Body text ≥4.5:1, large text ≥3:1, verified in both modes with axe DevTools or APCA.
- Modal, menu, and disclosure primitives are tested with keyboard-only and VoiceOver/NVDA narration.
- Windows High Contrast Mode is tested with
forced-colors: activeemulation; no information conveyed by color alone. motion-reduceandprefers-contrast: moreare exercised in CI via Playwright'semulateMedia.- 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
- Tailwind CSS v4 — Theme Variables (official
@theme,@theme inline, and namespace documentation) - Tailwind CSS — Functions & Directives (
@custom-variant,@utility,@reference,@variant) - Tailwind CSS v4.0 Release Announcement
- Upgrade Guide to Tailwind CSS v4 (border color, ring width,
outline-hidden, gradient renames) - MDN — `light-dark()`
- MDN — `color-scheme`
- MDN — `@media (forced-colors)`
- MDN — `@starting-style`
- W3C — CSS Custom Properties for Cascading Variables Level 1
- W3C — CSS Color Module Level 4 (`oklch`, `color-mix()`)
- W3C — Web Content Accessibility Guidelines (WCAG) 2.2 (SC 1.4.3, 1.4.6, 1.4.11, 2.4.7, 2.5.8)
- W3C — WAI-ARIA Authoring Practices Guide
- Radix Primitives — Accessibility Overview
- Design Tokens Community Group — Format Specification
- class-variance-authority Documentation
- tailwind-merge Documentation
- APCA Contrast — Easy Intro
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.