
Building a Design System with Modern CSS
A design system is the difference between a product that feels coherent and one that feels like five teams built it in five different years. It's the shared set of decisions, colors, spacing, type, and components, that lets anyone on the team build a new screen that looks like it belongs.
For a long time, building one in CSS meant reaching for a preprocessor, a utility framework, or a CSS-in-JS library, because plain CSS lacked variables, scoping, and any real way to manage the cascade. That's no longer true. Custom properties, cascade layers, container queries, :where(), and relative color syntax give you everything you need to build a maintainable system in the language the browser already speaks. This guide walks through building one from the ground up.
What a Design System Needs From CSS
Before writing code, it helps to name the problems a design system has to solve:
- A single source of truth for values like colors, spacing, and font sizes, so changing a brand color is one edit.
- Theming, so the same components work in light mode, dark mode, and perhaps a second brand.
- Predictable overrides, so a product team can adjust a component without a specificity war.
- Components that adapt to wherever they're placed, not just to the viewport width.
- Low coupling, so components don't break when the page around them changes.
Each of these maps to a modern CSS feature. Let's build the system layer by layer.
Layer 1: Design Tokens as Custom Properties
Design tokens are named design decisions. Instead of #2563eb scattered through fifty files, you have --color-brand. The most maintainable approach uses two tiers of tokens.
Primitive Tokens
Primitive tokens describe the raw palette. They have no meaning beyond their value:
:root {
/* Color primitives */
--blue-100: oklch(0.93 0.03 250);
--blue-500: oklch(0.62 0.19 255);
--blue-700: oklch(0.48 0.18 262);
--slate-50: oklch(0.98 0.005 250);
--slate-200: oklch(0.9 0.01 250);
--slate-700: oklch(0.37 0.03 257);
--slate-900: oklch(0.21 0.03 265);
--pink-500: oklch(0.66 0.21 355);
/* Spacing scale (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
/* Radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 999px;
}
Using OKLCH for primitives is worth the switch. Its lightness channel is perceptually uniform, so --blue-500 and --pink-500 at the same lightness value actually look equally light, which makes building accessible, consistent scales far easier than with HSL. OKLCH is supported in all modern browsers.
Semantic Tokens
Semantic tokens describe purpose, and they point at primitives. Components only ever use semantic tokens:
:root {
--color-bg: var(--slate-50);
--color-surface: white;
--color-text: var(--slate-900);
--color-text-muted: var(--slate-700);
--color-border: var(--slate-200);
--color-accent: var(--blue-500);
--color-accent-strong: var(--blue-700);
--color-danger: var(--pink-500);
--focus-ring: 0 0 0 3px var(--blue-100);
}
This indirection is what makes theming cheap. A dark theme doesn't touch components at all. It just remaps semantic tokens.
Fluid Type Scale
Typography tokens can be fluid using clamp(), so headings scale smoothly between a minimum and maximum size without breakpoints:
:root {
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--text-sm: clamp(0.875rem, 0.85rem + 0.1vw, 0.925rem);
--text-base: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--text-lg: clamp(1.25rem, 1.15rem + 0.45vw, 1.5rem);
--text-xl: clamp(1.5rem, 1.3rem + 0.9vw, 2.125rem);
--text-2xl: clamp(2rem, 1.6rem + 1.8vw, 3.25rem);
}
Mixing rem into the preferred value is important. A pure vw value doesn't respond to the user's browser font-size setting, which is an accessibility problem.
Layer 2: Theming
With semantic tokens in place, a dark theme is a short block. Support both the user's system preference and an explicit override via a data attribute:
/* Respect the OS setting unless the user picked light explicitly */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: var(--slate-900);
--color-surface: oklch(0.26 0.03 265);
--color-text: var(--slate-50);
--color-text-muted: oklch(0.75 0.02 255);
--color-border: oklch(0.35 0.03 260);
--color-accent: oklch(0.72 0.15 245);
color-scheme: dark;
}
}
/* Explicit dark choice from a theme toggle */
:root[data-theme="dark"] {
--color-bg: var(--slate-900);
--color-surface: oklch(0.26 0.03 265);
--color-text: var(--slate-50);
--color-text-muted: oklch(0.75 0.02 255);
--color-border: oklch(0.35 0.03 260);
--color-accent: oklch(0.72 0.15 245);
color-scheme: dark;
}
Setting color-scheme: dark also tells the browser to render scrollbars, form controls, and the default canvas in dark colors, which avoids a bright white scrollbar on a dark page.
A Shorter Option: light-dark()
The light-dark() function picks one of two values based on the element's computed color-scheme. It's Baseline newly available, supported in current versions of all major browsers, and it lets you define both themes in one place:
:root {
color-scheme: light dark;
--color-bg: light-dark(var(--slate-50), var(--slate-900));
--color-text: light-dark(var(--slate-900), var(--slate-50));
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
If you still support older browsers, keep the media query approach, or provide a plain fallback token before the light-dark() declaration.
Derived Colors With Relative Color Syntax
Hover states, subtle backgrounds, and borders are usually variations of a base color. Relative color syntax lets you derive them from a token instead of adding more primitives:
:root {
/* Slightly darker accent for hover */
--color-accent-hover: oklch(from var(--color-accent) calc(l - 0.08) c h);
/* Translucent tint for backgrounds */
--color-accent-subtle: oklch(from var(--color-accent) l c h / 0.12);
}
Relative color syntax is supported in current Chromium, Safari, and Firefox releases, but it's newer than the rest of this stack. If you need older browsers, color-mix() has broader support and covers most of the same use cases:
:root {
--color-accent-hover: color-mix(in oklch, var(--color-accent), black 15%);
--color-accent-subtle: color-mix(
in oklch,
var(--color-accent) 12%,
transparent
);
}
One detail to know: derived tokens declared on :root are computed once, where they're declared. If a nested theme later overrides --color-accent on a descendant, the derived token on :root won't update. Either redeclare derived tokens inside each theme block, or declare them on the component so they resolve against the local accent.
Layer 3: Organizing the Cascade With @layer
The biggest pain point in large CSS codebases is specificity. A product team adds a selector that's slightly more specific than yours, then you add !important, then they add !important, and the stylesheet slowly becomes unmaintainable.
Cascade layers solve this by letting you declare which groups of styles win, independent of selector specificity. Declare the order once at the top of your entry stylesheet:
@layer reset, tokens, base, layout, components, utilities, overrides;
Layers later in the list beat earlier layers, no matter how specific the selectors inside the earlier layers are. Then assign styles to layers:
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
}
@layer base {
body {
font-family: var(--font-sans);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.6;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
@layer utilities {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
You can also import third-party CSS directly into a layer so it can never override your components:
@import url("vendor/datepicker.css") layer(reset);
Two rules to remember. First, unlayered styles beat all layered styles, so product teams that write plain CSS without a layer will win over the system. That's often exactly what you want for one-off overrides. Second, !important reverses layer order: an important declaration in an early layer beats an important declaration in a later one. Keep !important for true utilities and accessibility overrides.
Cascade layers are Baseline widely available, so you can use them in production today.
Layer 4: Building Components
Components consume semantic tokens and expose their own component tokens as a customization API. Here's a button:
<button class="btn">Save changes</button>
<button class="btn btn--secondary">Cancel</button>
<button class="btn btn--danger">Delete project</button>
@layer components {
.btn {
/* Component-level API with defaults */
--btn-bg: var(--color-accent);
--btn-fg: white;
--btn-border: transparent;
--btn-padding-y: var(--space-2);
--btn-padding-x: var(--space-4);
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--btn-padding-y) var(--btn-padding-x);
font: inherit;
font-weight: 600;
color: var(--btn-fg);
background: var(--btn-bg);
border: 1px solid var(--btn-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 150ms ease;
&:hover {
--btn-bg: oklch(from var(--color-accent) calc(l - 0.08) c h);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--secondary {
--btn-bg: transparent;
--btn-fg: var(--color-text);
--btn-border: var(--color-border);
&:hover {
--btn-bg: var(--color-surface);
}
}
.btn--danger {
--btn-bg: var(--color-danger);
}
}
A few things are worth pointing out. Variants only change custom properties, never the underlying declarations, so each variant is tiny. Native CSS nesting with & keeps states next to the base rule, and it's supported in all modern browsers. And a consumer who needs a one-off can set --btn-bg inline or in their own stylesheet without touching specificity at all.
Low-Specificity Defaults With :where()
When a component styles its children, wrap selectors in :where() to give them zero specificity. That way, any consumer's class wins without a fight:
@layer components {
.prose :where(h2) {
font-size: var(--text-xl);
margin-block: var(--space-8) var(--space-4);
}
.prose :where(p, ul, ol) {
margin-block: 0 var(--space-4);
}
.prose :where(a) {
color: var(--color-accent);
text-underline-offset: 0.2em;
}
}
Layer 5: Components That Respond to Their Container
Viewport media queries are the wrong tool for components. A card might sit in a wide main column on one page and a narrow sidebar on another, and the viewport width tells you nothing about which. Container queries let a component respond to the space it's actually given.
<div class="card-slot">
<article class="card">
<img class="card__media" src="/images/report.jpg" alt="" />
<div class="card__body">
<h3 class="card__title">Quarterly report</h3>
<p class="card__text">Revenue grew across all regions this quarter.</p>
<a class="btn" href="/reports/q3">Read report</a>
</div>
</article>
</div>
@layer components {
.card-slot {
container: card / inline-size;
}
.card {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.card__media {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius-md);
}
/* Side-by-side layout when the container is wide enough */
@container card (min-width: 520px) {
.card {
grid-template-columns: 200px 1fr;
align-items: center;
}
.card__media {
aspect-ratio: 1;
}
}
.card__title {
font-size: clamp(1.125rem, 4cqi, 1.5rem);
margin: 0 0 var(--space-2);
}
}
Note that the container is a wrapper, not the card itself. An element can't query its own size, so the query container must be an ancestor. The cqi unit, 1% of the container's inline size, lets type scale with the container rather than the viewport. Size container queries are Baseline widely available.
Layer 6: Layout Primitives
Rather than every component inventing its own spacing, provide a few layout primitives that compose. These are small, single-purpose classes:
@layer layout {
/* Vertical rhythm between siblings */
.stack {
display: flex;
flex-direction: column;
gap: var(--stack-gap, var(--space-4));
}
/* Horizontal group that wraps */
.cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--cluster-gap, var(--space-3));
}
/* Responsive grid with no media queries */
.auto-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--auto-grid-min, 16rem), 100%), 1fr)
);
gap: var(--auto-grid-gap, var(--space-6));
}
/* Centered, max-width content column */
.container {
width: min(100% - 2 * var(--space-4), 72rem);
margin-inline: auto;
}
}
The min(..., 100%) inside minmax() prevents the grid from overflowing on screens narrower than the minimum column width, a common bug in auto-fill grids. Each primitive exposes a custom property with a default, so you can write style="--stack-gap: var(--space-8)" for a looser stack without a new class.
Documenting and Distributing the System
A design system that nobody can find isn't a system. A few practical steps make adoption easier:
- Keep tokens in one file (for example,
tokens.css) and consider generating it from a JSON source with a tool like Style Dictionary if you also need tokens in native apps or design tools. - Ship a single entry stylesheet that declares the layer order and imports each layer, so consumers get the right cascade by default.
- Document the component API by listing each component's custom properties and variants. The custom properties are the public interface; the internal declarations are implementation details.
- Build a living style guide, whether that's Storybook, a simple static page, or a set of HTML fixtures, and use it for visual regression testing.
A typical entry file looks like this:
/* design-system.css */
@layer reset, tokens, base, layout, components, utilities;
@import url("reset.css") layer(reset);
@import url("tokens.css") layer(tokens);
@import url("base.css") layer(base);
@import url("layout.css") layer(layout);
@import url("components/button.css") layer(components);
@import url("components/card.css") layer(components);
@import url("utilities.css") layer(utilities);
In production, bundle these imports rather than serving them as separate requests, since chained @import rules load sequentially. Bundlers like Vite, Lightning CSS, and PostCSS with postcss-import preserve the layer assignments when they inline the files.
Common Pitfalls
- Too many tokens : If every value becomes a token, the system becomes harder to use than raw CSS. Tokenize decisions that repeat, not every number.
- Components using primitive tokens : A button that references
--blue-500directly won't change in dark mode. Components should only use semantic or component tokens. - Forgetting fallbacks for newer features : Relative color syntax and
light-dark()are newer than layers and container queries. Check your support targets and providecolor-mix()or plain-value fallbacks where needed. - Querying the component itself : Container queries need an ancestor container. Setting
container-typeon the card and then querying the card's own size won't work. - Invisible focus states : A design system is the best place to guarantee accessible focus rings. Define them once in
baseand don't let components remove them.
Conclusion
Modern CSS has quietly become a complete design system toolkit. Custom properties give you tokens and theming, cascade layers give you predictable overrides, container queries give you truly reusable components, and :where(), nesting, and relative colors keep the code small and readable.
Start small: define your primitive and semantic tokens, declare a layer order, and rebuild one component, probably your button, on top of them. Once the pattern clicks, every new component becomes a short file of token assignments and a handful of layout rules, and changing your brand color really is a one-line edit.


