Design tokens

Brand a theme with CSS custom properties — the Tailwind 4 token layer, the shipped palette and theme presets, and what belongs in app.config.ts.

A theme's look is data, not code. Tokens are CSS custom properties, components read var(--…) instead of hard-coded values, and each tenant's brand is applied at runtime. The same build serves every customer; a rebrand is a token change, not a redeploy.

That's the mechanism a multi-tenant storefront needs. If colours and fonts were baked into component CSS, every customer would need a fork or a rebuild.

Where tokens live

The base layer builds on Tailwind 4, which configures itself from CSS rather than a JavaScript config file. A theme's stylesheet imports Tailwind and the component library, then declares its own tokens:

app/assets/css/main.css
@import "tailwindcss";
@import "@nuxt/ui";

/* Your own design tokens. @theme is Tailwind 4's token layer:
   what you declare here becomes both a CSS variable and a utility. */
@theme {
  --color-brand-500: oklch(58.5% 0.233 277.117);
  --font-display: "Inter", system-ui, sans-serif;
  --radius-card: 0.5rem;
}

Three layers of variable end up on the page, and it helps to keep them apart:

LayerLooks likeWho owns it
Tailwind theme tokens--color-*, --font-*, --radius-* inside @themeYou. Declaring one also generates the matching utility class.
Component library variables--ui-primary, --ui-container, --ui-header-heightThe component library, overridable from your CSS.
Your semantic tokenswhatever you name themYou, for anything the two above don't cover.

Overriding a component-library variable is a plain CSS declaration:

app/assets/css/main.css
:root {
  --ui-container: 1536px;
  --ui-header-height: 5.75rem;
}

Name tokens for their role

A --color-primary survives a rebrand from blue to green; a --color-blue-500 becomes a lie the moment the brand colour changes. Keep the set small while you're at it — define twenty or thirty core tokens and derive the rest with color-mix() and calc() rather than hand-maintaining a hundred literals.

CSS
.btn-primary {
  background: var(--color-primary);
  border-radius: var(--radius-card);
  font-family: var(--font-display);
}
.btn-primary:hover {
  /* derived, not a second hand-maintained token */
  background: color-mix(in oklch, var(--color-primary) 85%, black);
}

Centralise the tokens in one stylesheet rather than scattering overrides through component <style scoped> blocks, so there's a single source of truth.

The shipped palette and theme presets

@revenexx/cover ships a colour palette and a set of named theme presets, both importable through the package's deep-import map.

TypeScript
import { palette, COLOR_SHADES } from '@revenexx/cover/config/palette'
import { themes, DEFAULT_THEME_ID } from '@revenexx/cover/config/themes'

The palette is a map of colour name to the eleven Tailwind shades, in oklch. A preset picks a primary and a neutral out of that palette and adds a corner radius and an icon weight:

the shape of a preset
{
  id: 'midnight',
  name: 'Midnight',
  colors: { primary: 'indigo', neutral: 'zinc' },
  tokens: { radius: '0.375rem' },
  icons: { weight: 'linear' },
}

Presets are how a customer picks a look without a developer: the theme store applies the active preset's colours, radius, and icon weight at runtime. Use them as your starting point and add tokens for the parts a preset doesn't reach.

Blökkli pages can also carry per-page appearance — a colour scheme, a corner style, a spacing density, a font family — chosen by an editor and applied when the page renders. Your blocks get that for free by reading tokens rather than literals.

What belongs in app.config.ts

app.config.ts is the theme's runtime configuration object, merged across the layer chain. Two kinds of appearance setting live there rather than in CSS:

app/app.config.ts
export default defineAppConfig({
  // Icon mapping — which icon set the component library's built-in icons resolve to.
  ui: {
    icons: { /* … */ },
  },
  // Behavioural UI defaults the layer's components read.
  search: {
    autocompleteMinChars: 2,
  },
})

The rule of thumb: colours, spacing, and type go in CSS; choices and mappings go in app.config.ts. The service keys live in the same file, which is worth knowing so you don't create a second one.

Branding per tenant

One deployed theme serves many tenants, each on its own domain, each with its own brand. The pattern is: resolve the tenant per request, fetch its branding during server-side rendering, and inject the token overrides at the root — so the very first byte already carries the right brand and there's no flash of the default palette.

The step-by-step version, with the server route and the root component, is in Theme tokens and styling. Two rules from it are worth repeating here because they're easy to get wrong:

  • Fetch branding during SSR, not on mount. Applying tokens on the client produces a visible flash of the default palette.
  • Partition any branding cache by tenant. Tenant tokens change rarely, so caching them is right — but a cache keyed only by "branding" will serve one tenant's colours to another. See Multi-tenant themes.

Light and dark

Set color-scheme: light dark on :root so form controls and scrollbars follow the user's preference, then provide the dark overrides — either following the system preference or driven by an attribute for an explicit toggle:

app/assets/css/main.css
:root {
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-surface: oklch(21% 0.006 285.885);
    --color-text: oklch(96.7% 0.001 286.375);
  }
}

[data-theme='dark'] {
  --color-surface: oklch(21% 0.006 285.885);
  --color-text: oklch(96.7% 0.001 286.375);
}

Test both modes before you ship. A contrast regression is easy to miss in one mode and obvious in the other.

Serving per-tenant logos and assets

Branding is more than colour. Serve each tenant's logo, favicon, and social images from the branding payload rather than bundling one customer's assets into the theme — the assets themselves belong in Storage, where they can be resized and reformatted at request time.

app/components/SiteLogo.vue
<script setup lang="ts">
const { data: branding } = await useFetch('/api/branding')
</script>

<template>
  <NuxtImg :src="branding?.logoUrl" alt="Storefront logo" width="160" height="40" />
</template>

Next steps

Was this page helpful?