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.

Figma’s team

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.

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.

Theme
BrandSecondarySuccessWarningDangerNeutral
All listingsCarsMotorcyclesClassicsArchived
White
--s-bg-neutral-white
Subtle
--s-bg-neutral-subtle
Regular
--s-bg-neutral-regular
Brand
--s-bg-brand
Secondary
--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.

Navigation