All pages

Theming

Files you will touch:

  1. apps/web/src/styles/theme.css, the two theme blocks and nothing else
  2. apps/web/src/routes/__root.tsx, the two font imports
  3. apps/web/src/lib/theme/theme.ts, only if you rename a theme or change the default

theme.css is the reskin surface. Tokens only: nothing in globals.css names a colour, and no component hard-codes one.

Change the colours

Two daisyUI theme blocks ship, postflare and postflare-dark. Edit the values in place:

@plugin "daisyui/theme" {
  name: "postflare";
  default: true;
  color-scheme: "light";

  --color-base-100: oklch(100% 0 0);
  --color-base-200: oklch(97.3% 0.003 106.4);
  --color-base-300: oklch(92.7% 0.005 106.5);
  --color-base-content: oklch(0% 0 0);

  --color-primary: oklch(0% 0 0);
  --color-primary-content: oklch(100% 0 0);
  /* ... */
}

Every colour is oklch, and lightness is the first number. That is what makes a dark variant a mechanical edit rather than a redesign: the light theme's base-100 is 100% and the dark one's is 16.3%, at the same hue and chroma.

Both blocks have to change together. A colour that recurs becomes a token in both rather than a hard-coded value at a call site.

--color-neutral deliberately does not invert between the two. It is the fill behind prose code blocks, and a code block that flips to near-white in dark mode is unreadable.

The pairs that have to stay legible

Every --color-x has a --color-x-content, and daisyUI uses the second for text on the first. Change one and check the other, because a contrast failure shows up on exactly one theme and only on one component.

--color-success is a different lightness in each theme, 45% light and 70% dark, rather than the same value twice. Semantic colours that pass as body text against base-100 are a different problem in each direction.

Radii, borders and depth

--radius-selector: 9999px;   /* checkboxes, toggles, badges */
--radius-field: 0.625rem;    /* inputs, buttons, selects */
--radius-box: 0.5rem;        /* cards, modals, alerts */

--border: 1px;
--depth: 0;
--noise: 0;

daisyUI spends one --radius-field on buttons and text fields alike. Fields follow the card instead: Input, Select, MultiSelect and the two search comboboxes take --radius-box in their own base classes instead, which gives a field the same corner as the card around it. Change --radius-box and the fields follow.

--depth: 0 and --noise: 0 turn off daisyUI's gradient and texture effects. Turning them on is a different visual language, and the components in this repo were laid out without them.

Change the fonts

Two, both self-hosted, both exposed as CSS variables:

// apps/web/src/routes/__root.tsx
import '@fontsource-variable/inter'
import '@fontsource-variable/jetbrains-mono'

Both are variable fonts, so one import covers every weight. The Fontsource packages ship the font files with the app, and globals.css owns the --font-sans and --font-mono stacks, so swapping a typeface takes one package and import change here plus one declaration there. No request reaches Google at runtime.

How the theme is chosen

Three states, not two: follow the OS, an explicit override, and that override persisted.

Where What it does
__root.tsx ScriptOnce reads localStorage, falls back to prefers-color-scheme, sets data-theme before paint
useTheme() setTheme, clearTheme, toggleTheme, and isExplicit
ThemeToggle flips between the two shipped themes
Careful

The blocking script in __root.tsx is the only thing that reads the OS preference. daisyUI guards its own prefers-color-scheme block with :root:not([data-theme]), and <html> here always carries a server-rendered data-theme, so deleting that script does not fall back to a CSS dark mode. It removes dark mode.

It also cannot be moved into a component. A dark mode that flashes white on load reads as broken, and only a blocking inline script runs early enough to prevent that.

The script is built from the constants in lib/theme/theme.ts and serialized with JSON.stringify, so renaming a theme cannot leave a stale string quoted inside a script tag.

Ship dark first

Change one constant:

// apps/web/src/lib/theme/theme.ts
export const DEFAULT_THEME: Theme = DARK_THEME

An OS light preference then resolves dark as well, because the only branch left is "does the OS ask for dark". That is deliberately one knob rather than a knob plus a follow-the-OS flag.

LIGHT_THEME stays what toggleTheme() flips back to.

Rename or add a theme

THEMES in lib/theme/theme.ts and the name: in theme.css have to agree, along with the @custom-variant dark selector in globals.css. Three places read those strings, and a mismatch between any two is a dark mode that half works.

@custom-variant dark (&:where([data-theme="postflare-dark"], [data-theme="postflare-dark"] *));

That line exists because Tailwind 4's dark: resolves against prefers-color-scheme by default, which silently desyncs from an explicit override: a reader who picks light on a dark OS still gets every dark: class. Re-pointing it at the attribute is what makes the two agree.

Adding a third theme means a third block, a third entry in THEMES, and a control that can select it. toggleTheme() is a two-way flip and does not generalise.

Why daisyUI's own themes are not there

@plugin "daisyui" {
  themes: false;
}

That drops daisyUI's stock light and dark, which is 9.4KB of an 88KB stylesheet for two themes nothing here selects. The stock dark was also rejected on its own terms: its primary is hue 277 against this theme's 250, so the two read as different products, and leaving it in the bundle is one data-theme="dark" away from that.

Verify

pnpm dev

Toggle the theme on any page and reload. If it flashes the other theme first, the blocking script is not running.

Then check the docs and the dashboard as well as the landing page. Prose code blocks, the callout labels and the admin panels are where a token that only exists in one block shows up.

pnpm check
  • Components, what the tokens are rendered by
  • docs/adr/0017-daisyui-is-the-token-layer.md

Updated