Skip to content

Turn your existing CSS into a design system

A step-by-step way to extract the colours, spacing and type already in your CSS, organise them into tokens and components, and stop them drifting again.

By 7 min read

You do not need to start a design system from a blank Figma file. If your product has been live for a while, the system already exists. It is just scattered across stylesheets, component files and inline styles, with a few dozen accidental variations mixed in. This guide shows how to pull that implicit system out of your CSS, clean it up, give it names and structure, and put guard rails in place so it stays clean. It works whether you write plain CSS, CSS Modules, Sass or Tailwind.

What you are actually building

The Nielsen Norman Group defines a design system as "a complete set of standards intended to manage design at scale using reusable components and patterns", typically made up of a style guide, a component library and a pattern library. For a small team starting from existing code, that translates into three concrete deliverables:

  1. Tokens: named values for colour, spacing, typography, radii, shadows and motion.
  2. Components: one canonical implementation of each recurring UI element, built only from tokens.
  3. Rules: a short written record of how and when to use them, plus automated checks.

You can get to a useful version of all three in a few focused days, then improve it continuously.

Step 1: Inventory what is already there

Start by extracting every raw value from your styles. Do not judge yet, just collect. A small Node script is more reliable than eyeballing files:

JavaScript
// scripts/css-inventory.mjs
import { readFileSync } from "node:fs";
import { globSync } from "glob";

const files = globSync("src/**/*.{css,scss,tsx,jsx}");
const patterns = {
  colour: /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g,
  fontSize: /font-size:\s*([^;]+)/g,
  spacing: /(?:padding|margin|gap)(?:-[a-z]+)?:\s*([^;]+)/g,
  radius: /border-radius:\s*([^;]+)/g,
  shadow: /box-shadow:\s*([^;]+)/g,
};

const counts = {};
for (const file of files) {
  const src = readFileSync(file, "utf8");
  for (const [kind, re] of Object.entries(patterns)) {
    for (const m of src.matchAll(re)) {
      const value = (m[1] ?? m[0]).trim().toLowerCase();
      counts[kind] ??= {};
      counts[kind][value] = (counts[kind][value] ?? 0) + 1;
    }
  }
}

for (const [kind, values] of Object.entries(counts)) {
  console.log(`\n${kind}`);
  Object.entries(values)
    .sort((a, b) => b[1] - a[1])
    .forEach(([v, n]) => console.log(`${String(n).padStart(5)}  ${v}`));
}

Run it and save the output. Frequency matters: a colour used 140 times is probably a real decision, while one used once is probably an accident. If you use Tailwind, also look at your config and any arbitrary values in class names.

Alongside the numbers, take screenshots of your key screens. Visual inventory catches things a grep misses, such as two greys that are technically different but look the same, or a component that is styled correctly in one place and overridden in another.

Step 2: Cluster and choose

Now group near-duplicates and choose one winner per group. Lay your colours out as swatches, sorted by hue and lightness, and you will quickly see clusters: #333, #323232 and #2f2f2f are the same intent. Pick one.

Some guidelines that keep this step fast:

  • Prefer the most-used value in each cluster unless it fails a requirement such as contrast.
  • Snap spacing to a scale. If you see 6, 8, 10, 12, 14 and 16px, a 4px-based scale (4, 8, 12, 16, 24, 32…) absorbs them with minimal visual change.
  • Limit type sizes to five or six, each paired with a line height and weight.
  • Keep one or two radii and two or three shadow levels.

Check the colours you keep against WCAG 2.2: at least 4.5:1 contrast for normal text, 3:1 for large text, and 3:1 for the visual parts of UI components such as input borders and focus indicators. Consolidation is the cheapest moment to fix contrast, because you are touching every colour anyway.

Write down a mapping table from old values to new ones. You will use it to migrate.

Step 3: Separate primitives from semantics

The single most useful structural decision is to use two layers of tokens.

Primitive tokens describe the raw palette and scales. They are named by what they are: gray-700, indigo-600, space-4.

Semantic tokens describe purpose and point at primitives: text-muted, surface-raised, accent, border-subtle. Components only ever use semantic tokens.

CSS
:root {
  /* Primitives */
  --gray-50: #fafafa;
  --gray-200: #e4e4e7;
  --gray-600: #52525b;
  --gray-900: #18181b;
  --indigo-600: #4f46e5;
  --indigo-700: #4338ca;

  /* Semantics */
  --color-bg: var(--gray-50);
  --color-border: var(--gray-200);
  --color-text: var(--gray-900);
  --color-text-muted: var(--gray-600);
  --color-accent: var(--indigo-600);
  --color-accent-hover: var(--indigo-700);
}

This separation pays off immediately. Rebranding means changing primitives. Dark mode means remapping semantics. Neither touches component code.

CSS
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: var(--gray-900);
    --color-text: var(--gray-50);
    --color-border: var(--gray-600);
  }
}

MDN describes prefers-color-scheme as detecting whether the user has requested a light or dark theme through an operating system or browser setting. Because CSS custom properties participate in the cascade and inherit from their parent, you can also scope a theme to part of the page by redefining the semantic tokens on a container.

Step 4: Migrate gradually

Do not try to replace everything in one pull request. A safer sequence:

  1. Add the token file and import it globally. Nothing changes visually yet.
  2. Replace the most-used values first. Your inventory tells you which ones. A careful find-and-replace for the top ten colours often covers a large share of usages.
  3. Map legacy variables to new tokens. If you already have variables like --primary or $brand-blue, point them at the new semantic tokens instead of deleting them, so old code keeps working while you migrate.
  4. Handle stragglers per component as you touch them for other work.
CSS
/* legacy.css: keep old names alive while migrating */
:root {
  --primary: var(--color-accent);
  --text-light: var(--color-text-muted);
}

Review each step with screenshots. Small visual differences are expected (that is the point of consolidating), but they should be differences you chose.

Step 5: Find and consolidate components

Tokens fix values. Components fix structure. Search for the elements that recur on most screens and see how many implementations exist:

Terminal
grep -rlE "<button|<Button" src | wc -l
grep -rhoE "export (default )?function \w*(Button|Btn)\w*" src | sort | uniq -c

It is common to find a shared Button, a PrimaryButton someone wrote before it existed, and a dozen raw <button> elements with their own classes. Pick the best implementation, rebuild it on semantic tokens, give it a small set of variants and sizes, and move call sites over. Do the same for inputs, cards, badges, modals and empty states.

For a React-specific walkthrough of this step, including variant APIs and lint rules, see fixing inconsistent buttons and colours across a React codebase.

Step 6: Store tokens somewhere portable

CSS custom properties are a fine runtime format, but they are awkward to share with design tools, native apps or documentation. Keeping a source-of-truth token file in a standard format means you can generate CSS, TypeScript and other outputs from one place.

The W3C Design Tokens Community Group published its first stable format specification, version 2025.10, which defines a JSON format with $value, $type and $description properties and an alias syntax for referencing other tokens. We explain the format in detail in design tokens explained for developers.

If you would rather not hand-build this, M1rror can start a design system from your code: its static scan reads your React and TypeScript repository without running it, and the components it detects appear in the System marked unconfirmed until a person confirms them. You can also import tokens from DTCG or Tokens Studio JSON, and export them as CSS variables, TypeScript, DTCG and other formats.

Step 7: Stop the drift

A design system extracted from CSS will drift straight back unless something pushes back. Automated checks are the cheapest way to do that.

Stylelint's color-no-hex rule disallows hex colours, and its ignoreFunctions option lets you allow hex values inside var() fallbacks:

JSON
{
  "rules": {
    "color-no-hex": [true, { "ignoreFunctions": ["var"] }]
  }
}

Apply it to component styles and exclude the token file itself, which is the one place raw values belong. Add similar checks for arbitrary Tailwind values or inline styles if those are common in your codebase.

Step 8: Agree on it, then version it

A design system is a shared agreement, not just a file. Before you call it done, review the tokens and core components with whoever cares about how the product looks: a designer, a founder, a client. Show them the swatches, the type scale and the main components next to real screens.

Once it is agreed, version it. Treat token changes like API changes: a new version, a short changelog, and a review before it lands. That gives designers confidence that what they approved is what ships, and gives developers a clear source of truth when two opinions conflict.

Wrap-up

Your existing CSS already contains a design system; it just needs extracting. Inventory every value, cluster near-duplicates and choose winners, split tokens into primitives and semantics, migrate gradually with legacy aliases, consolidate the components that appear everywhere, store tokens in a portable format and add checks so the mess does not come back. Then get the people who care about the result to approve it, and version it like code. If you want a quicker win first, start with making your web app look more professional.