Add a component library
The template styles itself with SASS modules and ships no component
library. Every colour, radius and font is a CSS custom property in
frontend/app/styles/theme.css, named the way shadcn/ui names them, so a
library you add reads the brand and both themes from the same place. This
recipe adds Tailwind CSS v4 and shadcn/ui beside the SASS modules. It was
run end to end on a copy of this frontend with tailwindcss 4.3.3 and the
shadcn CLI 4.23.0: one component added, built, and checked in light and
dark.
How the variables line up
Each shadcn/ui name has a variable of the same name in theme.css, written
light-dark(<light>, <dark>), so a component painted with bg-primary
switches with the theme by itself:
| shadcn/ui | Here it paints |
|---|---|
--background, --foreground |
the public pages' sand ground and ink |
--card, --card-foreground |
raised panels on the app screens |
--popover, --popover-foreground |
menus and sheets that float |
--primary, --primary-foreground |
the moss primary action |
--secondary, --secondary-foreground |
the surface tone |
--muted, --muted-foreground |
app panels, and grey text |
--accent, --accent-foreground |
the pale moss tint (active rows, badges) |
--destructive |
the status red |
--border, --input, --ring |
lines, control outlines, focus rings |
--radius |
app control corners |
--chart-1 to --chart-5 |
data series |
The rest are extras the template needs, commented where theme.css
defines them: --surface, --field and --field-foreground (the moss
band), --app-background, --rule, --primary-hover, --accent-subtle,
--accent-strong, --destructive-foreground, --destructive-tint, the
--code-* pair and --shadow-popover. To use one from Tailwind, add a
line for it to the @theme inline block in step 3, such as
--color-surface: var(--surface);.
Steps
All paths are in frontend/.
Install Tailwind and the one package the shadcn components assume:
npm install -D tailwindcss @tailwindcss/postcss npm install class-variance-authoritynpx shadcn addinstalls the rest of what a component imports (thecnclass merger andradix-uifor the button), but notclass-variance-authority, whichshadcn initwould have installed.Create
postcss.config.mjs:export default { plugins: { '@tailwindcss/postcss': {} }, };Create
app/tailwind.css:/* Tailwind without its preflight: globals.css already resets the elements, in the same base layer. */ @layer theme, base, components, utilities; @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); /* dark: follows <html data-theme>, and the system when there is none. */ @custom-variant dark { &:where([data-theme='dark'], [data-theme='dark'] *) { @slot; } @media (prefers-color-scheme: dark) { &:where( :root:not([data-theme='light']), :root:not([data-theme='light']) * ) { @slot; } } } /* Tailwind's colour and radius names, pointed at styles/theme.css. */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } /* A bare `border` class draws in the border colour, not the text's. */ @layer base { *, ::before, ::after { border-color: var(--border); } }The fonts need no line:
theme.csssets--font-sansoutside any layer, so it wins over Tailwind's default andfont-sansis Work Sans.Import it in
app/layout.tsx, aboveglobals.css, so the layer order it declares comes first:import './tailwind.css'; import './globals.css';Create
components.json. Skipnpx shadcn init: it writes its own palette into the CSS file, andaddneeds only this file.{ "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/tailwind.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "ui": "@/components/shadcn", "utils": "@/lib/utils", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" }uipoints atcomponents/shadcn, notcomponents/ui: shadcn writesbutton.tsx, and on macOS and Windows, where file names ignore case, that file would replace the template'scomponents/ui/Button.tsx.Add a component and use it:
npx shadcn add buttonimport { Button } from '@/components/shadcn/button'; <Button variant="outline">Export</Button>Build and look at it in both themes.
npm run build, then open the page with thethemecookie set tolightand todark(the toggle in the header sets it).bg-primaryis the moss,bg-cardthe raised panel, and thedark:classes in the component apply with the toggle.
Living with the SASS modules
- Classes from a module always beat Tailwind utilities. The modules
are outside any cascade layer and Tailwind's utilities are inside one,
so specificity does not enter into it. Style each element from one
side: do not pass
className="px-2"to acomponents/uiprimitive to restyle it; it will not apply. - The element resets in
globals.csssit in@layer base, below the utilities, so they do not undobg-primaryorborderon a shadcn button. Keep new element rules there. - Both sets read the same variables. Change a colour in
theme.cssand the SASS primitives and the shadcn components move together. Runnpm run testafter:theme-contrast.test.tschecks the text pairs. - shadcn's destructive button paints white text on
--destructive(at 60% in dark), not--destructive-foreground. Check that pair against your red, or edit the copied component, which is yours. lib/utils.tshas its owncn(), which only joins class names; the shadcn components import theirs from thecnpackage, which also resolves clashing Tailwind classes. Each keeps working for its own side.
Other libraries
Radix Themes and Mantine name their variables their own way
(--accent-9, --mantine-color-*), so these names do not line up with
them by themselves. Point theirs at theme.css in one CSS block, the way
the @theme inline block in step 3 does for Tailwind.
Related
- Add a page - the SASS primitives in
components/ui - Rebrand - changing the colours both sets read