Semantic Tokens, Not Utility Soup: Accessible Design Systems with Tailwind CSS v4
Tailwind CSS v4 turns design tokens into native CSS variables, but accessible design systems still require semantic layers, typed primitives, and deliberate guardrails. Learn how to build composable components that survive theme changes, forced-colors mode, and real production constraints.
AI Generated with Qwen 3.0 via CometAPIDesign systems fail in predictable ways: a component looks right in the default theme but breaks in dark mode, a focus ring disappears against a surface, or a button API grows into an unmaintainable prop matrix. Tailwind CSS v4 changes the mechanics of theming by moving configuration into CSS and leaning on native custom properties, cascade layers, and modern color functions. That is a strong foundation, but it is not a design system by itself. The missing layer is semantic tokens plus accessible, composable primitives.
This article shows a production-minded architecture for Tailwind CSS v4: primitive tokens, semantic tokens, typed React primitives, accessibility guardrails, and operational checks that keep the system honest as it scales.
Why Tailwind CSS v4 Changes Design System Architecture
Tailwind CSS v4 is CSS-first. Instead of maintaining a large JavaScript config, you define theme variables inside @theme, and Tailwind generates utilities that reference native CSS custom properties. That is a philosophical shift with real architectural consequences:
- Tokens are inspectable at runtime and can be overridden by media queries, data attributes, or user preference.
- Cascade layers reduce specificity wars between resets, utilities, and component styles.
- Modern CSS functions such as
oklch(),color-mix(), andlight-dark()make it practical to express contrast, hover states, and theme switching without JavaScript. - Component code can stay semantic:
bg-canvas,text-muted,border-border, andoutline-accentinstead of raw palette values.
The critical rule is simple: primitives describe the palette; semantic tokens describe intent. Components must only consume semantic tokens. If a button knows about neutral-800, dark mode and brand refreshes become a search-and-replace exercise instead of a token change.
Primitive, Semantic, and Component Tokens
A useful three-layer model looks like this:
| Layer | Example | Owner |
|---|---|---|
| Primitive | --color-brand-600 | Brand or design foundation |
| Semantic | --color-accent | Design system |
| Component | --button-bg | Component, only when necessary |
Most components should never need component-level tokens. They compose semantic utilities directly. Component tokens are useful for highly bespoke widgets, but they should remain private and documented.
@import 'tailwindcss';
@theme {
--color-brand-400: oklch(0.72 0.14 250);
--color-brand-600: oklch(0.54 0.18 250);
--color-neutral-0: oklch(1 0 0);
--color-neutral-50: oklch(0.985 0.002 250);
--color-neutral-100: oklch(0.96 0.004 250);
--color-neutral-200: oklch(0.92 0.006 250);
--color-neutral-300: oklch(0.86 0.008 250);
--color-neutral-600: oklch(0.55 0.012 250);
--color-neutral-800: oklch(0.32 0.012 250);
--color-neutral-900: oklch(0.24 0.012 250);
--color-neutral-950: oklch(0.16 0.012 250);
--color-danger-400: oklch(0.7 0.18 25);
--color-danger-600: oklch(0.55 0.2 25);
--radius-control: 0.5rem;
--color-canvas: light-dark(var(--color-neutral-0), var(--color-neutral-950));
--color-surface: light-dark(var(--color-neutral-50), var(--color-neutral-900));
--color-surface-hover: light-dark(var(--color-neutral-100), var(--color-neutral-800));
--color-text: light-dark(var(--color-neutral-950), var(--color-neutral-0));
--color-muted: light-dark(var(--color-neutral-600), var(--color-neutral-300));
--color-border: light-dark(var(--color-neutral-200), var(--color-neutral-800));
--color-accent: light-dark(var(--color-brand-600), var(--color-brand-400));
--color-accent-hover: light-dark(var(--color-brand-400), var(--color-brand-600));
--color-accent-contrast: light-dark(var(--color-neutral-0), var(--color-neutral-950));
--color-danger: light-dark(var(--color-danger-600), var(--color-danger-400));
}
:root {
color-scheme: light dark;
}
[data-theme='light'] {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
@media (prefers-contrast: more) {
:root {
--color-muted: light-dark(var(--color-neutral-950), var(--color-neutral-0));
--color-border: light-dark(var(--color-neutral-950), var(--color-neutral-0));
}
}
@media (forced-colors: active) {
:root {
--color-canvas: Canvas;
--color-surface: Canvas;
--color-text: CanvasText;
--color-border: ButtonText;
--color-accent: Highlight;
--color-accent-contrast: HighlightText;
}
}This gives you utilities such as bg-canvas, text-muted, border-border, and outline-accent. The semantic layer uses light-dark() so the system theme works by default, while a data-theme attribute can force light or dark mode. Forced-colors users get system colors instead of brand colors, which is essential for Windows High Contrast and similar modes.
Building Typed, Composable Accessible Primitives
Composability is not the same as unlimited props. A strong primitive exposes a small, typed set of variants, forwards refs, preserves native semantics, and allows controlled escape hatches through className. This is where typed variants and class merging pay off.
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-[var(--radius-control)] font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none',
{
variants: {
variant: {
primary: 'bg-[var(--color-accent)] text-[var(--color-accent-contrast)] hover:bg-[var(--color-accent-hover)]',
secondary: 'border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]',
ghost: 'text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariantProps {
isLoading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{ className, variant, size, isLoading = false, disabled, children, type = 'button', ...props },
ref
) {
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
type={type}
disabled={isDisabled}
aria-busy={isLoading || undefined}
data-slot='button'
data-variant={variant ?? 'primary'}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
>
{isLoading ? (
<span
aria-hidden='true'
className='size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none'
/>
) : null}
<span>{children}</span>
</button>
);
}
);The data-slot and data-variant attributes become a stable styling contract. Parent layouts can target [data-slot='button'] without depending on Tailwind class strings. tailwind-merge resolves conflicts when a consumer passes an override, while cva keeps variant types exhaustive and documented.
Composable Field Primitives with Error Semantics
Accessible forms need more than visual error text. The label, description, error message, and input must be connected programmatically. A small render-prop primitive keeps that contract composable without hard-coding one input implementation.
export interface FieldRenderProps {
id: string;
describedBy: string | undefined;
invalid: boolean;
}
export interface FieldProps {
id?: string;
label: string;
description?: string;
error?: string;
children: (props: FieldRenderProps) => React.ReactNode;
}
export function Field({ id, label, description, error, children }: FieldProps) {
const generatedId = React.useId();
const fieldId = id ?? generatedId;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = error ? `${fieldId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const invalid = Boolean(error);
return (
<div className='grid gap-1.5'>
<label htmlFor={fieldId} className='text-sm font-medium text-[var(--color-text)]'>
{label}
</label>
{children({ id: fieldId, describedBy, invalid })}
{description ? (
<p id={descriptionId} className='text-sm text-[var(--color-muted)]'>
{description}
</p>
) : null}
{error ? (
<p id={errorId} role='alert' className='text-sm text-[var(--color-danger)]'>
{error}
</p>
) : null}
</div>
);
}
export interface TextFieldProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'> {
label: string;
description?: string;
error?: string;
}
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
function TextField({ label, description, error, className, ...props }, ref) {
return (
<Field label={label} description={description} error={error}>
{({ id, describedBy, invalid }) => (
<input
ref={ref}
id={id}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
className={cn(
'h-10 rounded-[var(--radius-control)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-[var(--color-text)] placeholder:text-[var(--color-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]',
invalid && 'border-[var(--color-danger)]',
className
)}
{...props}
/>
)}
</Field>
);
}
);The error message uses role='alert', the input receives aria-invalid, and aria-describedby points to both the description and the error. That is the difference between a component that looks accessible and one that behaves accessibly with screen readers.
Accessibility Guardrails for Semantic Token Systems
Semantic tokens make accessibility easier, but they do not enforce it. Add guardrails at the system level rather than asking every feature team to remember them.
- Use
focus-visiblestyles with sufficient contrast against every surface token, not just the canvas. - Respect
prefers-reduced-motionon spinners, transitions, and decorative motion. - Test forced-colors mode because background colors may disappear entirely.
- Do not rely on color alone for errors; pair danger color with text and iconography.
- Keep native semantics. A styled
buttonis better than a clickabledivwith ARIA patches. - Use
aria-busyfor loading states and avoid destroying focus during async updates.
Theme persistence also needs defensive code. localStorage can throw in private browsing, embedded webviews, or disabled-storage environments.
export type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'ui-theme';
export function readStoredTheme(): Theme {
try {
const value = window.localStorage.getItem(STORAGE_KEY);
if (value === 'light' || value === 'dark' || value === 'system') {
return value;
}
return 'system';
} catch (error: unknown) {
if (error instanceof DOMException) {
console.warn('Theme storage is unavailable; falling back to system.', error);
}
return 'system';
}
}
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'system') {
root.removeAttribute('data-theme');
return;
}
root.setAttribute('data-theme', theme);
}Composing Without Coupling: Slots, Variants, and Escape Hatches
A design system scales when teams can compose without forking. Use variants for known visual states, data-* attributes for stable styling hooks, and className for layout overrides. Avoid dynamic class construction such as bg-${color}-500, because Tailwind cannot see those strings at build time and the resulting CSS becomes fragile.
For complex widgets, do not reinvent keyboard behavior. Build on proven accessible primitives such as React Aria or Radix UI, then skin them with semantic tokens. Your design system should own visual language and API ergonomics; it should not own focus trapping, typeahead, or collision-aware positioning unless that is genuinely your differentiator.
Operational Checklist for Shipping a Tailwind v4 Design System
- Lint component classes so raw palette values cannot leak into primitives.
- Add automated contrast checks for every semantic foreground/background pair.
- Run visual regression tests in light, dark, high-contrast, and forced-colors modes.
- Test keyboard-only flows and screen-reader announcements for every interactive primitive.
- Verify reduced-motion behavior with
prefers-reduced-motion: reduce. - Audit generated CSS to confirm expected utilities and token overrides are present.
- Document token intent, not just token names.
- Version breaking changes to primitive APIs and token semantics explicitly.
- Keep an escape hatch, but measure how often teams use it.
Sources & Technical References
- Tailwind CSS v4 announcement
- Tailwind CSS theme variables
- Tailwind CSS functions and directives
- MDN: Using CSS custom properties
- MDN: light-dark() color function
- MDN: color-mix()
- MDN: oklch() color function
- MDN: prefers-reduced-motion
- MDN: forced-colors
- WCAG 2.2
- WAI-ARIA Authoring Practices Guide
- React forwardRef reference
- class-variance-authority documentation
- Radix UI Primitives introduction
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.