Theme tokens and styling
A Theme's look is data, not code. You define design tokens as CSS custom properties, components read those tokens 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.
This page is for frontend developers and designer-developers styling a Theme. You should have a Theme scaffolded — see Build your first Theme.
Why tokens
A multi-tenant Theme is deployed once and serves many customers, each on their own domain. If colors and fonts were baked into component CSS, every customer would need a fork or a rebuild. CSS custom properties solve this cleanly: define the brand on :root, have components reference var(--…), and swap the values per tenant at runtime. The approach is well supported in every modern browser and lets designers write the tokens directly.
1. Define the tokens
Put the token set in one place — a single stylesheet that is the source of truth for the Theme's design. Name tokens for their role, not their value, so they survive a rebrand.
:root {
color-scheme: light dark;
/* Color */
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
--color-surface: #ffffff;
--color-surface-muted: #f9fafb;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
/* Typography */
--font-family-base: 'Inter', system-ui, sans-serif;
--font-size-base: 1rem;
--line-height-base: 1.5;
/* Shape and spacing */
--radius-md: 0.5rem;
--spacing-unit: 0.25rem;
--container-max-width: 1280px;
}
Keep the set small. Define twenty or thirty core tokens and derive the rest with color-mix and calc rather than hand-maintaining a hundred literals.
2. Consume tokens in components
Components reference tokens, never raw values. That's the whole contract — change a token, every component that reads it updates.
<template>
<button class="btn-primary"><slot /></button>
</template>
<style scoped>
.btn-primary {
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
padding: calc(var(--spacing-unit) * 3) calc(var(--spacing-unit) * 6);
font-family: var(--font-family-base);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
</style>
This works for Blökkli blocks too. The hero block from Customize blocks can read --color-primary for its default background, so it picks up each tenant's brand automatically.
3. Override tokens per tenant at runtime
On a deployed Theme the platform resolves the tenant from the request host and injects the tenant context per request. Fetch the tenant's branding during server-side rendering and inject the token overrides as inline styles, so the very first byte already carries the right brand — no flash of the default palette.
Add a server-side API route that returns the tenant's branding tokens:
export default defineEventHandler(async (event) => {
// The platform injects the tenant per request; the SDK forwards it to the gateway.
const branding = await getTenantBranding(event)
// Map the tenant's branding onto your token names.
return {
cssVars: {
'--color-primary': branding.primaryColor,
'--color-primary-hover': branding.primaryColorHover,
'--font-family-base': branding.fontFamily,
},
logoUrl: branding.logoUrl,
}
})
Apply the returned overrides at the root of the app during SSR:
<script setup lang="ts">
// Fetched during SSR so the branding is in the first byte (no flash of default).
const { data: branding } = await useFetch('/api/branding')
const rootStyle = computed(() =>
Object.entries(branding.value?.cssVars ?? {})
.map(([key, value]) => `${key}: ${value}`)
.join('; '),
)
</script>
<template>
<div :style="rootStyle">
<NuxtPage />
</div>
</template>
Tenant tokens change rarely, so fetch them once and cache aggressively server-side rather than on every render. Because the values differ per tenant, partition any cache by tenant — a branding cache keyed only by "branding" would serve one tenant's colors to another.
4. Serve per-tenant logos and assets
Branding is more than color. Serve each tenant's logo, favicon, and social images from the branding payload rather than bundling one customer's assets into the Theme:
<script setup lang="ts">
const { data: branding } = await useFetch('/api/branding')
</script>
<template>
<img :src="branding?.logoUrl" alt="Storefront logo" width="160" height="40" />
</template>
5. Support light and dark
Set color-scheme: light dark on :root so form controls and scrollbars follow the user's preference, then provide dark overrides. You can follow the system preference or let the user toggle.
Follow the system preference automatically:
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #111827;
--color-surface-muted: #1f2937;
--color-text: #f3f4f6;
--color-text-muted: #9ca3af;
--color-border: #374151;
}
}
Or drive it from a data-theme attribute for an explicit toggle:
[data-theme='dark'] {
--color-surface: #111827;
--color-surface-muted: #1f2937;
--color-text: #f3f4f6;
--color-text-muted: #9ca3af;
--color-border: #374151;
}
<script setup lang="ts">
const theme = useState<'light' | 'dark'>('theme', () => 'light')
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme.value)
localStorage.setItem('theme', theme.value)
}
onMounted(() => {
const saved = localStorage.getItem('theme') as 'light' | 'dark' | null
if (saved) {
theme.value = saved
document.documentElement.setAttribute('data-theme', saved)
}
})
</script>
<template>
<button type="button" :aria-pressed="theme === 'dark'" @click="toggle">
Switch to {{ theme === 'dark' ? 'light' : 'dark' }} mode
</button>
</template>
Test both modes before you ship. A contrast regression is easy to miss in one mode and obvious in the other.
Habits that keep styling maintainable
- Name tokens for their role.
--color-primarysurvives a rebrand;--color-blue-500becomes a lie the day the brand changes. - One source of truth. Centralize tokens in one stylesheet rather than scattering overrides through component
<style scoped>blocks. - Inject tenant tokens in SSR. Apply the override in the first byte so the brand never flashes from default to custom.
- Cache per tenant. Branding changes rarely — cache it, but key the cache by tenant so brands never cross.
Troubleshooting
- Brand flashes from default to custom on load. You're applying the override client-side. Fetch the branding during SSR and inline it on the root element.
- One tenant sees another's colors. A branding cache keyed without the tenant. Partition the cache by tenant.
- Dark mode misses some surfaces. A component uses a raw color instead of a token. Replace it with the matching
var(--…).
What's next
- Deploy your storefront — ship the styled Theme and activate it per domain.
- Reference: Theming.