Theming
Theming
The whole look of the app lives in two blocks in src/styles.css: :root
(light mode) and .dark (dark mode). Landing pages, auth pages, the app,
emails, the cookie banner, favicons, the OG image and the browser chrome
color all read from them. A palette change is one edit, or one command.
Swap the palette: one command
- Browse presets at https://ui.shadcn.com/create
- Click Shuffle until you find something you like
- Click Get code and copy the preset code (e.g.
b4r0BkVlEX) - Run:
bun run theme <preset-code>
# example:
bun run theme b4r0BkVlEX
scripts/theme.ts does three things:
shadcn apply <preset> --only themerewrites the shadcn variables in:root/.darkand updatescomponents.json. It keeps the tokens shadcn doesn't know about (--success,--warning,--info,--primary-text,--highlight,--cc-*).scripts/sync-theme-paint.tscopies the new--background/--foregroundinto the first-paint colors and the theme-color meta values (see below).scripts/generate-favicons.tsregenerates favicons, app icons, the OG image and the web manifest from the new palette.
Review the diff, run make ci, and commit src/styles.css,
components.json, src/hooks/use-theme.ts, src/routes/__root.tsx,
config.ts and public/. A preset brings its own --radius and --chart-*
values too.
More palettes at https://ui.shadcn.com/themes.
What each token controls
| Tokens | Controls |
|---|---|
--background, --foreground | Page background and body text |
--card, --popover (+ -foreground) | Surfaces |
--primary, --primary-foreground | Buttons, active states, brand accents |
--primary-text | Primary used as text (links, accents) on the page background |
--highlight | Accent highlight, a lighter primary |
--secondary, --muted, --accent | Secondary buttons, muted text, hover states |
--destructive, --success, --warning, --info | Status: errors, OK, warnings, notices |
--border, --input, --ring | Borders, form inputs, focus rings |
--radius | Border radius; every component derives from this one value |
--sidebar-* | Sidebar |
--chart-* | Data visualization (and the landing hero's sample avatars) |
Derived automatically
You don't edit these; they follow the palette:
- Components: everything in
src/components/ui/and the features uses token classes (bg-primary,text-primary-text,text-muted-foreground, ...). - Derived tokens:
--primary-textand--highlightare computed from--primarywith relative color syntax (oklch(from var(--primary) min(l, 0.5) c h)). Light-mode--primary-textcaps lightness at 0.5 so links keep 4.5:1 contrast on--backgroundand--muted, even when--primaryis a bright fill; dark mode raises it to at least 0.72. - Emails:
src/lib/email-theme.tsreads:rootfromstyles.cssand converts oklch to hex (alpha is blended over--background). Token values must stayoklch(...)literals for this to work. - Cookie banner:
--cc-*on#cc-mainmap to the tokens. - First paint: the theme-init scripts in
src/routes/__root.tsxandTHEME_PAINTinsrc/hooks/use-theme.tspaint--background/--foregroundas literal inline styles on<html>, to win the race against the deferred stylesheet. They can't usevar(), soscripts/sync-theme-paint.tscopies the values in, along with hexconfig.theme.lightThemeColor/darkThemeColorfor<meta name="theme-color">.src/lib/theme-paint.test.tsfails if any of them drift. After a manual edit of--background/--foreground, runbun scripts/sync-theme-paint.ts. - Favicons, OG image, manifest: see below.
What stays manual
- Logo shapes:
public/icon.svgandpublic/logo.svg(see below). - Fonts: see "Change the font".
- Brand marks: third-party logos (
src/assets/brand-icons/) keep their own colors.
Favicon and logo color
public/icon.svg and public/logo.svg mark their themed shapes:
data-brand="" shapes get --primary, data-fg="" shapes (the wordmark
text) get --foreground. make favicons rewrites those fills in the source
SVGs, renders the favicons and app icons, draws the OG image (logo.svg on a
--background canvas, rendered in Playwright's Chromium with an embedded font) and sets the manifest colors from --background.
bun run theme runs all of this for you; after a manual palette edit, run
make favicons and commit the files in public/.
When you replace the SVGs, keep data-brand="" fill="..." (or
data-fg="" fill="...") on every shape that should follow the theme. Browsers
cache favicons hard, so check in a private window.
Change the font
Fonts are defined once, in the @theme block of src/styles.css
(--font-sans, --font-mono), and used by body and code. The default is
the system stack.
- Install a
@fontsource-variable/*package:bun add @fontsource-variable/geist - In
src/styles.css, add the import at the top:@import '@fontsource-variable/geist'; - Put the family first in
--font-sans(or--font-mono) in@theme.
The OG image's wordmark uses its own embedded font so it renders the same on
every machine: FONT_PATH in scripts/generate-favicons.ts (Inter by
default, a devDependency used only there). Point it at your brand font's
.woff2 and run make favicons.
Keeping it single-source: make check-raw-colors
make ci runs scripts/check-raw-colors.ts, which fails on colors that
bypass the tokens in src/**: Tailwind palette classes (bg-blue-500,
text-gray-700), bg-white / text-white / text-black, arbitrary color
classes (bg-[#24292e]), and hex literals in .tsx files. Use a token
instead (bg-background, text-primary-text, bg-success/15, ...). The
short allowlist at the top of the script covers confetti, the spinner's white
variant, email templates and the appearance-page previews; add to it only
with a reason.
Related
- UI Components, pre-installed shadcn components and custom additions