#React#TypeScript

Design Systems and Design Tokens

As products grow, UI values drift and components become inconsistent. This article explains Design Tokens as design atoms, layered component libraries from tokens to business components, theme switching, and a shared language that keeps design and engineering aligned.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Design Systems and Design Tokens: Stop Every UI from Doing Its Own Thing

If you have worked on products of any meaningful size, you have probably seen this: the same "primary color" is #1677ff on the home page, #1890ff in the profile area, and #2f54eb in the admin console. Some buttons have a 4px radius, some have 6px, and others simply use rounded-full. A designer updates the primary color in Figma, and the frontend team spends an entire day searching and replacing across the codebase, still missing a few instances.

This is not a matter of someone being irresponsible. It is a problem caused by the absence of a design system.

As a product grows, UI inconsistency is almost inevitable: multiple contributors, multiple platforms and pages, accumulating legacy code, and successive generations of designers. Without a "single source of truth," drift will occur. This article avoids abstract design philosophy and focuses on practice: how to turn design decisions into variables with Design Tokens, how to layer a component library, how to implement theme switching, and how to give design and engineering a genuinely shared language.

1. Why UI Inevitably Becomes Inconsistent as Products Grow

First, acknowledge the problem. UI inconsistency usually has three root causes:

1. Values are "scattered" rather than "defined." Colors, font sizes, spacing, and border radii are written directly into individual components. Changing one brand color requires finding hundreds of hard-coded values throughout the codebase. This is called value drift: the same semantic concept looks different in different places.

2. Components are not layered. A Button handles base styling, the business logic for "submitting an order," and authentication state all at once. Nobody dares to change it because one edit affects a broad area. Primitive and business components become entangled, making reuse a luxury.

3. Design and engineering speak different languages. A designer defines color/primary/500 in Figma, but the code calls it --brand. The designer updates Figma without engineering knowing, while engineering adds color/primary/600 without syncing it back to design. Two sets of tokens will never stay aligned.

A Design System exists to solve these three problems. It is not a UI Kit or merely a component library. It is a binding shared language that connects design decisions to code implementations. Design Tokens are its primary mechanism.

2. Design Tokens: Turn Design into "Atoms"

Design Tokens are the atomic layer of a design system. Their essence is simple: extract every visual decision, including colors, font sizes, spacing, border radii, shadows, and animation durations, into named variables instead of writing literal values.

First, consider the counterexample:

tsx
// 反模式:值散落,漂移的开始
<button style={{ background: '#1677ff', padding: '8px 16px', borderRadius: '6px' }}>
  提交
</button>

Now consider the correct approach. Define tokens first, using CSS variables here:

css
:root {
  /* 颜色 token:语义化命名,而不是 color-blue-500 */
  --color-brand-primary: #1677ff;
  --color-brand-primary-hover: #4096ff;

  /* 间距 token:用 t-shirt 尺寸,可扩展 */
  --spacing-sm: 8px;
  --spacing-md: 16px;

  /* 圆角 */
  --radius-md: 6px;

  /* 字号 */
  --font-size-body: 14px;
  --font-size-heading-lg: 24px;

  /* 阴影 */
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08);
}

Components then reference tokens exclusively and never use raw values:

tsx
<button className="btn-primary">提交</button>
css
.btn-primary {
  background: var(--color-brand-primary);
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: var(--radius-md);
  font-size: var(--font-size-body);
}
.btn-primary:hover {
  background: var(--color-brand-primary-hover);
}

There is a critical naming principle here: tokens should be semantic, not literal.

  • Literal (bad): --color-blue-500, --spacing-16
  • Semantic (good): --color-brand-primary, --spacing-md, --color-text-subtle

Why? Literal names describe "what it looks like now." That becomes awkward when you change themes or brand colors: --color-blue-500 may actually be orange in a dark theme, making the name misleading. Semantic names describe "what it represents," so they remain valid across themes.

The key to cross-platform support is giving tokens one platform-neutral source. CSS variables solve only the Web. If your product also has mobile apps or mini programs, tokens should use JSON as their single source, then be compiled with tools such as Transformer or Style Dictionary into platform-specific formats: CSS variables for the Web, Swift constants for iOS, XML for Android, and config for Tailwind.

json
{
  "color": {
    "brand": {
      "primary": { "value": "#1677ff", "type": "color" },
      "primaryHover": { "value": "#4096ff", "type": "color" }
    }
  },
  "spacing": {
    "sm": { "value": "8", "type": "dimension", "unit": "px" },
    "md": { "value": "16", "type": "dimension", "unit": "px" }
  }
}

This JSON is the constitution of the design system. Every platform's visual language derives from it. Change it once, and the update takes effect everywhere.

If you use Tailwind, tokens should drive the Tailwind config directly instead of duplicating values in the config:

ts
// tailwind.config.ts —— 从 tokens 派生,别手动抄
import tokens from './design-tokens.json'

export default {
  theme: {
    extend: {
      colors: {
        brand: {
          primary: tokens.color.brand.primary.value,
          'primary-hover': tokens.color.brand.primaryHover.value,
        },
      },
      spacing: {
        sm: `${tokens.spacing.sm.value}px`,
        md: `${tokens.spacing.md.value}px`,
      },
    },
  },
}

3. Layered Architecture: Tokens → Primitive Components → Business Components → Pages

Tokens alone are not enough. A design system must be layered, or the component library will become a tangled mess. I recommend a four-layer architecture:

text
┌─────────────────────────────────────┐
│  4. 页面 (Page)                     │  ← 组装业务组件,处理布局
├─────────────────────────────────────┤
│  3. 业务组件 (Business Component)   │  ← ProductCard / OrderTable
├─────────────────────────────────────┤
│  2. 基础组件 (Primitive Component)  │  ← Button / Input / Modal
├─────────────────────────────────────┤
│  1. Tokens(原子)                   │  ← 颜色/字号/间距/圆角
└─────────────────────────────────────┘

The rule is that upper layers may depend on lower layers, but lower layers must never depend on upper layers. Tokens know nothing about Button, and Button knows nothing about ProductCard. This is a one-way dependency. Violate the rule, and the system will decay.

Here is how to implement a primitive component. It consumes only tokens and contains no business logic:

tsx
// Button.tsx —— 基础组件,纯展示,无业务
import { type ButtonHTMLAttributes } from 'react'

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}

export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
  const base = 'inline-flex items-center justify-center font-medium transition-colors'
  const variants = {
    primary: 'bg-brand-primary text-white hover:bg-brand-primary-hover',
    secondary: 'bg-transparent border border-brand-primary text-brand-primary',
    ghost: 'bg-transparent text-brand-primary hover:bg-gray-100',
  }
  const sizes = {
    sm: 'h-8 px-spacing-sm text-sm rounded-md',
    md: 'h-10 px-spacing-md rounded-md',
    lg: 'h-12 px-6 text-lg rounded-lg',
  }
  return <button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} {...props} />
}

A business component consumes primitive components and adds business semantics:

tsx
// AddToCartButton.tsx —— 业务组件,组合基础组件 + 业务逻辑
import { Button } from '@/components/ui/Button'
import { useCart } from '@/hooks/useCart'

export function AddToCartButton({ productId }: { productId: string }) {
  const { add, isLoading } = useCart()
  return (
    <Button
      variant="primary"
      size="lg"
      loading={isLoading}
      onClick={() => add(productId)}
    >
      加入购物车
    </Button>
  )
}

Pages only compose components; they do not contain styling details:

tsx
export default function ProductDetailPage({ product }) {
  return (
    <Page>
      <ProductCard product={product} />
      <AddToCartButton productId={product.id} />
    </Page>
  )
}

This layering has clear benefits: primitive components can be reused in any business context, business components focus on business logic, and pages handle only composition. Change the styling of Button, and every button across the site updates with it. Change the "add to cart" logic, and only the business component changes. The blast radius of a change remains controllable.

4. Themes and Dark Mode: Switch Multiple Themes with One Token Set

Theme switching is a classic design-system use case, and it works entirely because tokens drive it. The idea is: token names remain unchanged while their values change with the theme.

css
/* 亮色主题 */
:root {
  --color-bg-base: #ffffff;
  --color-text-primary: #1f1f1f;
  --color-bg-elevated: #fafafa;
  --color-border-subtle: #e5e5e5;
}

/* 暗色主题 —— 同名 token,换值 */
:root[data-theme='dark'] {
  --color-bg-base: #0a0a0a;
  --color-text-primary: #f0f0f0;
  --color-bg-elevated: #1a1a1a;
  --color-border-subtle: #2a2a2a;
}

Switching themes requires changing only one attribute:

tsx
// 切暗色
document.documentElement.dataset.theme = 'dark'

The component code does not need to change at all because it references var(--color-bg-base), which resolves to a different value under each theme. That is the power of tokens: they reduce the complex problem of multiple themes to swapping one variable table.

Keep these practical details in mind:

  1. Always use semantic tokens (bg-base, text-primary) rather than raw colors (gray-900). Otherwise, an area designed with a light background and dark text may become a dark background with dark text in dark mode, making it unreadable.
  2. Avoid hard-coding colors inside theme checks in components (theme === 'dark' ? '#fff' : '#000'). Once these branches proliferate, they become unmaintainable. Token resolution should handle all of them.
  3. Use prefers-color-scheme to follow the system automatically:
css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    /* 默认暗色 token */
  }
}

When the user has not made an explicit choice, follow the operating system setting.

5. Design and Engineering Collaboration: Stop Maintaining Separate Token Sets

The most common reason design systems fail is not technology but collaboration. Designers maintain one token set in Figma while frontend engineers copy another into code, and the two never stay synchronized. Every change must be made manually in both places. Who can guarantee that nothing will be missed?

The correct approach is a single source plus automated synchronization. There are two mainstream solutions:

1. The Tokens Studio plugin (formerly Figma Tokens). Designers define tokens in Figma, and the plugin exports JSON directly into the code repository. CI runs Style Dictionary to compile that JSON into each platform's format. A designer changes Figma → a PR is generated automatically → frontend engineers review it → it is merged. Token changes go through code review, with diffs, history, and rollback support.

2. A Style Dictionary compilation pipeline.

js
// style-dictionary.config.js
import StyleDictionary from 'style-dictionary'

StyleDictionary.extend({
  tokens: './design-tokens.json',
  platforms: {
    web: {
      transformGroup: 'css',
      buildPath: 'build/css/',
      files: [{ destination: 'tokens.css', format: 'css/variables' }],
    },
    ios: {
      transformGroup: 'ios-swift',
      buildPath: 'build/ios/',
      files: [{ destination: 'Tokens.swift', format: 'ios-swift/class.swift' }],
    },
    tailwind: {
      transformGroup: 'js',
      buildPath: 'build/tw/',
      files: [{ destination: 'tailwind-tokens.js', format: 'javascript/module' }],
    },
  },
}).buildAllPlatforms()

One JSON file compiles into Web CSS, iOS Swift, and Tailwind config: change it once, and the update takes effect everywhere.

There is a crucial organizational question here: who owns the tokens? I recommend joint ownership by design and frontend engineering, with repository ingestion controlled by frontend engineering. Tokens ultimately take effect as code, so the code repository is the sole source of truth. Figma is only the editor on the design side; exported JSON must pass code review before it can be merged. Without a clear boundary, collaboration will devolve into disputes.

6. Component Library Practices: Storybook, Versioning, and On-Demand Imports

A design system is not finished when its components are written. It is a product and needs documentation, versions, and a distribution mechanism.

Storybook is the de facto standard. Write stories for every component to demonstrate its variants, states, and edge cases. This is more than documentation; it is the component's test bed, where visual regression and accessibility tests run against Storybook.

tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
}
export default meta

export const Primary: StoryObj<typeof Button> = {
  args: { variant: 'primary', children: '提交' },
}

export const AllVariants: StoryObj<typeof Button> = {
  render: () => (
    <div className="flex gap-4">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
    </div>
  ),
}

Versioning is mandatory; use semantic versioning. Once multiple business applications consume a component library, a breaking change is a serious event. Treat major.minor.patch rigorously:

  • patch: bug fixes that do not affect the API
  • minor: new functionality that remains backward-compatible
  • major: breaking changes that require consumers to modify code

Before releasing a major version, always provide a deprecation period. First add an @deprecated warning to the old API, keep it for one or two releases, and remove it only in the next major version. Deleting an API immediately betrays its consumers.

Support on-demand imports. Do not bundle the component library into one monolith that consumers must import in full. Implement Tree-shaking correctly and export each component independently, so import { Button } from '@myorg/ui' bundles only Button. Bundle size is a key adoption metric for component libraries; nobody wants to add 200KB for one button.

7. Anti-Patterns: Avoid These Traps

Anti-pattern 1: Writing literal values instead of using tokens.

This is the most common trap. Writing color: '#1677ff' directly in a component feels faster in the short term but creates long-term debt. Three months later, a brand refresh makes you grep for 400 hard-coded values, and each one requires deciding, "Is this really the primary color, or just another blue?" The cost of making those judgments is far higher than the initial cost of extracting a token. A practical rule: if a visual value appears more than twice in the codebase, extract it into a token.

Anti-pattern 2: Putting everything into the component library.

A primitive component library accumulates a "user avatar component," a "payment button," and a "company Logo component," but all of these are business concerns and do not belong in the primitive library. The test is simple: could this component be used in another project outside the current company or product? If not, keep it out of the primitive library and put it in the business-component layer. A bloated primitive library leads to slow releases, more breaking changes, and fear of upgrades. Eventually, it is abandoned.

Anti-pattern 3: Giving tokens literal names.

As noted earlier, names such as --color-blue-500 are time bombs. Once dark mode arrives, the name no longer matches the actual color and code readability collapses. Always use semantic names.

Anti-pattern 4: Using conditionals for themes instead of switching tokens.

Writing {theme === 'dark' ? 'bg-black' : 'bg-white'} everywhere spreads the complexity of multiple themes across every component. The correct approach keeps token names fixed and changes their values with the theme, leaving components unaware of themes.

Anti-pattern 5: Letting design and engineering maintain separate token sets.

This was emphasized in the collaboration section. As long as two manually copied token sets exist, they will fall out of sync. Either the designer's Figma tokens are the source and CI pulls them into code, or the code tokens are the source and sync back to Figma. There can be only one source.

Summary

  1. A design system is not a component library; it is a shared language for design and engineering. It solves value drift, confused layering, and collaboration disputes, with Design Tokens as its primary mechanism.
  2. Tokens are the atoms of a design system. Turn colors, font sizes, spacing, border radii, and shadows into variables; use semantic names, keep JSON as the cross-platform single source, and compile it into each platform's format.
  3. A component library must be layered. Tokens → primitive components → business components → pages, with one-way dependencies. Upper layers may depend on lower layers, but lower layers must never depend on upper layers. The blast radius of change must remain controllable.
  4. Theme switching relies on tokens, not conditionals. Token names remain fixed while values change with the theme. Components remain unaware of themes, reducing multiple themes to "swapping one variable table."
  5. The key to collaboration is a single source plus automated synchronization. Do not maintain separate copies of Figma tokens and code tokens. Use Tokens Studio and Style Dictionary so that changing one place updates every platform.

Final takeaway: hard-coding a color takes only 10 seconds, but replacing hundreds of scattered instances across an entire site takes three days. The entire purpose of Design Tokens is to pay off those three days of debt within the initial 10 seconds.

A design system is not a one-off project. It is a continuously evolving contract. Its greatest enemy is not technical complexity but the wishful thinking that "we will hard-code it this time and fix it later." Establish the single source of truth with the first token, and the rest will grow naturally from it.

Related

Related Content