A design system in 2026 looks less like a Storybook-fronted monorepo and more like a thin layer over well-chosen primitives. Tailwind v4 carries the tokens. Headless UI, Radix, or React Aria carries the behaviour and accessibility. Your design system — the thing your team actually owns — is the composition layer: the variant API, the theming model, the handful of conventions that keep a Button in a marketing site and a Button in a dashboard looking and behaving the same. That's the layer most teams over-invest in and under-design. Here's the pragmatic version that holds up past the first product team.
The 2026 stack, unapologetically
The default stack we reach for on new SaaS and marketplace projects: Next.js + React Server Components, Tailwind v4 for styling and tokens, Radix Primitives for behaviour-heavy components, Headless UI for the lighter stuff, class-variance-authority (cva) for variant APIs, and tailwind-merge to defuse class conflicts at the consumer boundary. shadcn/ui is useful as a starting point — not as a dependency, but as a set of reference implementations you copy, own, and evolve. None of that is novel. What's new in 2026 is that the combination has stabilised enough that rolling your own from this base is a two-week project, not a quarter-long one.
Pick your primitives
Headless primitives give you unstyled, accessible behaviour — focus management, keyboard navigation, ARIA wiring, portal handling — without imposing a look. The three serious options in 2026 each make different tradeoffs.
| Library | Component coverage | API style | Best for |
|---|---|---|---|
| Radix Primitives | Broad (30+) | Composable, slot-based | Design systems that need full control and edge-case primitives |
| Headless UI | Focused (~16) | Ergonomic, opinionated | Tailwind-first teams who want fewer decisions |
| React Aria Components | Broad (30+) | Render-props, very explicit | Teams with strict accessibility requirements (WCAG AA+, government, fintech) |
| Ark UI | Broad, framework-agnostic | State-machine-driven | Multi-framework products (React + Vue + Solid sharing components) |
| Base UI (MUI) | Growing | Slot-based | Teams migrating off Material UI without changing their stack |
The working default for most SaaS teams is Radix for the hard stuff (Dialog, Popover, DropdownMenu, Tooltip, Select, Tabs) and Headless UI for the lighter pieces or where you want a flatter API (Transition, Combobox). React Aria is the right call only if you have accessibility requirements that go beyond WCAG AA and you're prepared to absorb its more verbose render-prop API. Don't mix three libraries into one system unless you have a reason; pick one as the primary and use others only for the components the primary doesn't ship.
Tokens as CSS variables, not JavaScript objects
Design tokens are the contract between design and code, and in 2026 the contract is written in CSS. Tailwind v4's @theme block exposes tokens as native custom properties, which means the same --color-bg-subtle that generates a bg-bg-subtle utility is also available to inline styles, runtime JavaScript, SVG fills, and third-party components that don't know Tailwind exists. That single-source-of-truth property is the thing most v3-era design systems hacked around with theme() imports and runtime token providers. v4 gives it to you for free.
Structure tokens in three layers. Base tokens are raw values (--color-blue-500, --spacing-4). Semantic tokens give them purpose (--color-bg-default, --color-text-muted, --color-border-subtle). Component tokens are the rare cases where a component needs its own knob (--color-button-primary-bg). Consumers only ever reference semantic and component tokens. Base tokens are an implementation detail. That discipline is what keeps a theme swap from turning into a find-and-replace across 400 files.
/* design-system/tokens.css */
@theme {
/* Base — raw scale */
--color-slate-50: oklch(0.98 0.003 247);
--color-slate-900: oklch(0.21 0.034 264);
--color-brand-500: oklch(0.72 0.18 250);
--color-brand-600: oklch(0.64 0.19 250);
/* Semantic — what consumers actually use */
--color-bg-default: var(--color-slate-50);
--color-bg-subtle: oklch(0.97 0.005 247);
--color-text-default: var(--color-slate-900);
--color-text-muted: oklch(0.44 0.02 264);
--color-border-default: oklch(0.92 0.008 247);
--color-accent: var(--color-brand-500);
--color-accent-hover: var(--color-brand-600);
}
:root[data-theme="dark"] {
--color-bg-default: var(--color-slate-900);
--color-bg-subtle: oklch(0.26 0.03 264);
--color-text-default: oklch(0.98 0.003 247);
--color-text-muted: oklch(0.68 0.02 264);
--color-border-default: oklch(0.33 0.03 264);
}The component API — cva and tailwind-merge
Every component in a scalable design system has the same shape: a recipe of variants (variant, size, tone) mapped to class strings, a mechanism for consumers to extend those classes safely, and a primitive underneath handling behaviour. class-variance-authority covers the recipe. tailwind-merge covers safe extension — it resolves conflicts so that a consumer passing className="px-8" overrides the component's default padding rather than fighting it. That two-function combination is the backbone of every shadcn-style component you'll write.
// components/button.tsx
import { forwardRef } 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 items-center justify-center gap-2",
"rounded-md text-sm font-medium",
"transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
"disabled:opacity-50 disabled:pointer-events-none",
],
{
variants: {
variant: {
primary:
"bg-accent text-white hover:bg-accent-hover",
secondary:
"bg-bg-subtle text-text-default hover:bg-border-default",
ghost:
"text-text-default hover:bg-bg-subtle",
destructive:
"bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "h-8 px-3",
md: "h-10 px-4",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button> & { asChild?: boolean };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={twMerge(button({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";Three things to notice in that shape. First, every class string references semantic tokens (bg-accent, text-text-default) and not raw palette values, so dark mode and theming work without touching the component. Second, asChild + Radix Slot lets the consumer render whatever element they need while keeping the button's styling and accessibility — a common escape hatch for when a Button has to be an anchor. Third, twMerge at the boundary means className from the caller always wins, which is the only contract that doesn't break at scale.
Theming and dark mode
Theming in v4 is a root-level attribute swap, not a provider tree. Set data-theme="dark" on the html element (or whatever scope you want), override the semantic tokens under that selector, and every component reading semantic tokens themes itself. No React context, no re-render, no Next.js hydration dance. For multi-brand setups — one codebase serving several white-labelled skins — the same pattern scales: data-brand="acme" overrides tokens for that brand, and components don't care.
- Use a small script in the head to set the theme attribute before React hydrates. That avoids the dark-mode flash that every app ships with at some point.
- System preference belongs in code, not CSS. Read prefers-color-scheme once, write the attribute, store the choice — then listen for changes only if the user hasn't made an explicit selection.
- Respect prefers-reduced-motion. Tailwind v4 ships motion-safe: and motion-reduce: variants; use them on anything that moves, not just the hero section.
Accessibility defaults worth keeping
The headless primitive does the heavy ARIA lifting, but accessibility still leaks into your layer through a handful of defaults. Set them once, globally, and stop worrying about them.
- Visible focus rings on every interactive element, keyed to a semantic --color-accent. focus-visible: keeps them out of mouse-user's way while keeping them for keyboard users.
- Minimum touch target of 44×44 pixels on mobile. The size.sm variant looks great on desktop and fails on iOS; gate it behind a media query or forbid it on touch-primary components.
- Color contrast audited against WCAG AA at token creation time. OKLCH makes this measurable; add a design-review step that runs tokens through a contrast checker before they ship.
- Form inputs with associated labels — not placeholders standing in for labels. Headless UI's Field and React Aria's TextField both wire this correctly by default; use them instead of rolling your own.
- Don't disable autocomplete without reason. Browsers and password managers are better at UX than your disabled input is.
Avoid the CSS-in-JS flashback. In 2020 the design-system default was styled-components or Emotion; the trap was building runtime style engines that serialised tokens on every render. Tailwind v4 tokens are static CSS variables — resist the urge to wrap them in a React context and re-invent the runtime tax you just escaped. If you need dynamic styles, inline style={} with the variable, not a JS-in-CSS library.
Packaging and distribution
The design system doesn't have to be a published package. The shadcn-style approach — a CLI that copies components into the consumer's codebase — has become the dominant pattern for one good reason: it deletes the versioning tax. Consumers own the code, they edit it when they need to, and they upgrade on their own timeline. For a single product, or a small stable of products in one monorepo, that's the right shape. For multiple independent products (say, a marketplace and a separate internal tool), the shadcn-copy model stops scaling around the point where the same fix has to be applied in four places. That's when you graduate to a published package — but not before.
A published design-system package is a committment. Every consumer becomes a breaking-change negotiation. Stay with the copy-and-own model until you have three consumers making the same fixes in parallel; only then is the coordination overhead of a shared package a net win.
Documentation that doesn't rot
Storybook is still the standard, but a design-system site in 2026 is lighter than the Storybook-monorepo setups of 2022. The minimum viable docs: one live example per variant, copy-pasteable code, props table auto-generated from TypeScript, and an accessibility notes section per component. Skip the design-token explorer page, the pixel-specs page, the Figma-sync integration. The component itself is the documentation; everything else decays faster than it pays back.
When to stop building and ship
The most common design-system failure mode is scope drift. A team starts with "we need a Button" and six months later is shipping a utility-class audit tool, a Figma plugin, and a style-lint custom rule. The fix is a finished line. A design system is done for v1 when it has tokens, a dozen components (Button, Input, Label, Select, Checkbox, Radio, Card, Dialog, Dropdown, Toast, Tabs, Avatar), a dark theme, and docs. That's enough for any SaaS product to look consistent. Ship it, use it on three real features, then extend based on friction — not a wishlist.
The team that ships a small design system in week two and extends it for six months beats the team that designs a comprehensive system for six months and ships it in week twenty-four. Every time.
Key takeaways
- Your design system is the composition layer — tokens, variant API, conventions. Headless primitives handle behaviour and accessibility; Tailwind handles styling. Don't re-invent either.
- Tokens as CSS variables via Tailwind v4's @theme. Three layers — base, semantic, component — and consumers only ever touch semantic tokens.
- cva + tailwind-merge is the component backbone. Variants as data, className from the caller always wins, asChild as the escape hatch for polymorphic elements.
- Radix for the hard primitives, Headless UI for the light ones, React Aria only when accessibility requirements go beyond WCAG AA. One primary library, not three.
- Copy-and-own beats a published package until you have three consumers and a coordination cost. Ship v1 with a dozen components and extend from real friction, not a wishlist.