A glossy three-tier cake with a slice cut away to reveal three cleanly separated layers, rim-lit in red and cyan neon on a dark reflective floor beside the title "A Design System in Three Layers."

A Design System in Three Layers

Every team, given enough time, reinvents the button. Then the card. Then inevitably the PM or QA asks: “why does this dropdown look different on three pages?” For years I assumed that was a discipline problem—if everyone just paid closer attention, the UI would stay consistent. It isn’t a discipline problem. It’s architectural.

TL;DR: a design system is an API for your UI. The systems that survive contact with a real product are layered—generic primitives at the bottom, a brand layer in the middle, the app on top—with dependencies on other layers only pointing in one direction. Design tokens (named CSS variables like --color-brand-accent) are the contract between those layers, and if you split them correctly into “themeable” and “fixed,” you get components you can re-skin for a completely different brand without touching a line of component code. The rest of this post is the why and the how, with examples from rebuilding my own site’s components into a real system.

Why a design system at all

Without a system, your UI drifts. You end up with twelve shades of “primary” because every new screen eyeballed the last one, like a game of telephone played in hex codes. You duplicate effort—three dropdowns, each written by whoever needed a dropdown because the one that existed was slightly different than what they needed. So the original code was copy/pasted and modified.

You re-litigate accessibility per component: this dialog traps focus, that one doesn’t, because different developers have different skills and passions, which is fine at first, until nobody remembers which one uses aria and which one doesn’t. And every new screen pays a slow tax of starting from scratch.

My blood pressure is already up just thinking about it.

What a design system buys you is the inverse of all that:

  • Consistency. One source of truth for what a button is, so the answer to “which blue?” is a lookup, not a debate.
  • Velocity. New screens are composed from existing parts instead of recreated. Assembly, not fabrication.
  • Accessibility by default. You get focus rings, keyboard navigation, and ARIA attributes (Accessible Rich Internet Applications—the markup that tells a screen reader what a widget is and what it’s doing) right once, in the primitive, and every consumer inherits it. In my system, every Storybook story runs axe accessibility checks at the “error” level, so a regression fails CI before it ever ships. I don’t have to remember to care. The robot cares for me.
  • A shared vocabulary. When design and engineering both say “Card,” they mean the same artifact, not two things that rhyme.

There’s a great post from Nathan Curtis on this that made it click for me: “A design system isn’t a project. It’s a product, serving products.” In it, he teaches that it’s not a folder of components you tidy up once. It has consumers, so treat it like anything else with consumers—version it, document it, test it. Mine ships as versioned packages from a private npm registry, with changelogs, because bugs happen and it can be easier to know what changed by looking through the changelogs than pawing through (potentially) unhelpful git commit messages. It takes discipline to write good changelogs, but you’ll be glad you did when someone says, “Hey XYZ is broken, wasn’t that fixed last week? What changed?”

Try flipping your thinking: instead of picturing whole screens, picture a small set of building blocks that snap together to make any screen you need. Brad Frost calls this Atomic Design, where you start with atoms (the tiniest pieces), combine them into molecules, and then into organisms. It’s a handy way to organize your thoughts, as long as you don’t get hung up on whether a button is a molecule or an organism. I’ve wasted more hours than I’d like to admit arguing about what to call a component, or trying to convince the team to build something outside the design system just to experiment. Usually, I get shot down with, ‘that’s why we have a design system.’ Honestly, being rigid about these categories doesn’t help the product. It just slows everyone down.

Picture this: the client asks for something new and a little different on their site. But the team is so strict about sticking to the design system that you end up in endless meetings, arguing over details that don’t really matter. Meanwhile, you could have just built a custom component, shown it to the client, and if they liked it, added it to the design system later. If not, you toss it out. The design system is supposed to help you move faster, not slow you down.

The categories serve the composition; the composition is the point.

The three layers (cake, not onion)

Shrek famously argued that ogres are like onions: they have layers. Donkey’s counterpoint holds up better under engineering review—you know what else has layers? Parfaits. Everybody likes parfaits. Cakes too. The difference between onion layers and cake layers is whether they’re pleasant and intentional or whether peeling them apart makes you cry. A well-layered design system is a cake. Most codebases I’ve inherited were onions.

Here are the three layers, top of the cake last:

Layer 1—generic primitives and tokens. Brand-unaware, reusable-anywhere building blocks: Button, Card, Dialog, Select. This layer knows nothing about my site. It doesn’t know my accent color, my logo, or that I’m fond of angled banners. In my case this is three packages under a shared scope: @kdd/tokens (the CSS variables), @kdd/primitives (headless interaction logic—dialog, select, combobox, date picker—built on Zag.js state machines, which handle the keyboard/focus/ARIA behavior with no styling opinions at all), and @kdd/design-system (Svelte 5 components that put the two together).

Here is the honest story about trying to be ‘framework-agnostic.’ I started out building this layer as web components, hoping it would be like a universal plug that fits any outlet: Astro, Svelte, or whatever dashboard comes next. That version is now retired. The Lit source code is still hanging around in a folder called ‘_legacy,’ acting as a kind of fossil record for how I used to do things. The problem was that wrapping everything in web components turned out to be more work than it was worth, especially when only one framework was actually using it. On top of that, web components cannot be server-side rendered, which was the main reason I picked SvelteKit in the first place. That was the deal-breaker. So I shifted as much of the flexibility as possible into CSS instead.

The genuinely portable parts turned out to be the tokens (pure CSS, no framework anywhere near them) and the headless Zag logic. The components themselves are Svelte, and I’ve made peace with that. Portability is a attribute of your layers, not a requirement on the whole system.

Layer 2—the brand layer. All the things that make this product look like itself—the flashy headings, the angled color banner, the way each section gets its own color, the logo—those are the visible parts. On my site, that’s handled by the Astro layouts and the theme CSS. Think of Layer 1 as the box of crayons: it gives you the colors, but doesn’t tell you what to draw. Layer 2 is the artist who actually picks the colors and paints the picture. Layer 2 takes what Layer 1 offers and decides how everything should look. That’s where all the strong opinions live.

Layer 3—the app. Think of Components as the final assembler on a car factory line. It takes all the finished parts from Layer 2, snaps them together, and rolls out the complete vehicle. It doesn’t design the parts or decide how they’re shaped. So if you ever catch yourself adding a border-radius directly in a component file, that’s a sign something went wrong earlier in the process.

This is the spot where design system rules can start to feel like gospel. But I don’t mean you have to stick only to what’s already in the design system when you’re building a component. Actually, this is exactly where you should try out new ideas. If your new piece gets used often enough, it can graduate up to the official design system. If not, it just hangs out in the code as a one-off, custom part of your app.

Dependencies always flow downward, not up. Layer 1 never borrows anything from Layer 2. No colors, no fonts, not even a one-off logo. That rule is what lets you pick up Layer 1 and drop it into a new project without dragging along any old branding baggage. If you ever need to rebrand, Layer 2 takes the hit. Layer 1 just keeps humming along, blissfully unaware.

Let me show you how this plays out on my own site. The navigation bar swaps its accent color depending on the page. Case studies use the main brand color, blog posts use the backup. The obvious move is to build three separate navbars. But the smarter way is to have a single, flexible nav, and let the layout hand off the right accent to the branding layer.

<MainLayout accent="secondary">

which flips a class on the page. The cleanest illustration of what that class does is the page headings—one rule, one token reassignment (simplified):

.accent-secondary h1 {
--heading-text-color: var(--color-secondary-accent);
}

Every heading that reads --heading-text-color re-colors itself, and the header follows suit through a handful of sibling rules. The “feature” was a class flip and a token swap—a few lines per accent. That’s what a good layer boundary buys you: features collapsing into configuration.

Tokens are the contract

Tokens are the seam between the layers. Think of them as the public API for your look and feel. Components read tokens. Themes write them. Each side stays on its own side of the fence.

Whenever I need to explain this, I reach for the wall outlet analogy. Think about your appliances: you do not solder your toaster directly into the house wiring. You just plug it into a standard socket. If an electrician comes in and rewires the whole house, swaps out the panel, or runs new circuits, your lamp does not care. The only thing your lamp expects is the outlet. That is the contract. The panel is layer 1, the wiring is layer 2, and the outlet—plus whatever you plug into it—is layer 3. If a component reads var(--color-brand-accent), it is like a lamp with a plug. If it has #6a8dff hardcoded, that is a lamp soldered straight into the wall. At some point, an electrician is going to have a few choice words for you.

The real dividing line is between what you can safely repaint and what you have to leave bolted to the floor. In other words, which parts of the design system are themeable, and which are fixed in place.

  • Themeable means the stuff a brand can swap out to make things feel like theirs: color, border radius (how round the corners are), border thickness, shadow depth, and font family. Basically, the paint, the trim, and the typeface.
  • Fixed means the bones of the layout—the things you don’t mess with if you want the house to stay standing. That’s spacing, padding, the type-size scale, line heights, motion, and z-index (what sits on top of what). Changing the theme should never make the layout feel more crowded or more spread out.

Figuring out what’s fixed and what’s themable is like deciding which walls in your house are load-bearing and which ones you can remove. It’s a practical conversation about boundaries and rules, not just a debate over favorite colors or the grand theory behind your design system.

:root {
/* fixed—structure, identical under every theme */
--space-4: 1rem;
--text-lg: 1.125rem;
/* themeable—identity, assigned by the brand layer */
--color-brand-accent: oklch(0.7 0.15 250);
--radius-field: 0.5rem;
}

Why bother with a fixed tier? Because when I say “theme,” I mean paint. And paint can only do so much. You can repaint any room in your house any color you want. Go wild, make it neon green if you like. But if your improvements start moving a load-bearing wall, you are not just painting anymore. You are renovating, and that needs a little more care and consideration than the color on the walls.

DaisyUI version 5, which I actually like, turned sizing into per-theme variables. For example, --------------------------------------------------------------------------- sets the base size of buttons and inputs, and

sets the size for checkboxes and toggles. This means that when you swap themes, your buttons can suddenly get taller or shorter. It is tempting, and I get why a component library would do this. That approach makes sense if you are serving thousands of unrelated projects. But inside a single product’s design system, it is like moving a wall when you just meant to repaint. If you change the height of controls when you swap themes, you are not just changing the look. You are changing the layout, and it sneaks in under the guise of a simple paint job. In my system, sizing always stays in the fixed tier. That is on purpose, and it will not change. Themes are for changing how things look, not how big they are.

Having two tiers is better than dumping everything into one big bag of variables. Like how recently I put together some IKEA furniture and all the hardware was in one bag. This made it harder to find all the screws of a certain size; you had to paw through a bunch of unrelated hardware until you found it. By contrast, the tiered approach organizes like things. If a variable is in the themeable tier, you know it is safe to tweak. If it is in the fixed tier, you know changing it is a big deal, like surgery. If you put everything in one flat namespace, you lose that signal. I have written a longer post about this, including the mistakes I made along the way, so I will stop here for now.

Let me say something about the values themselves. I switched every color in the system to oklch, which is a perceptually uniform color space. That means if you change the numbers by the same amount, you get the same amount of visual change. Hex and RGB do not work that way. The real benefit is that making new colors is no longer a gamble. Hovers, tints, and translucent fills now come from color-mix() instead of picking random hex codes by hand:

.card {
border: 1px solid color-mix(in oklab, var(--color-brand-accent) 30%, transparent);
}

That line ships on this site today. Whatever the accent token holds, the border is a 30% wash of it—the derivation travels with the theme for free. (The in oklab matters: mix in sRGB and midpoints go muddy and gray.)

Re-skinning: one component, many faces

Here’s the payoff for all that ceremony. The same Button component renders pixel-identical to my live site under one theme, and re-skins to a totally different brand under another—no code change, just a different file of token values.

Here’s the move that makes ‘looks exactly like my site’ and ‘is generic’ play nicely together: the site itself turns into a theme. Picture it like this: in my tokens package, there’s a file called website.css. That’s the recipe for how my site looks right now. Right next to it, there’s a second file—call it other-app.css—which is the same idea but for a different project with its own style. My site isn’t the boss of the design system. It’s just the first person in line to use it.

Storybook is where the contract proves itself. Using globals and a toolbar decorator, I added a paintbrush icon to the toolbar with two entries—“kevindench.design” and “Project Assistant”—plus a light/dark switcher next to it. Flip the brand and every component on screen re-themes live, because the decorator swaps one data-theme attribute and the tokens do the rest. It’s the system demonstrating its own API in real time, and it doubles as a test: my rule of thumb is that if you can apply an absurd theme—comic sans, hot pink, tragic shadows—and nothing breaks (it merely offends), the layers are actually decoupled. If something breaks, a component was reading past the contract, and you just found it cheaply.

Why this is just good software design

Strip away the CSS and none of this is new. It’s the standard principles of software design wearing a designer’s hat.

  • Separation of concerns. Structure (Layer 1), identity (Layer 2), composition (Layer 3). Each layer has exactly one job, which means each kind of change has exactly one home.
  • Dependency direction. The stable, generic thing must not depend on the volatile, specific thing—Robert Martin’s stable-dependencies principle, a sibling of SOLID’s dependency inversion. Brand churns every couple of years; a button’s focus handling shouldn’t churn with it.
  • Open/closed, applied to UI. Components are open for extension via theming and closed for modification. You don’t fork the button to recolor it. The moment someone forks a primitive “just to tweak it,” you have two buttons, and entropy has a foothold.
  • Dumb components, smart composition. Primitives render from props and raise events; the brand and app layers hold the opinions. A component that doesn’t know what brand it’s wearing is trivial to test and trivial to carry into the next product.

Here is the honest tradeoff, because there is always a catch. Layering costs you time up front. Building a generic navigation bar and adding a spot for theming took me a lot longer than just hardcoding a navbar. Packaging up tokens as their own thing took even longer. The payoff is like compound interest: it only works if you keep the account open. If you close it early, you just paid a bunch of fees for nothing.

If you are building a product or a platform that will stick around longer than its first coat of paint, those extra layers pay for themselves over and over. But if you are cranking out a one-off landing page that goes live on Friday and disappears by fall, just hardcode the blue and move on. This is merely a technique of how to author your CSS. Not all techniques are applicable in all situations, it often takes experience to know which technique is the right tool for the job.

Closing

Think of a design system like the wiring in a house. The API is the panel, and layering keeps the lights on when you upgrade it. You want generic building blocks that can move from room to room, a brand layer you can repaint without worrying about the wiring, and tokens—those are the little agreements about color, spacing, and type—that act as the handshake between the two. The trick is to make sure all the dependencies flow in one direction, so you never have to tear down a wall just to change a lightbulb.

Build the parts once, and you can keep changing the look as often as you like. The hard work is up front; the fun part is swapping out the paint.

If you’re starting from scratch, skip the temptation to build forty components and write a rulebook. Start with tokens—those are the basic values like colors and spacing, split into two piles: the ones you want to be able to change (themeable), and the ones that should never budge (fixed). Then pick one primitive you actually need, like the button that every project seems to invent from scratch. The whole system grows out from that first solid handshake.

Whatever you do, don’t make sizing something you can theme. Changing the size of everything isn’t a theme—it’s like moving the walls in your house every time you want a new color. That’s just chaos.