How we built a design system for multiple brands on one codebase
design system is a set of building blocks and standards that help keep the look and feel of products and experiences consistent.
At Car and Classic, we’re running a web app that hosts more than one brand. Each brand has its own look and feel, but they share the same code. This post is about how we set up our design system to make that work without turning every component into a mess of if-statements.
The problem
When two brands live in the same app, you need a way to say “this brand uses blue buttons, that one uses orange” without hardcoding it everywhere. Get it wrong and you end up with logic scattered all over your components. Get it right and swapping a brand theme is just loading a different CSS file.
How the tokens are structured
We have three layers. Each one builds on the one before it.
The spacing variable
Everything starts here:
--spacing: 0.25rem;
One variable. All spacing in the app is a multiple of this. So 4 * 0.25rem = 1rem, 8 * 0.25rem = 2rem, and so on. If we ever want the whole app to feel a bit tighter or looser, we change this one value and everything adjusts.
Primitives
Primitives are the raw values. Actual colors, actual sizes. No context, no meaning attached - just the values themselves.
:root,
[data-theme='carandclassic'] {
/* Colors */
--color-gray-50: #f7f9f7;
--color-gray-100: #eff1ef;
/* ... */
--color-brand-50: #f4f7fA;
--color-brand-100: #e5f4e4;
--color-brand-200: #7cf3b8;
--color-brand-300: #27eb96;
/* ... */
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
}
You’ll never see these used directly in a component. They’re only here to be referenced by the next layer.
Semantic tokens
This is where things get useful. Semantic tokens give meaning to the primitives. Instead of --color-blue-600, you have --color-action-primary. The name tells you what it’s for, not what it looks like.
:root,
[data-theme='carandclassic'] {
/* semantic/color/background/neutral/regular/default */
--s-bg-neutral-regular: var(--color-gray-100);
/* semantic/color/background/brand/primary/regular/default */
--s-bg-brand: var(--color-brand-200);
/* semantic/color/background/brand/primary/regular/focus */
--s-bg-brand-focus: var(--color-brand-300);
}
Components only ever use semantic tokens. That’s the rule. It’s what makes switching brands possible without touching any component code.
Attention!
Attention!
The variables have their name shorten to make it easier to work with and ship a few less bytes to the client. br The comment above the variable dictates its name in Figma’s variable tables.
One theme file per brand
Each brand gets its own CSS file that overrides the semantic layer. Primitives are usually shared across brands, but the semantics are fully customised per brand.
/* themes/carandclassic.css */
:root,
[data-theme='car-and-classic'] {
/* Colors */
--color-gray-50: #f7f9f7;
--color-gray-100: #eff1ef;
/* ... */
--color-brand-50: #f4f7fA;
--color-brand-100: #e5f4e4;
--color-brand-200: #7cf3b8;
--color-brand-300: #27eb96;
/* ... */
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
}
/* themes/les-anciennes.css */
[data-theme='les-anciennes'] {
/* Colors */
--color-gray-50: #f7f9f7;
--color-gray-100: #eff1ef;
/* ... */
--color-brand-50: #f4f7fa;
--color-brand-100: #e7edf3;
--color-brand-200: #cfd9e3;
--color-brand-300: #b6c4d3;
/* ... */
/* Border radius */
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
}
Load the right file and the whole app looks like that brand. No component changes needed.
Here’s a live example of both themes. Toggle between them to see how the same components look under each brand.
--s-bg-neutral-white
--s-bg-neutral-subtle
--s-bg-neutral-regular
--s-bg-brand
--s-bg-brand-secondary
Hooking it up to Tailwind
We point Tailwind’s config at the CSS variables so the utility classes stay brand-aware.
@theme {
--spacing: var(--spacing, 0.25rem);
--color-gray-50: var(--color-gray-50);
--color-brand-50: var(--color-brand-50);
--color-brand-100: var(--color-brand-100);
--color-brand-200: var(--color-brand-200);
}
So a button looks like this:
<button class="bg-brand-50 hover:border-(--s-bg-brand-hovered) px-4 py-2">
Get started
</button>
Same template, different brand, different look. No conditionals.
SSR and Inertia - getting the theme in early
This is where we need to be careful. With Inertia SSR, the theme has to be on the page before the client picks up. If the theme loads too late, you get a flash where the page briefly shows the wrong styles.
We handle this in the middleware. The tenant is resolved on the server and the right theme file is injected into the <head> before Inertia renders anything.
// HandleInertiaRequests middleware
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'tenant' => $request->tenant()?->slug,
]);
}
// app.js (SSR entry)
createInertiaApp({
resolve: name => resolvePageComponent(...),
setup({ el, App, props }) {
// Theme is already in <head> by the time this runs
// so there's no flash on hydration
createApp({ render: () => h(App, props) }).mount(el);
},
});
The theme <link> tag goes into the layout before the first Inertia render. By the time the client hydrates, the right CSS variables are already there.
Why this works well in practice
The three-layer setup (spacing base, primitives, semantic tokens) keeps things clean as you add more brands:
- Adding a new brand: write a theme file that overrides the semantic layer
- Brand refresh: update the primitive values, nothing else needs to change
- Building a new component: use semantic tokens and it works for every brand automatically
- Auditing the design system: the semantic layer is the single place where all design decisions live
Adding a new brand is a CSS file, not a refactor.