Files you will touch:
apps/web/src/styles/theme.css, the two theme blocks and nothing elseapps/web/src/routes/__root.tsx, the two font importsapps/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 |
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
Related
- Components, what the tokens are rendered by
docs/adr/0017-daisyui-is-the-token-layer.md
