Theming
The Forest theme
A Zoo UI theme changes color and type, and nothing else. Forest points Nuxt UI's semantic colors at its own ramps, sets Inter and JetBrains Mono, and gives light pages a paper ground under white surfaces. Radius, spacing, shadows, motion, focus rings and every component's geometry stay exactly as Nuxt UI ships them.
Everything downstream is Nuxt UI's own logic: which shade paints a hover, picking the 400 shade in dark mode, the alpha steps behind soft and subtle variants. The theme chooses colors; it doesn't re-specify components.
| Nuxt UI color | Forest ramp |
|---|---|
primary | the scheme's accent |
neutral, secondary | the --pine-* grays (Forest's secondary is muted, not a second hue) |
success, info | a pair chosen per scheme |
warning | amber |
error | clay red |
On top of the colors, Forest sets a value in the mono face where it reads as data: badges, keyboard keys, and table headers (as a small uppercase kicker). Alerts get a striped edge, and toasts a colored left edge with no progress bar.
Schemes
A scheme is a set of raw ramps: the grays, the accent, and a success and info pair. Import one after the layer's stylesheet and every component repaints. Use the picker in the header to try them on this page.
| Scheme | File | Success | Info |
|---|---|---|---|
| Pine · Teal (default) | schemes/teal.css | green | sky |
| Slate · Cobalt | schemes/slate-cobalt.css | green | cyan |
| Graphite · Violet | schemes/graphite-violet.css | green | blue |
| Paper · Olive | schemes/paper-olive.css | teal | blue |
| Ink · Indigo | schemes/ink-indigo.css | green | cyan |
| Ember · Amber | schemes/ember.css | green | blue |
Success and info are per scheme because they carry fixed meanings and must never read as the primary. Forest's accents span most of the hue wheel, so no single green and blue stay clear of all of them; each scheme's pair sits at least 30° of OKLCH hue away from its accent.
The gray ramp
Every surface, text color and border comes from one gray ramp, --pine-*, which is Nuxt UI's neutral. The default is slightly green-tinted; each scheme brings its own. These swatches read the live variables, so they follow the scheme you pick.
--pine-*
The accent ramp, --teal-* in every scheme (the name is historical), is Nuxt UI's primary:
--teal-*
Use Nuxt UI's semantic utilities (text-muted, bg-elevated, border-default) rather than the ramps directly, so your markup follows the scheme and dark mode on its own.
The page ground. Nuxt UI paints the light page with --ui-bg, the same white its cards, inputs and overlays use. Forest paints the page with --pine-50 and leaves --ui-bg white, so everything Nuxt UI draws reads as raised. Let the body show through: a wrapper painted bg-default hides the ground.
Colored edges
Zoo UI's one addition to Nuxt UI's surfaces is a 3px edge in the surface's color: on the left of inline surfaces, on the top of overlays. No color, no edge.
| Surface | How | Edge |
|---|---|---|
| Card | <ZCard color="…"> | left |
| Dialog | <ZModal color="…"> | top |
| Side drawer | <ZSlideover color="…"> | top |
| Bottom drawer | <ZDrawer color="…"> | top |
| Toast | useToast().add({ color }) (Forest) | left |
| Banner | <UAlert color="…" variant="outline"> (Forest) | left, striped |
The four wrappers belong to the base, so a product without Forest has them too, in its own colors. Each adds only color and passes every other prop, v-model, event and slot straight to the Nuxt UI component.
No color
A plain Nuxt UI card.
primary
A card with the primary edge.
success
A card with the success edge.
warning
A card with the warning edge.
error
A card with the error edge.
<script setup lang="ts">
const colors = ['primary', 'success', 'warning', 'error'] as const
</script>
<template>
<div class="w-full grid sm:grid-cols-2 gap-3">
<ZCard>
<p class="font-semibold">No color</p>
<p class="text-sm text-muted">A plain Nuxt UI card.</p>
</ZCard>
<ZCard v-for="c in colors" :key="c" :color="c">
<p class="font-semibold capitalize">{{ c }}</p>
<p class="text-sm text-muted">A card with the {{ c }} edge.</p>
</ZCard>
</div>
</template>Per-section accent
A product can paint each section in its own hue. Put data-accent="<name>" on a container, and everything inside reads six roles instead of a fixed color:
| Role | For |
|---|---|
--accent | the solid: an icon tile, a band's rule, a colored edge |
--accent-on | text or an icon on the solid |
--accent-strong | titles and labels in the accent |
--accent-soft | a band or panel tint |
--accent-muted | chips |
--accent-line | a rule or border |
The base defines the roles; your product supplies the hues. A hue is eight stops, --a-50 to --a-950, set under [data-accent='<name>'] in your CSS. A name with no hue reads as your primary. Components read the roles with arbitrary values (bg-(--accent), border-(--accent-line)), and the colored-edge wrappers take color="accent". Overlays carry the section's accent with them when they portal to <body>, so a dialog opened from a violet section has a violet edge.
Billing
This card's edge is the section's accent.
<template>
<section data-accent="violet" class="w-full rounded-md bg-(--accent-soft) p-4 flex flex-col gap-3">
<p class="text-sm font-semibold text-(--accent-strong)">Billing</p>
<ZCard color="accent">
<p class="text-sm">This card's edge is the section's accent.</p>
</ZCard>
</section>
</template>
<style>
/* The product supplies each hue's stops; here, Tailwind's violet. */
[data-accent='violet'] {
--a-50: var(--color-violet-50); --a-100: var(--color-violet-100); --a-200: var(--color-violet-200);
--a-400: var(--color-violet-400); --a-500: var(--color-violet-500); --a-600: var(--color-violet-600);
--a-800: var(--color-violet-800); --a-950: var(--color-violet-950);
}
</style>light-dark()
Each role carries its light and its dark value in one declaration, light-dark(var(--a-600), var(--a-400)), so your CSS needs no .dark rules. Your own tokens can do the same:
@theme {
--color-val-input: light-dark(var(--color-sky-900), var(--color-sky-200));
}
This needs color-scheme declared where the tokens live. Tailwind's production build compiles light-dark() into variables it defines only under a color-scheme declaration, and the color-mode module only toggles a dark class. So the base declares :root { color-scheme: light } and .dark { color-scheme: dark }. Without it, such a token resolves in development and comes out empty in production.
Typefaces and the font hooks
Forest's faces are Inter for the interface and display, and JetBrains Mono for code and values. Both are under the SIL Open Font License and self-hosted from fontsource packages the theme depends on, so nothing is fetched from a font CDN.
Each role is two hooks, a family and its OpenType feature set:
| Hook | Forest's default |
|---|---|
--zoo-font-sans | "Inter Variable", then system faces |
--zoo-font-display | "Inter Variable", then system faces |
--zoo-font-mono | "JetBrains Mono Variable", then system monos |
--zoo-features-sans | normal |
--zoo-features-display | normal |
--zoo-features-mono | "calt" 0: code ligatures off, so pasted text reads as typed |
Every place the theme sets a face reads the hooks: Tailwind's font-sans, font-mono and font-display, and the defaults for the page and for code, kbd and pre. Switching a family switches its feature set with it. The defaults sit in a cascade layer, so any unlayered declaration of your own wins, whatever the import order:
@import "@zooui/theme-forest/assets/css/forest.css";
:root {
--zoo-font-sans: "Acme Sans", system-ui, sans-serif;
--zoo-features-sans: "ss01";
}
A licensed font package
A licensed face is best shipped as its own package that sets all six hooks. For example, a package named @acme/licensed-fonts could ship a commercial face family and its feature sets. A product licensed for it imports its CSS after the theme, and the faces and their sets replace Inter and JetBrains Mono together:
@import "@zooui/theme-forest/assets/css/forest.css";
@import "@zooui/theme-forest/schemes/ink-indigo.css";
@import "@acme/licensed-fonts/fonts.css";
Zoo UI itself never depends on such a package; the hooks are the whole contract.