logo

Imad Attif

UI Design Systems: A Practical Guide for Frontend Engineers

Imad Attif, Sr. Frontend Engineer

16 min read

Jun 22, 2026

Every frontend team eventually has the same realization: the app has eleven slightly different buttons, three shades of "our blue," and a dark mode that breaks whenever someone hardcodes a hex value. The fix has a name, and it's not "be more careful." It's a UI design system.

Most writing about design systems is aimed at designers and talks about brand consistency. This guide is for the engineering half of the problem: how a design system is actually built, in code, in 2026. It's grounded in a real production system I studied closely (a fintech platform's component library serving web and mobile), so the patterns here are ones that hold up under real product pressure, not conference-talk idealism.

Here's the one-sentence definition to anchor everything: a design system is design decisions turned into reusable code, in two layers: tokens (the values) and components (the behaviors), plus the conventions that keep both coherent.

What is a UI design system, concretely?

Strip away the philosophy and a production design system is a package in your repo with three kinds of content:

  1. Design tokens: named values for color, typography, and spacing, so "our blue" exists in exactly one place.
  2. Components: Button, Dialog, Input and friends, each encapsulating markup, styling, behavior, and accessibility.
  3. Conventions: how variants are expressed, how classes merge, how components are imported, how they're documented and tested.

The system I studied is a good reference shape: a single packages/ui in a monorepo, 53 React components, each with exactly one Storybook story, built on a now-standard stack (Radix UI primitives, Tailwind CSS, CVA for variants, shadcn patterns). Consumers include the main web app, while the React Native app shares only the color palette and implements its own components. That last detail matters, and we'll come back to why.

A useful reframe if you've read about software architecture: a design system is encapsulation applied to UI. Consumers pass variant props and get correct-looking, accessible components; they never reach into internal structure. Every anti-pattern in design systems is some version of breaking that encapsulation.

Design tokens: the value layer

Tokens are where every design system should start, because they're cheap to add and everything else builds on them. A token is a named CSS custom property standing in for a raw value:

1:root {2  --color-primary: oklch(0.55 0.2 260);3  --text-sm: 0.875rem;4  --radius: 0.5rem;5}

The mistake teams make is stopping at one flat list. The production pattern is three layers, each with a different job:

Layer 1: the palette. Raw color scales with no meaning attached: --blue-500, --gray-100, --red-600, including alpha variants. This is "which colors exist," and nothing more.

Layer 2: semantic tokens. Meaning, mapped onto the palette: --color-primary, --color-destructive, --color-success, --color-muted. Semantic tokens must reference palette scales, never raw hex. This is the layer where light and dark mode live, because dark mode is just a different mapping of the same semantics:

1:root {2  --color-background: var(--gray-50);3  --color-foreground: var(--gray-900);4  --color-destructive: var(--red-600);5}6
7.dark {8  --color-background: var(--gray-950);9  --color-foreground: var(--gray-50);10  --color-destructive: var(--red-400);   /* lighter red reads better on dark */11}

Layer 3: the theme. The layer your styling tool consumes: typography scales, spacing, font families, wired into Tailwind (v4 does this in CSS with @theme, no JS config file) or whatever your system uses.

Why the layering is worth it: components only ever use semantic tokens. A component that says bg-destructive doesn't know or care which red that is, so rebranding is a palette edit, dark mode is a semantic remap, and no component changes for either. Notice this is the same "depend on the interface, not the internals" rule that governs good module design; tokens are interfaces for design values.

The cross-platform payoff from the real system: the palette layer ships as its own CSS-only package, shared by web and React Native. Each platform defines its own semantic mappings on top. The colors stay consistent across platforms; the meaning stays adaptable per platform. That's the right split, and it's also an honest admission that React components don't cross the native boundary, but tokens do.

Don't build behavior from scratch: primitives

Here's the hard-won lesson baked into every serious design system of the last five years: the difficult part of a component library isn't the styling, it's the behavior. A dropdown menu needs keyboard navigation, focus trapping, screen reader announcements, portal rendering, collision-aware positioning, and dismissal semantics. Getting all of that right takes months, and getting it wrong is invisible until an accessibility audit or a lawsuit.

So production systems don't build it. They build on headless primitives: libraries like Radix UI (the most common choice) that ship the behavior and accessibility with zero styling. Your design system wraps a primitive and adds the visual layer:

1import * as DialogPrimitive from '@radix-ui/react-dialog';2
3export function DialogContent({ className, ...props }) {4  return (5    <DialogPrimitive.Portal>6      <DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />7      <DialogPrimitive.Content8        className={cn(9          'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',10          'rounded-lg bg-background p-6 shadow-lg',11          className12        )}13        {...props}14      />15    </DialogPrimitive.Portal>16  );17}

Focus management, Escape-to-close, aria attributes: all inherited. You wrote CSS.

This is also the idea behind shadcn/ui, which deserves a mention because it changed how teams start design systems. Instead of installing a component library as a dependency, shadcn generates the source code of each component (Radix + Tailwind + CVA, the exact stack described here) into your repo, and you own it from there. The system I studied began from shadcn patterns and diverged where the product needed it, which is the intended use: it's a starting point you fork, not a framework you're locked into.

The same buy-don't-build judgment extends past primitives. In the production catalog, the chart component wraps recharts, toasts wrap sonner, the command palette wraps cmdk, the drawer wraps vaul. The design system's job is to be the single, styled, consistent seam over those choices, so the app never imports them directly. If the team swaps the chart library later, the app doesn't know.

Variants: the API of a component

A design-system component's public API is mostly its variants: the sanctioned set of appearances. The convention for expressing them in the Tailwind world is CVA (class-variance-authority):

1import { cva, type VariantProps } from 'class-variance-authority';2
3const buttonVariants = cva(4  // base classes: every button, always5  'inline-flex items-center justify-center rounded-md font-medium transition-colors disabled:opacity-50',6  {7    variants: {8      variant: {9        default: 'bg-primary text-primary-foreground hover:bg-primary/90',10        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',11        outline: 'border border-input bg-transparent hover:bg-accent',12        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',13      },14      size: {15        default: 'h-10 px-4 text-sm',16        sm: 'h-8 px-3 text-xs',17        lg: 'h-12 px-6 text-base',18      },19    },20    defaultVariants: { variant: 'default', size: 'default' },21  }22);23
24type ButtonProps = React.ComponentProps<'button'> &25  VariantProps<typeof buttonVariants>;26
27export function Button({ className, variant, size, ...props }: ButtonProps) {28  return (29    <button30      className={cn(buttonVariants({ variant, size }), className)}31      {...props}32    />33  );34}

Three things to notice, because they're the whole pattern:

  • Variants are a closed, typed set. VariantProps derives the prop types from the CVA definition, so <Button variant="dnager"> is a compile error. The design language is enforced by the type checker.
  • The cn utility is load-bearing. It's two lines: clsx resolves conditional classes, and tailwind-merge resolves conflicting Tailwind utilities so that a caller's className genuinely overrides base classes (cn('px-4', 'px-6') yields px-6, not both). Without it, the escape hatch doesn't work and people fork components instead.
1import { clsx, type ClassValue } from 'clsx';2import { twMerge } from 'tailwind-merge';3
4export function cn(...inputs: ClassValue[]) {5  return twMerge(clsx(inputs));6}
  • className is the escape hatch, variants are the paved road. Consumers get one-off adjustments through className, but anything used twice should become a variant. That's the governance loop of a design system in one sentence.

Context-aware components

A subtler pattern from the production system, for when the same component must look different depending on where it's used: a context variant axis. Their Typography component renders differently in the default app, inside the rich-text composer, and inside AI chat responses; a CVA context axis plus compoundVariants (rules for specific combinations, like "h1 in composer context gets different margins") encodes it:

1const typographyVariants = cva('...', {2  variants: {3    variant: { h1: '...', h2: '...', p: '...' },4    context: { default: '', composer: '', chat: '' },5  },6  compoundVariants: [7    { variant: 'h1', context: 'composer', class: 'mt-6 mb-2 text-2xl' },8    { variant: 'h1', context: 'chat', class: 'mt-3 mb-1 text-xl' },9  ],10});

The alternative people reach for (styling components from the outside based on parent selectors) breaks encapsulation and gets unmaintainable fast. Making context an explicit, typed prop keeps the component owning its appearance in every setting it supports.

Package boundaries: the imports are part of the design

How consumers import from the design system is an architectural decision, and the production system makes an opinionated one: granular per-file exports, no barrel file.

1{2  "exports": {3    "./styles/global.css": "./src/styles/global.css",4    "./components/*": "./src/components/*.tsx",5    "./lib/*": "./src/lib/*.ts"6  }7}

So consumers write import { Button } from '@acme/ui/components/button', and import { Button } from '@acme/ui' simply doesn't resolve. Two reasons, both practical:

  • Tree-shaking becomes trivial. Importing the button can't accidentally pull the chart library and the PDF viewer into your bundle, because there's no barrel aggregating them.
  • The export map is the public interface. Internal file layout can change freely; anything not listed in exports is unreachable by consumers. This is information hiding enforced by the package manager instead of code review.

If your design system lives in a monorepo (most do), this composes with workspace tooling: the package declares its dependencies explicitly, consumers can't reach into src/ paths that aren't exported, and boundary lint rules can enforce that apps import UI only through the design system rather than from Radix or recharts directly.

Storybook: the workshop and the contract

The development harness for a design system is Storybook: every component gets a story file, and the story is where the component is built, reviewed, and manually tested in isolation, long before any app uses it.

The conventions from the production system are worth copying wholesale:

  • One story file per component, no exceptions. Their ratio is exactly 53 components to 53 stories. The story is part of the definition of done.
  • Cover Default plus 2 to 4 key variants, not the full combinatorial matrix. Stories are documentation; sixty permutations document nothing.
  • Use realistic production content. "Submit expense report," not "Lorem ipsum." Fake-looking content hides real layout bugs (truncation, wrapping, long names).
  • Test both themes. A theme-switching addon renders every story in light and dark, which catches the hardcoded-hex regressions the token system exists to prevent.

Alongside stories: unit tests with Testing Library for behavioral logic, and visual regression tooling (Chromatic or similar) if the team can support it. But if you do only one thing, do the stories; they're simultaneously the dev environment, the documentation, and the review surface.

What components does a design system need?

Component catalogs converge. The production system's 53 components group into five categories, and this is roughly the checklist every mature system fills in:

  • Form: Input, Textarea, Select, Checkbox, Radio, Switch, and a Form wrapper integrating the form library (React Hook Form there).
  • Layout and overlays: Card, Dialog, Sheet, Drawer, Tabs, Sidebar, Breadcrumb.
  • Content and feedback: Typography, Avatar, Badge, Alert, Progress, Skeleton, Spinner.
  • Interactive: Button, Toggle, Tooltip, Popover, Dropdown Menu, Context Menu.
  • Data display: Table, Pagination, Scroll Area, Collapsible, Chart.

Don't build these all up front. The honest sequencing is: tokens first, then Button, Input, Dialog, and Typography (every app needs them week one), then everything else on demand, extracted from real product code the second time it's needed. A design system built ahead of real usage guesses wrong about APIs; one extracted from usage doesn't.

Common failure modes

Design systems fail in predictable ways, all of them versions of broken encapsulation or broken governance:

  • The escape hatch becomes the road. If className overrides outnumber variant usage, your variants don't match what the product needs. The fix is a feedback loop (promote repeated overrides into variants), not scolding.
  • Semantic tokens get bypassed. One #3B82F6 in app code and dark mode has a bug. Lint for raw hex values and raw palette tokens in application code.
  • The wrapper leaks. Consumers import recharts directly "just this once," and now the seam is gone and the chart library can never be swapped. Boundary rules, not vigilance.
  • Stories rot. A component changes, its story doesn't, and the documentation now lies. Same PR or it didn't happen.
  • The system says no too often. If getting a variant added takes weeks, product teams fork components, and the system dies of irrelevance. A design system is a product with internal customers; treat requests like a product team would.

FAQ

What's the difference between a component library and a design system? A component library is the code artifact: the package of components. A design system is that plus the token layer, the conventions, the documentation, and the governance process. You can install a component library; a design system is something your organization operates.

Should I use shadcn/ui or build my own design system? For most teams these aren't alternatives: shadcn is the fastest way to start your own, since it generates Radix-based, token-friendly component source into your repo that you then own and evolve. Build fully custom only if your design language diverges hard from what its primitives model, or you're not in React.

What are design tokens and why do they matter? Named variables for design values (colors, type, spacing), layered from raw palette to semantic meaning. They make theming, dark mode, rebranding, and cross-platform consistency into data changes instead of codebase-wide refactors.

How do I handle dark mode in a design system? At the semantic token layer: a .dark class remaps semantic tokens to different palette values, and components (which only reference semantic tokens) adapt automatically. If dark mode requires touching components, tokens are layered wrong.

Can a design system be shared between web and React Native? The token layer can and should be (ship the palette as its own package); the component layer realistically can't, since DOM components don't render natively. Share the values, reimplement the components per platform against the same semantics.

Summary

  • A design system is design decisions as code: tokens for values, components for behavior, conventions to keep both coherent. Its guiding principle is encapsulation.
  • Layer your tokens: palette (which colors exist), semantic (what they mean, where dark mode lives), theme (what the styling tool consumes). Components touch only semantic tokens.
  • Buy behavior, own appearance: build on headless primitives (Radix) for accessibility-critical behavior, wrap third-party libraries behind your own components, start from shadcn patterns rather than zero.
  • Variants are the API: CVA gives you a typed, closed set of appearances; cn (clsx + tailwind-merge) makes the className escape hatch actually work; repeated escapes should become variants.
  • Boundaries are features: granular package exports, no barrel file, and lint rules that keep apps importing through the system.
  • Storybook is the contract: one story per component, realistic content, both themes, updated in the same PR.

Start with tokens and four components, extract the rest from real usage, and treat the system as a product whose customers are your own engineers. That's the entire playbook.