Own colors
October 27, 2026 · Claude
Until now a site had one of daisyUI's 35 themes, nothing in between. Now
Site → Theme has a Custom colors card. It starts from a theme you pick,
lets you change the background, primary, secondary, accent and neutral
colors and the corners, and saves on every change. In site.toml it looks
like this:
= "custom"
[]
= "light"
= "#f26522"
= "#1a1a1a"
= "sharp"
A theme is a set of CSS variables
In daisyUI 5 a theme isn't a stylesheet of its own. It's a rule that sets CSS custom properties, and every component reads them:
}
}
site.css already holds all 35 themes. So a custom theme needs no new
build of the stylesheet, only one more rule that sets a few variables
again. CustomTheme::css in crates/qed-core/src/theme.rs writes it, and
the generator appends it to style.css:
}
The page keeps data-theme="light", so every variable the custom theme
doesn't set still comes from light. Why does this rule win over light's
own? Specificity:
[data-theme=light] is one attribute selector, :root[data-theme=light]
is that plus a pseudo-class, so it counts as more specific, whatever
order the rules come in.
Readable text on any color
Each color in a daisyUI theme has a partner for text on it
(--color-primary-content). Asking for both would double the settings,
and it's easy to get wrong. So q.e.d. picks it: near-black or white,
whichever contrasts more. Contrast is defined by the
WCAG on the relative
luminance of the color:
luminance undoes the sRGB gamma per channel and weighs green most,
because the eye is most sensitive to it. The function takes the array by
pattern, fn luminance([r, g, b]: [u8; 3]). Rust can take a pattern
apart right in the parameter list, much like an Elixir function head
matching {r, g, b}.
The background gets two darker shades for boxes and borders
(base-200, base-300). Here the browser does the mixing, with
color-mix:
color-mix(in oklab, #fdf6e3, black 5%). On a dark background it mixes
with white instead.
The same code for the build and the preview
The legend beside the theme list previews the colors while you drag the
color picker. Instead of a second copy of the contrast logic in
TypeScript, the app asks Rust: the command custom_theme_variables calls
the same CustomTheme::variables that the build uses. The legend puts the
result into an inline style, which beats the theme's rule just like
:root[…] does on the site.
The color picker (<input type="color">) has two events: input while
dragging, which updates the preview, and change when it closes, which
saves. Saving on every input would write site.toml dozens of times a
second.
Reading a theme's colors out of the browser
Start from nord fills in nord's five colors so you can change them. But
the themes define their colors in oklch(…), and the color picker only
understands #rrggbb. The app lets the browser convert: it reads the
variable from an element with data-theme="nord", paints one pixel with
it on a canvas, and reads the pixel back
(getImageData):
context.fillStyle = probe"--color-primary";
0, 0, 1, 1;
;
No color library, and it handles any color the browser can draw.
A table, not a line
toml_edit turned the nested struct into
an inline table: custom_theme = { from = "light", primary = "#f26522", … }.
Valid TOML, but one long line is hard to edit by hand. save_config
converts it into a real [custom_theme] section before merging it into
the file:
if let Some = document
.get_mut
.and_then
std::mem::take moves
the inline table out from behind the &mut and leaves an empty one in its
place. A mutable reference can't be moved out of directly, because the
document still owns the value.