Design System & Branding
How @nuxfire/tokens centralizes theming across all three frontend apps, and how to rebrand the starter kit for your own product.
Every design token — brand colors, corner radius, dark-mode surface mapping, and Nuxt UI component
overrides — lives in one workspace package: @nuxfire/tokens, consumed by apps/app,
apps/web, and apps/login alike. Before this package existed, each app duplicated its
own app.config.ts and main.css — a real risk of one app quietly drifting from the others. Rebranding
now means editing one file, not three.
The package
packages/tokens/
├── theme.css # Tailwind @theme tokens, dark-mode mapping, corner radius
└── index.ts # sharedUiConfig (Nuxt UI app.config.ts fragment) + enterpriseTableUi
A consuming app wires it up in three places:
{ "dependencies": { "@nuxfire/tokens": "workspace:*" } }
@import "tailwindcss";
@import "@nuxt/ui";
@import "@nuxfire/tokens/theme.css"; /* after @nuxt/ui, so it wins over its defaults */
import { sharedUiConfig } from "@nuxfire/tokens";
export default defineAppConfig({
ui: { ...sharedUiConfig }, // app-specific keys can follow the spread
});
The @source gotcha
The package reaches each app through a node_modules symlink (a Bun workspace), and Tailwind does not
scan node_modules for class names by default. Without the line below, any class that exists only
inside index.ts (for example, a compoundVariants override like before:bg-primary/15) renders in the
DOM with no CSS rule behind it — silently broken, no error. theme.css already carries the fix:
@source "./index.ts";
If index.ts ever grows a genuinely new utility class — not one already used elsewhere in an app's own
scanned source — this directive is what keeps it from going dead again.
Brand accent — shared across all three apps
flame (red, primary) and fire (orange, secondary) are defined once in theme.css, byte-identical
in every app. ui.colors maps primary: 'flame', secondary: 'fire' in sharedUiConfig.
Neutral scale — intentionally different per app
Unlike the brand accent, the neutral surface scale (night-*) is not shared — each app defines its
own in its own main.css, same token names, different hues:
apps/appuses a cold true-gray scale, matching an enterprise reference panel (Linear-style) byte-for-byte — this is the working dashboard and the platform-admin console, where a neutral base and the brand accent reserved for actions/highlights is the standard SaaS-utility convention (GitHub, Linear, Vercel all do the same).apps/webandapps/loginuse a warm, ember-tinted gray — the public brand site's own identity.
Rebranding one app's base tone never touches the other two — edit only that app's --color-night-* block.
Corner radius — one token drives everything
:root {
--ui-radius: 0.1875rem; /* 3px */
}
Nuxt UI derives its entire rounded-* scale proportionally from this single variable
(--radius-lg: calc(var(--ui-radius) * 2), and so on), so every card, button, input, modal, and badge
tightens together from one edit — no per-component override needed.
Shadow policy
Static surfaces (cards, sections, panels) use zero shadow — separation comes from a 1px
border/ring, never box-shadow. Floating overlays (modals, dropdowns, popovers, tooltips) keep their
shadow: they float over content with no backdrop scrim, so the shadow is the only depth cue they have —
that's Nuxt UI's own default behavior, left untouched.
Component overrides (sharedUiConfig)
| Component | Override | Why |
|---|---|---|
UCard | bg-elevated instead of bg-default | The stock variant leaves a card the same color as the page (1:1 contrast, measured) — bg-elevated is a raised tone. |
UAvatar (neutral, no image) | bg-accented instead of bg-elevated | The stock fallback shares the card's own background, so a photoless avatar inside a card had no visible background at all. |
UNavigationMenu (vertical) | 24px icons (was 20px), collapsed rows as a centered size-11 square, active item gets a colored ring in addition to the tint | A defined square tap target and a bordered active state, not just a floating glyph and a flat color wash. |
UTabs | default link variant instead of pill | A solid brand-color block reads heavier than the underline treatment used across settings screens. |
UTable (enterpriseTableUi) | small, muted, uppercase, letter-spaced header | Matches the column-label convention Linear/Stripe/Notion tables use, instead of a heading-sized header. |
enterpriseTableUi ships separately from sharedUiConfig, passed explicitly via each <UTable>'s :ui
prop — an instance prop always wins, which a global slot override for this particular component had not
been verified to do.
Accessibility — measured, not estimated
The dark/light surface mapping in theme.css comes from real contrast measurements against the running
app (WCAG relative-luminance formula), not a guess. The stock Nuxt UI mapping measured a card at 1.00:1
against its own page background (invisible) and inactive icons at 2.61:1 (below the 4.5:1 WCAG AA
floor). The current dark-mode text ladder against the #0d0d0d canvas: 18.7:1 / 15.6:1 / 12.1:1 / 5.48:1
— every tier clears AA, with dimmed deliberately the tightest since it also drives placeholders.
Rebranding checklist
- Change the accent color everywhere: edit the
flame-*/fire-*scales inpackages/tokens/theme.css. - Change the corner radius everywhere: edit
--ui-radiusin the same file. - Change a component's default look everywhere: edit
sharedUiConfig/enterpriseTableUiinpackages/tokens/index.ts. - Change one app's neutral tone only: edit that app's own
--color-night-*scale in its ownmain.css— not inpackages/tokens. - Override something for one app only: spread
sharedUiConfigin that app'sapp.config.ts, then add keys after the spread — the later definition wins.
Every token is a CSS custom property, never a class hardcoded into a component, which is also the right
foundation for a future per-tenant theme editor: overriding a handful of --ui-*/--color-* variables at
runtime reaches every component for free.
Ready to build and launch your SaaS?
Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.