CSS & design systems
Design tokens, scales, theming and the styling tool that fits your stack.
In 30 seconds
A design system is a shared set of decisions (colours, spacing, type, radii, motion) stored as named tokens and used by every component. Instead of typing #1a1a1a or 17px in a hundred places, you write var(--color-text) and var(--space-4). The result is a UI that looks consistent, is easy to re-theme and stays maintainable as the team and the codebase grow.
Why I use it
- I use design tokens on every project because they turn hundreds of small visual choices into a few named decisions that a designer and a developer can both read.
- I have seen what happens without them: per-project override files with hundreds or thousands of lines of !important, where nobody dares to change a colour.
- I care about the styling tool choice because it decides whether a component can render on the server, how much JavaScript ships, and how painful a redesign will be.
- A clear responsive strategy (a fixed breakpoint list plus container queries) is what makes a layout survive real content, real languages and real devices.
How it works
01The idea: decisions, not values
Think of a design system as the rulebook of a brand. A raw value like #0f62fe means nothing; a token like --color-accent tells you what it is for. Tokens usually come in two layers. Primitive tokens describe the palette (--blue-600, --gray-100). Semantic tokens describe purpose (--color-accent, --color-surface, --color-text-muted) and point at primitives. Components only use semantic tokens. That one rule is what makes theming and redesigns cheap: you change what --color-surface points to, and every card, modal and input follows. Tokens cover more than colour: spacing, font sizes, line heights, radii, shadows, z-index layers, durations and easings all belong in the same file.
:root {
/* primitives */
--gray-50: #f7f7f8;
--gray-900: #16161a;
--blue-600: #2952e3;
/* semantic: components use only these */
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
--color-accent: var(--blue-600);
--radius-md: 0.5rem;
--duration-fast: 150ms;
}
02Spacing and type scales
A scale is a short, fixed list of allowed sizes. Instead of 13px here and 15px there, you pick steps like 4, 8, 12, 16, 24, 32, 48, 64 for spacing, and a modular ratio (for example 1.2 or 1.25) for font sizes. Fewer options means faster decisions and visible rhythm. Fluid type uses clamp() so headings grow smoothly between a minimum and maximum without breakpoints: clamp(2rem, 1.2rem + 3vw, 3.5rem). Use rem for font sizes so the user's browser font setting is respected, and keep line-height unitless (1.5 for body, 1.1 to 1.2 for large headings).
- Spacing: a 4px or 8px base grid, named --space-1 to --space-10.
- Type: 6 to 8 steps are enough for most products.
- Fluid: clamp(min, preferred, max) for headings and section padding.
- Never let a component invent a size that is not on the scale.
03Theming: light, dark and brands
Because components read semantic tokens, a theme is just a different set of values for the same names. Dark mode can follow the operating system with prefers-color-scheme, or a user choice stored in a data-theme attribute on the html element. Modern CSS also has the light-dark() function, which picks one of two values based on the color-scheme property, so many tokens can be written once. For white-label products, each brand is another token set loaded on the root. Set the theme attribute on the server or in a tiny inline script before paint, otherwise users see a flash of the wrong theme.
:root {
color-scheme: light dark;
--color-bg: light-dark(#ffffff, #121214);
--color-text: light-dark(#16161a, #ececf1);
}
/* explicit user choice wins over the OS setting */
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
04CSS Modules vs styled-components vs Tailwind
All three can build a good design system; they differ in where styles live and what runs at runtime. CSS Modules are plain CSS files with locally scoped class names, compiled at build time, with zero runtime cost and full support in React Server Components. styled-components is CSS-in-JS: styles live in the component and can react to props, but styles are generated in the browser and rely on React context, so in the Next.js App Router they need client components and a style registry for SSR. The project has been in maintenance mode since 2025 and is not a good default for new RSC projects. Tailwind is utility-first: you compose classes like px-4 text-sm in the markup. Version 4 defines tokens in CSS with @theme, generates only the classes you use, and works perfectly with server components.
- CSS Modules: real CSS, zero runtime, RSC-safe; you name things yourself.
- styled-components: dynamic props and co-location; runtime cost, client-only in RSC.
- Tailwind: very fast to build, tiny CSS output, RSC-safe; long class lists need discipline.
- Zero-runtime CSS-in-JS (vanilla-extract, Panda, StyleX) sits in between.
05Component libraries
Your token file is the foundation; the component library is the house built on it. Start with the boring, high-traffic pieces: Button, Input, Select, Checkbox, Modal, Toast, Card, Stack and Grid layout primitives. Each component should have a small, typed API (variant, size, disabled) instead of accepting arbitrary styles, so the system stays consistent. For complex interactive widgets (dialogs, menus, comboboxes), build on headless, accessible primitives such as Radix, React Aria or Ark UI rather than writing focus traps and keyboard handling yourself. Document each component with real examples, and version shared packages with semantic versioning so a breaking change in a button does not surprise ten consuming apps.
// Button/index.tsx - a small, closed API
import s from "./style.module.css";
type Props = {
variant?: "primary" | "ghost";
size?: "sm" | "md";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ variant = "primary", size = "md", className, ...rest }: Props) {
const cls = [s.button, s[variant], s[size], className].filter(Boolean).join(" ");
return <button className={cls} {...rest} />;
}
06Responsive strategy and container queries
Pick one breakpoint list, store it in one place, and use it everywhere. Whether you go mobile-first (min-width) or desktop-first (max-width) matters less than being consistent. Media queries answer the question 'how wide is the screen?'. Container queries answer the better question for components: 'how wide is the box I am in?'. A product card can switch from stacked to horizontal when its container is wide enough, whether it sits in a sidebar or a full-width grid. Container queries and the cqi unit are supported in all modern browsers. Use intrinsic layouts too: grid with repeat(auto-fill, minmax(16rem, 1fr)) often removes the need for breakpoints entirely.
.cardWrap {
container-type: inline-size;
}
.card {
display: grid;
gap: var(--space-3);
}
@container (min-width: 28rem) {
.card { grid-template-columns: 10rem 1fr; }
}
07Production tips
Guard the system with tooling, not memory. Stylelint can forbid raw hex colours and px values outside the token file. Cascade layers (@layer reset, tokens, components, utilities) make specificity predictable so you never need !important. Keep tokens additive: renaming or deleting a token is a breaking change, so introduce the new one, migrate, then remove the old. Test components at your real breakpoints and with long translated strings, because German, Russian or Armenian text breaks layouts that look fine in English. Finally, check contrast for every text and background token pair in both themes.
- Lint rule: no hex, no px outside tokens.
- Use @layer instead of !important.
- Add tokens first, migrate, then delete.
- Test with the longest locale and at every breakpoint.
Use it when
- Any product with more than a handful of screens or more than one developer.
- When you need dark mode, white-labelling or multiple brands from one codebase.
- When several apps should look and behave the same and share components.
- Before a redesign, so the next one is a token change instead of a rewrite.
Skip it when
- A one-page landing that will live for a few weeks; a few CSS variables are enough.
- Do not build a full component library before the product's UI has stabilised; extract components after you have seen them repeat.
Alternatives
- Tailwind CSS
Utility classes with tokens defined in @theme; fast to build, RSC-friendly, opinionated markup.
- CSS Modules
Scoped plain CSS with zero runtime; my default for server-first Next.js projects.
- styled-components / Emotion
Runtime CSS-in-JS; convenient props-based styling, but client-only under React Server Components.
- vanilla-extract / Panda CSS / StyleX
Type-safe styles in TypeScript compiled to static CSS at build time.
Best practices
- Two token layers: primitives for the palette, semantic tokens for components.
- Components use semantic tokens only, never raw values.
- Keep one breakpoint list and one spacing scale in a single file.
- Prefer container queries for components that appear in different layouts.
- Build complex widgets on accessible headless primitives.
- Make token changes additive and version shared packages.
- Check colour contrast for every text and background pair in each theme.
Common mistakes
- Hard-coding hex and px in components, which makes theming impossible.
- Fighting specificity with !important instead of using cascade layers or scoped styles.
- Choosing a runtime CSS-in-JS library for a server-components-first app and then marking everything as a client component.
- Inventing breakpoints per component, so the layout jumps at a dozen different widths.
- Giving components a style escape hatch for everything, which slowly destroys consistency.
Deep dive
08Cascade layers: an architecture for specificity
Cascade layers (@layer) decide which group of styles wins before specificity is even compared. You declare the order once, for example reset, vendor, tokens, base, components, utilities, and any rule in a later layer beats any rule in an earlier one, however specific its selector is. That ends the arms race of longer selectors and !important. Three details trip people up. Styles outside any layer beat all layered styles, so CSS you do not control should be imported into a low layer with @import url(...) layer(vendor), otherwise it silently wins. !important reverses the order: an important declaration in an early layer beats an important one in a later layer, which lets a reset protect accessibility rules. And the order is fixed the first time a layer name appears, so declare the full list at the very top of the entry stylesheet, before any import.
@layer reset, vendor, tokens, base, components, utilities;
@import url("datepicker.css") layer(vendor);
@layer components {
.button { background: var(--color-accent); }
.card .button.primary { background: var(--color-accent-strong); }
}
@layer utilities {
/* wins over both rules above, despite lower specificity */
.bg-surface { background: var(--color-surface); }
}
09Colour you can compute: OKLCH, color-mix() and relative colours
Hex and HSL are poor at building scales. Two HSL colours with the same lightness value can look very different in brightness, so generated tints fail contrast unpredictably. OKLCH describes colour as perceived lightness, chroma (colourfulness) and hue, so stepping lightness from 0.95 down to 0.25 gives an even-looking scale and makes contrast easier to reason about. Modern CSS can also derive colours in the browser. color-mix(in oklch, var(--brand), black 15%) makes a hover shade from any brand colour, and relative colour syntax, oklch(from var(--brand) l c h), lets you change one channel and keep the rest. All of this works in current versions of the major browsers. The practical win is white-labelling: a tenant supplies one or two brand colours, and the system computes hover, pressed, subtle background and border states. Computed colours are a starting point, not a guarantee, so still test every text and background pair against WCAG contrast.
:root {
--brand: oklch(0.58 0.17 255);
--color-accent: var(--brand);
--color-accent-hover: color-mix(in oklch, var(--brand), black 15%);
--color-accent-subtle: oklch(from var(--brand) 0.95 calc(c * 0.3) h);
--color-accent-border: oklch(from var(--brand) calc(l + 0.15) c h);
--color-on-accent: oklch(0.99 0 0);
}
[data-brand="b"] {
--brand: oklch(0.62 0.19 25); /* the only value a new brand changes */
}
10Tokens as a pipeline, not a file
In one app, a single tokens.css file is enough. Across several web apps, a native mobile app and a design tool, tokens become a product with consumers. The source of truth is then a JSON file in the W3C Design Tokens Community Group (DTCG) format, which published its first stable version, 2025.10, and is understood by Figma plugins and build tools. A transformer such as Style Dictionary turns that source into CSS custom properties, a typed TypeScript object for autocompletion, and Swift or Kotlin constants for mobile. Treat the package like any public API: semantic versioning, a changelog, and deprecations that keep an old name as an alias for one major version while a lint rule warns about it. Run screenshot tests (for example Storybook with a visual diff service) on every token change, because one value can move hundreds of components at once.
{
"color": {
"blue-600": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.145, 0.388, 0.922], "hex": "#2563eb" }
},
"accent": { "$type": "color", "$value": "{color.blue-600}" }
},
"space": {
"4": { "$type": "dimension", "$value": { "value": 1, "unit": "rem" } }
}
}
11Runtime cost of CSS at scale
CSS performance rarely matters until a page has thousands of elements; then a few things cost real time. Style recalculation: changing a custom property on :root, for example on theme switch, invalidates styles for every element that inherits it. Once per click is fine; animating a root variable every frame is not, so scope animated variables to the element that uses them. Register animated variables with @property and inherits: false, which gives them a real type, makes them interpolate smoothly and stops the change from cascading through the tree. Unused CSS: a global stylesheet only grows, so prefer per-component CSS (CSS Modules, or Tailwind, which generates only the classes it finds) and check the DevTools Coverage panel. Off-screen work: content-visibility: auto with contain-intrinsic-size lets the browser skip layout and paint for long sections below the fold. Selector matching is rarely the bottleneck; measure with the Performance panel's selector stats before rewriting selectors.
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.meter {
background: linear-gradient(90deg, var(--color-accent) var(--progress), transparent 0);
transition: --progress 300ms ease-out;
}
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}
Real-world scenarios
A white-label client needs its own brand in two weeks
Problem
The product is being sold to a second client who wants their own colours, font and corner radius. Components contain a mix of tokens and hard-coded values, so nobody knows how much will break.
How to solve it
Start with an audit: search component files for hex, rgb() and px values and list them; anything that must change per brand becomes a semantic token. Then add a brand mapping, a [data-brand] block that remaps semantic tokens to the new primitives, plus font-family and radius tokens. Derive hover, pressed and subtle states with color-mix() or relative colour syntax, so the client only supplies two or three colours. Run an automated contrast check over every text and background pair for both brands in both themes. Finally, enable a Stylelint rule such as color-no-hex for component folders, so new hard-coded values fail CI. Component logic does not change; only values do.
Third-party widget styles keep overriding the design system
Problem
A date picker and a chat widget ship their own global CSS. Some of their selectors beat your component styles, and the team has started adding !important to fight back.
How to solve it
Move vendor CSS into a low cascade layer. Declare the order at the top of the entry stylesheet, for example @layer reset, vendor, base, components, utilities, and import the widget CSS with layer(vendor). Now any component rule wins regardless of specificity. Remove the !important patches one by one, checking each with a visual regression run. If a widget injects a style tag at runtime that you cannot layer, wrap it in a container and target it from the components layer with @scope, or isolate it in a shadow root. Write the layer order into the contribution guide so new code lands in the right layer.
Dark mode passes design review but fails an accessibility audit
Problem
The dark theme looks fine to the team, but an audit finds muted text and placeholder text below 4.5:1 and focus rings that disappear on dark surfaces.
How to solve it
Make contrast a test, not a judgement. Export every semantic text, icon and border pair per theme and check them in CI with a WCAG contrast library, failing below 4.5:1 for text and 3:1 for UI parts. Fix it at the token level: raise the lightness of --color-text-muted in the dark mapping instead of patching components. Give focus its own token pair, such as --focus-ring and --focus-ring-offset, defined per theme, and draw a two-colour ring (outline plus a contrasting box-shadow) so it shows on any background. Then test once more in Windows forced-colors mode, where custom colours are replaced by system colours.
Cheat sheet
- Declare layer order once
@layer reset, vendor, tokens, base, components, utilities;- Import vendor CSS into a low layer
@import url("vendor.css") layer(vendor);- Hover shade from any colour
color-mix(in oklch, var(--color-accent), black 15%)- Adjust one channel of a colour
oklch(from var(--brand) calc(l + 0.2) c h)- Theme-aware value without media queries
color-scheme: light dark; color: light-dark(#111, #eee);- Fluid type that respects zoom
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);- Component-level responsive rule
.wrap { container-type: inline-size; } @container (width > 40rem) { ... }- Typed, animatable custom property
@property --x { syntax: "<length>"; inherits: false; initial-value: 0px; }- Forbid hex colours (Stylelint)
"color-no-hex": true- Skip rendering off-screen sections
content-visibility: auto; contain-intrinsic-size: auto 600px;
In my real projects
- On a pet-supplies e-commerce platform I wrote the design-system rules into the project's AI instructions: design tokens only, 7 fixed breakpoints, no hex or px in components, and theme changes that are additive only.
- I co-maintain an in-house package ecosystem with a core spacing-token set and an admin UI design system of about 20 components, plus a design inspector that lets designers tune CSS variables live and save them back to source.
- In that ecosystem I planned a move to semantic tokens to remove per-project override files of roughly 383 to 2,248 lines each.
- I have shipped with all three approaches: styled-components on several Next.js storefronts, Tailwind on a SaaS admin, and CSS Modules with a single tokens file on this site.
Questions & answers
Q1What is a design token, and why two layers?
A design token is a named design decision, such as a colour, a spacing step or a duration, stored once and referenced everywhere, usually as a CSS custom property. Two layers keep it flexible. Primitive tokens describe what exists, like --blue-600. Semantic tokens describe purpose, like --color-accent or --color-surface, and point at primitives. Components only consume semantic tokens. Then dark mode, a rebrand or a white-label client is just a new mapping from semantic names to primitives, and no component code changes. Without the semantic layer you end up searching for every place blue-600 was used and guessing which ones were really 'accent'.
Q2CSS Modules, styled-components or Tailwind for a new Next.js App Router project?
For a server-components-first app I would choose CSS Modules or Tailwind. Both compile to static CSS at build time, add no runtime JavaScript and work in server components. CSS Modules suit teams that like writing real CSS with tokens and want clean markup; Tailwind suits teams that value speed and are happy with utility classes. styled-components relies on React context and runtime style injection, so in the App Router it forces client components and an SSR registry, and the project has been in maintenance mode since 2025. I would keep it in existing codebases where it works, but not start a new one with it.
Q3When would you use container queries instead of media queries?
Media queries are about the viewport; container queries are about the component's own space. I use media queries for page-level layout, like switching the navigation or the number of grid columns. I use container queries for reusable components that appear in different contexts, such as a product card that sits in a narrow sidebar on one page and a wide grid on another. With container-type: inline-size on the wrapper and an @container rule, the card adapts to its real width. This removes a whole class of bugs where a component looks right on a big screen but is squeezed inside a narrow column.
Q4How do you implement dark mode without a flash of the wrong theme?
First, make every component read semantic colour tokens, so a theme is only a different set of values. Second, decide the theme before the first paint. If the user has a saved preference, read it from a cookie on the server and render data-theme on the html element, or run a tiny inline script in the head that sets it from localStorage. If there is no preference, follow prefers-color-scheme through color-scheme and light-dark(). Avoid toggling the theme in a React effect after hydration, because the page has already painted in the default theme by then and the user sees a flash.
Q5How do you keep a design system consistent across a team?
With tooling and a small API surface rather than reminders. Tokens live in one file, and a Stylelint rule forbids raw colours and pixel values elsewhere. Components expose a closed set of variants and sizes instead of arbitrary style props. Shared components are documented with live examples and versioned, so consumers upgrade deliberately. Code review checks for new one-off values, and I write the rules into the project's contribution guide or AI instructions so assistants follow them too. When someone needs something the system does not offer, we add it to the system properly instead of patching it locally.
Q6How do you make a design system accessible?
Accessibility starts in the tokens: every text and background pair must meet WCAG contrast, 4.5:1 for normal text and 3:1 for large text and UI parts, in every theme. Focus styles are a token too and are never removed, only restyled with :focus-visible. Interactive components use real elements, such as button for actions and a for navigation, and complex widgets are built on headless primitives that already handle keyboard support, focus trapping and ARIA. Sizes respect user font settings by using rem, targets are at least 24 by 24 CSS pixels, and motion tokens respect prefers-reduced-motion.
Senior-level questions
S1How would you roll out a breaking token change across a dozen consuming apps?
I treat the token package as a public API. First, I never rename in place: I add the new semantic token, keep the old one as an alias pointing to it, and mark it deprecated in the generated TypeScript types and in a lint rule that warns on use. That ships as a minor version, so apps upgrade without breakage. Second, I provide a codemod, often a simple PostCSS or find-and-replace script, that rewrites old names. Third, I track adoption: a CI job that searches each repository for deprecated names gives a burn-down list. Only when usage reaches zero, or after an announced date, do I remove the alias in a major version. Visual regression tests on each app catch cases where the alias was not truly equivalent. The trade-off is a slower rollout in exchange for no surprise breakage.
S2Build-time or runtime theming for a multi-brand product?
Runtime theming with CSS custom properties is my default: one CSS bundle, the brand or theme is a data attribute that remaps semantic tokens, and switching costs one style recalculation. It suits user-toggled dark mode and tenant branding loaded from a database, with no rebuild per tenant. Build-time theming generates a stylesheet per brand; each brand ships slightly less CSS and you get compile-time checks, but every new brand needs a build and deploy, and switching means reloading. Many teams combine them: palettes generated at build time from the token source, semantic mapping applied at runtime. What I avoid is theming through a JavaScript theme provider in React, because it forces client components and re-renders the tree on every theme change, which hurts both server rendering and interaction performance.
S3How do Tailwind and a token system fit together?
They are complementary: tokens decide which values exist, Tailwind is a convenient way to apply them. Tailwind v4 is configured in CSS: you declare tokens inside an @theme block, and it generates utilities such as bg-accent or p-4 and also exposes every value as a CSS variable that plain CSS can read. I map semantic tokens into @theme and reset the default palette with --color-*: initial, so nobody can reach for an arbitrary blue-500. Arbitrary values like p-[13px] are allowed but flagged in review. Complex components can still use CSS Modules that read the same variables. The real risk with utilities is drift through one-off values and long, unreadable class lists; the cure is a small set of shared component primitives and lint rules, not abandoning Tailwind.
Glossary
- Cascade layer
- A named group of styles (@layer). Later layers win over earlier ones before specificity is compared.
- Specificity
- The browser's score for how precise a selector is; it decides conflicts inside the same layer and origin.
- OKLCH
- A colour space using perceived lightness, chroma and hue, so equal steps look equally different to the eye.
- color-mix()
- A CSS function that blends two colours in a chosen colour space, for example to make hover shades.
- Relative colour syntax
- Building a new colour from an existing one, changing selected channels: oklch(from var(--x) l c h).
- DTCG format
- The W3C community group's JSON standard for design tokens, readable by design tools and build tools.
- Style Dictionary
- A build tool that converts one token source into CSS, JavaScript, iOS and Android outputs.
- @property
- Registers a custom property with a type, initial value and inheritance, so it can animate and validate.
- Style recalculation
- The browser step that works out final styles per element; large invalidations make it slow.
- @scope
- A CSS at-rule that limits styles to a subtree, optionally with a lower boundary where they stop.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.