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

  1. Browse presets at https://ui.shadcn.com/create
  2. Click Shuffle until you find something you like
  3. Click Get code and copy the preset code (e.g. b4r0BkVlEX)
  4. Run:
bun run theme <preset-code>
# example:
bun run theme b4r0BkVlEX

scripts/theme.ts does three things:

  1. shadcn apply <preset> --only theme rewrites the shadcn variables in :root / .dark and updates components.json. It keeps the tokens shadcn doesn't know about (--success, --warning, --info, --primary-text, --highlight, --cc-*).
  2. scripts/sync-theme-paint.ts copies the new --background / --foreground into the first-paint colors and the theme-color meta values (see below).
  3. scripts/generate-favicons.ts regenerates 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

TokensControls
--background, --foregroundPage background and body text
--card, --popover (+ -foreground)Surfaces
--primary, --primary-foregroundButtons, active states, brand accents
--primary-textPrimary used as text (links, accents) on the page background
--highlightAccent highlight, a lighter primary
--secondary, --muted, --accentSecondary buttons, muted text, hover states
--destructive, --success, --warning, --infoStatus: errors, OK, warnings, notices
--border, --input, --ringBorders, form inputs, focus rings
--radiusBorder 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-text and --highlight are computed from --primary with relative color syntax (oklch(from var(--primary) min(l, 0.5) c h)). Light-mode --primary-text caps lightness at 0.5 so links keep 4.5:1 contrast on --background and --muted, even when --primary is a bright fill; dark mode raises it to at least 0.72.
  • Emails: src/lib/email-theme.ts reads :root from styles.css and converts oklch to hex (alpha is blended over --background). Token values must stay oklch(...) literals for this to work.
  • Cookie banner: --cc-* on #cc-main map to the tokens.
  • First paint: the theme-init scripts in src/routes/__root.tsx and THEME_PAINT in src/hooks/use-theme.ts paint --background / --foreground as literal inline styles on <html>, to win the race against the deferred stylesheet. They can't use var(), so scripts/sync-theme-paint.ts copies the values in, along with hex config.theme.lightThemeColor / darkThemeColor for <meta name="theme-color">. src/lib/theme-paint.test.ts fails if any of them drift. After a manual edit of --background / --foreground, run bun scripts/sync-theme-paint.ts.
  • Favicons, OG image, manifest: see below.

What stays manual

  • Logo shapes: public/icon.svg and public/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.

  1. Install a @fontsource-variable/* package:
    bun add @fontsource-variable/geist
    
  2. In src/styles.css, add the import at the top:
    @import '@fontsource-variable/geist';
    
  3. 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.

  • UI Components, pre-installed shadcn components and custom additions