All ArticlesUI Frameworks•November 7, 2026•8 min read

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.

Semantic Tokens, Not Utility Soup: Accessible Design Systems with Tailwind CSS v4AI Generated with Qwen 3.0 via CometAPI

Design 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(), and light-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, and outline-accent instead 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:

LayerExampleOwner
Primitive--color-brand-600Brand or design foundation
Semantic--color-accentDesign system
Component--button-bgComponent, 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.

css
@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.

tsx
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.

tsx
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-visible styles with sufficient contrast against every surface token, not just the canvas.
  • Respect prefers-reduced-motion on 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 button is better than a clickable div with ARIA patches.
  • Use aria-busy for 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.

ts
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

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