Theming
How the theme is put together — owned components, one token layer, and a dark mode that switches on a class.
The theme is not a dependency you configure. It is a set of files the layer owns, built on shadcn-vue and Tailwind 4, and the reason it is shaped that way decides what overriding one costs you.
Changing something is Override the theme. This page is why the seams sit where they do.
Components are copied in, not imported
shadcn-vue is not a component library you install. Its CLI writes the source of a
component into your project, and from then on the file is yours. The layer holds
its primitives in app/components/ui/, and components.json points the CLI at
that alias, so adding one is a single command.
Two consequences follow, and they run in opposite directions:
- You can change anything, without a fork. A component you want to look different is a file you edit — there is no upstream to fight, no theme API to find a gap in.
- Nothing updates itself. An upstream fix to a primitive reaches you when you re-run the CLI for it, not when a dependency bumps. The layer carries that cost for its own primitives; a component you override in your project, you carry.
That trade is the whole reason for the third level in the override guide: the smallest change that does the job is the right one, because a component you replace is a component you now maintain.
Why shadcn-vue rather than a ready-made documentation theme is recorded in ADR 0008.
One token layer, two stylesheets
The palette is CSS custom properties in oklch, defined once on :root and
redefined on .dark. Every component reads a token; none of them holds a colour.
That is what makes a one-line override possible — you redefine --primary in
your own stylesheet and every surface that uses it moves.
Two files carry it, and they are not interchangeable:
| File | Holds |
|---|---|
app/assets/css/duxt.css | The palette, the Tailwind entry, the layer's own preset — the file to read |
app/assets/css/typeset.css | shadcn's typeset, vendored verbatim — the file not to edit |
typeset.css has no registry item behind it, so the CLI can neither fetch nor
update it. Upgrading means downloading the file again, which is why local changes
belong in duxt.css's preset instead: an edit made in typeset.css is an edit
the next upgrade silently discards.
Dark mode is a class, not a media query
@custom-variant dark (&:is(.dark *)) — the theme switches on a dark class,
and @nuxtjs/color-mode sets exactly that class because the layer configures it
with classSuffix: ''.
This is the detail that catches people overriding a colour. A token defined only
under @media (prefers-color-scheme: dark) is never read, and a token defined
once on :root is used in both modes — which means it is wrong in one of them.
Redefine in both blocks, or accept that one mode keeps the layer's value.
The palette deviates from shadcn's default, deliberately
--muted-foreground is darker here than the shadcn neutral set ships it. That
token carries the page descriptions, the table of contents and the breadcrumb,
and at shadcn's value it measures below the 4.5:1 that WCAG AA asks for on the
surfaces it actually lands on.
Nothing in a browser-based check catches this: an accessibility run over the
built pages has no computed colour to measure. The layer therefore measures the
tokens themselves in tests/contrast.test.ts, pairing each foreground with the
backgrounds it is really used on. Override a foreground token and you take that
guarantee over — the test reads the layer's file, not yours.
Tailwind has to be told where the layer is
@source '../../../app' sits near the top of duxt.css and is not decoration.
Tailwind scans for class names from the build root, which is the consuming
project's directory. The layer lives outside it, in node_modules, so without
that line every utility used by a layer component would be absent from the
generated CSS — a site that renders with no styles at all.
The path is relative to the stylesheet, so it holds wherever the package is installed.
Where this leaves you
- A colour, a radius, a font: redefine the token in your own stylesheet.
- A layout that is wrong for your site: shadow the component by name.
- A primitive the layer does not carry: add it with the CLI.
Each step down that list costs more maintenance than the one above it. The override guide walks all three, and Brand your site covers the part that is identity rather than theme — the name, the mark and the icons.