Theme
Read when: reaching for a colour, radius or font, setting up the stylesheet, switching the palette, or anything to do with dark mode. Owns: the token rules, the two palettes, radius, fonts for Arabic and Latin, dark mode, the unlayered overrides, the chart colour ramp, adding a token. The copy-ready files are ../assets/app.css and ../assets/fonts.css.
TL;DR
- One stylesheet (the one
components.jsonnames) defines every token. Nothing else defines a colour. - Never write a colour. Semantic tokens only; a shade in between is an alpha of a token.
- The palette is neutral with one hue, and that hue lives in the chart ramp (or in
--primary, for the accent palette). Status chips get their own tone tokens. --font-sanslives in a plain@theme, never@theme inline, so[lang='ar']can override it.- Fonts are self-hosted in
fonts.css, not a Google Fonts@importin the main stylesheet. - Overrides that must beat Tailwind or a library go unlayered at the foot of the file.
The one rule: tokens, not colours
No bg-blue-500, no #1f2937, no bg-[#0b0f19]. Each token is redefined under .dark; a literal
is right in one theme and wrong in the other.
| You mean | Write |
|---|---|
| page background, body text | bg-background, text-foreground |
| a panel on the page | bg-card / text-card-foreground |
| a quiet caption, a quiet fill | text-muted-foreground, bg-muted |
| a hover or active surface | bg-accent / text-accent-foreground |
| the main action | bg-primary text-primary-foreground |
| a second action | bg-secondary text-secondary-foreground |
| danger | text-destructive, bg-destructive/10 |
| any hairline, a field's edge | border-border (or border), border-input |
| a focus ring | ring-ring/50 |
A shade in between is an alpha (bg-primary/80, ring-destructive/20), never a new colour. If no
token seems to fit, you are almost always reaching for the wrong one.
The stylesheet, part by part
../assets/app.css is ordered so each part does one job:
- Imports:
tailwindcss,./fonts.css,tw-animate-css(the registry's enter/exit animations),vue-sonner/style.css(the toaster's own CSS, needed since vue-sonner 2), andshadcn-vue/tailwind.css(state variants such asdata-open:that newer styles use). @custom-variant dark: dark mode follows a.darkclass, not the system setting directly.@theme: the font stack, kept out ofinline(see Fonts).@theme inline: maps each--xvariable to a--color-xutility (bg-x,text-x) and derives the radius scale.inlinemakes the utility read the variable at use time, which is what lets.darkswap it.:rootand.dark: the values.color-schemesits there too, so scrollbars, native selects, date inputs and autofill follow the theme; without it a dark page keeps a white scrollbar.@layer base: border colour, outline colour, body colours and font.- Unlayered overrides at the foot (see below).
init writes its own version of this file; replace it with the asset after init, keeping any
@source lines your framework needs (Laravel scans its Blade root view).
Palettes
Both start from shadcn's neutral base. Pick one per app.
Neutral (the default, in the asset). --primary is near-black in light and near-white in dark,
so the main action, the active nav row and the focus ring carry weight without a hue. The one hue
is the chart ramp, five steps from light to deep (--chart-1 … --chart-5).
Accent. Same neutrals, but --primary and --ring carry one brand hue. A blue example:
| Token | Light | Dark |
|---|---|---|
--primary, --ring, --sidebar-primary |
hsl(219 100% 50%) |
hsl(216 100% 68%) |
--primary-foreground |
hsl(0 0% 100%) |
hsl(0 0% 3.9%) |
- The accent is deeper in light than in dark, and
--primary-foregroundinverts with it. Why: one value cannot carry white text on a white page and dark text on a black one. - With an accent palette, use distinct hues for the chart ramp (the stock shadcn chart values) so a series does not look like a button.
hsl()andoklch()both work; keep one format per file so values compare at a glance.
Status tones
A status is the one thing a reader scans rather than reads, so it may carry a hue. Define a small
set of tone tokens (--tone-green, --tone-blue, …) in :root, lift them in .dark, expose them
in @theme inline, and let one component map a status to a tone (see the wrapper example in
vendored-folder.md). Why: when the meaning of a status changes, one file changes.
To add any token (a --warning, a tone): define it and its -foreground in :root and
.dark, then add --color-warning: var(--warning); to @theme inline. Then bg-warning works.
Radius
One knob: --radius (0.625rem in the asset). --radius-sm/md/lg/xl derive from it. Use
rounded-md, rounded-lg, never a pixel value, or that corner stops following the knob. Newer
init output derives the scale by multiplying (sm 0.6×, xl 1.4×) instead of subtracting; either
is fine, just keep the one you started with (checked 2026-11-03).
The sidebar shell
<Sidebar collapsible="icon" variant="inset"> makes the page a rounded panel floating on the
shell, entirely from the vendored sidebar; never rebuild it by hand. It reads as floating only while
--sidebar is a step darker than --card in light (the asset uses oklch(0.968 0 0)); the dark
values already invert.
Fonts: Arabic and Latin
One design in two scripts, so Latin inside an Arabic line sits on the same skeleton: Noto Sans for Latin and Noto Sans Arabic for Arabic. Any pair drawn as one family works the same way.
@theme {
--font-sans: 'Noto Sans', 'Noto Sans Arabic', ui-sans-serif, system-ui, sans-serif;
}
:root[lang='ar'] {
--font-sans: 'Noto Sans Arabic', 'Noto Sans', ui-sans-serif, system-ui, sans-serif;
}
--font-sanslives in a plain@theme.inlinebakes the literal into thefont-sansutility, so the Latin stack is frozen onto every element and the[lang='ar']override never reaches it.- Self-host the faces in
fonts.csswithunicode-range. Why:addrewrites Google Fonts imports in the main stylesheet (vendored-folder.md), and a build that fetches fonts fails when that host is unreachable. - Under
[lang='ar'], no tracking and no uppercase. Letter-spacing breaks joined letters and Arabic has no case. The asset resets both unlayered, sotracking-*anduppercaselose.
Dark mode
.dark on <html>, set by an appearance composable with three choices (light · dark · system) and
remembered in a cookie or localStorage. The Laravel Vue starter kit ships one (useAppearance).
Set the class in an inline script in the root HTML before the app mounts, or the page flashes light.
You write no dark: colour utilities: the token swap does it. dark: is for the rare case where a
whole treatment changes, and the registry already carries those. If something looks wrong in dark,
fix the token's .dark value, not the call site.
Unlayered overrides, and why
Tailwind's utilities live in a cascade layer. An unlayered rule beats every layered one, whatever the specificity, so the few rules that must win go at the foot of the stylesheet:
- vue-sonner's font. Its stylesheet is unlayered and sets its own font stack, so a
font-sansclass on<Toaster>loses. The override puts toasts back on--font-sans. - Arabic tracking and case (Fonts above).
- Registry direction gaps: sheet edge, alert-dialog header alignment, switch thumb. The why for each is in rtl.md.
Add to this block only what cannot be said with a token or a class.
Chart colours
--chart-1 … --chart-5 are defined in :root and again in .dark, so a series reads on both.
- One series takes
var(--chart-1); several take consecutive steps. - With the neutral palette, never colour a series with
--primary: a bar the same black as the main button reads as part of the interface, not as data. - A single-hue ramp suits one measure or ordered groups; unrelated categories need distinct hues.
After any add
The CLI can rewrite this file. What it changes and how to check is in vendored-folder.md.