Companion to VARIABLES.md (which documents what exists). This one is for the other direction: "my component looks broken in dark mode — why?"
Written for whoever (human or AI) picks this up next. Start at the checklist.
Symptoms, most common cause first:
| Symptom | Cause |
|---|---|
| Light text on a white box (unreadable) | Hard-coded background: #fff with no color set |
| Dark text on a dark box | Hard-coded color: #333 (or #000) with no background set |
| Component ignores the theme entirely | Hard-coded hex values instead of --f-color-* roles |
| Shadow looks like a grey halo | Hard-coded rgba(0,0,0,…); use var(--f-drop-shadow) |
| Borders vanish | border-color from a fixed grey; use --f-color-border-0..2 |
| Component is correct, your override isn't | Specificity — see §4 |
| Correct in the site, wrong in wp-admin | Admin/editor contexts are deliberately excluded — see §3 |
The one rule: never set background without color, or color without
background. Setting only one leaves the other inherited from a theme that may
have flipped underneath you. Either set both from --f-color-* roles, or set
neither and let the component's own theme rules do it.
Colors live in CSS custom properties so they can flip at runtime, and there are
no Sass aliases for them — use var(--f-color-bg-1) everywhere, Foundation
component settings included.
/* ✗ breaks in dark mode */
.thing {
background: #fff;
border: 1px solid #ddd;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
/* ✓ follows the theme */
.thing {
background: var(--f-color-bg-1);
color: var(--f-color-text-1);
border: 1px solid var(--f-color-border-1);
box-shadow: var(--f-drop-shadow);
}Roles you want most of the time: --f-color-bg-0..2, --f-color-text-0..2,
--f-color-border-0..2, --f-drop-shadow. Full table in
VARIABLES.md §1.
Custom properties work in inline styles and in <style> blocks printed at
runtime, so plugin output has no excuse to hard-code. When the stylesheet might
not be loaded at all, use the fallback argument:
background: var( --f-color-bg-1, #fff );
color: var( --f-color-text-1, #1d2327 );Light-mode values as fallbacks are fine — if the variable is missing, Foundation is missing, and there is no dark theme to clash with.
Most "make it themeable" work is already done. A notice/banner is a
.callout plus a palette class, and it is theme-correct for free:
<div class="callout success">Saved.</div>
<div class="callout alert">Something went wrong.</div>Palette classes: primary, secondary, success, warning, alert. Note the
error variant is alert, not error or danger — a wrong name silently
falls back to the default token rather than erroring.
.callout derives its background, border, and text color from
--f-color-<name> via color-mix(), so all five variants work in both themes.
The palette itself differs per mode ($foundation-palette vs
$foundation-palette-darktheme), which is why hard-coding the "same" green as a
hex is not equivalent.
The classic mistake: adding class="callout success" and then overriding it
with an inline style="background: #fff". Now the box is white in both themes
while the text color still comes from the theme. Pick one system.
Selection cascade, first match wins (see
scss/custom-styles/_root-variables.scss):
- Forced:
<html data-theme="light">/<html data-theme="dark"> - OS preference:
prefers-color-scheme: darkwhen nodata-themeis set - Default: light
Two details that cause "it works for me" bug reports:
- In OS-preference mode the roles land on
body, not:root. The selector is:root:not([data-theme]) body:not(…). Anything that reads the variables above<body>, or renders outside it, gets light values while the page looks dark. Forceddata-thememode applies at the root and does not have this split. - Admin and editor contexts are excluded on purpose — the OS-preference rule
skips
.editor-styles-wrapper,.wp-editor, and.wp-admin, so the Gutenberg iframe and TinyMCE don't get a dark canvas the editor doesn't expect. A component that only misbehaves in wp-admin is usually hitting this, not a bug.
foundation-apply-theme() also sets color-scheme, which is what makes native
form controls, scrollbars, and <select> popups follow along. If those look
wrong in an isolated subtree, that subtree is likely missing the theme roles
entirely.
When you add fallback styling to a component that also carries theme classes, keep your rule less specific so the theme still wins when it is loaded:
/* Fallback — 0,1,0. Only applies when Foundation is absent. */
.my-notice {
background: #fff;
color: #1d2327;
}
/* Foundation's .callout.success is 0,2,0 and beats it. */Source order does not save you here: a <style> block printed in the footer
comes after the enqueued stylesheet, so an equal-specificity rule of yours
would win and silently break dark mode. Use specificity, not order, to express
"this is only a fallback." Conversely, put your own layout rules (position,
flex, spacing) behind an id or a compound selector so they reliably apply — it
is only the colors that should yield.
If a component sits inside a themed subtree, its background is derived from
that subtree's --f-color-theme, in both modes. Two consequences:
- Don't assume
--f-color-bg-1is the page background — inside a tinted card it is the card's. Pick the role by meaning, not by the color you saw once. - The whole derivation is wrapped in
@supports (color: oklch(from red l c h)). Browsers without relativeoklch()fall back to the flat palette, so verify there before calling it a bug.
Details and the light/dark derivation table: VARIABLES.md §3.
Check both modes, and check the forced and OS-preference paths separately — per §3 they apply the roles at different elements.
// In DevTools: force a mode without touching OS settings.
document.documentElement.dataset.theme = "dark";
document.documentElement.dataset.theme = "light";
delete document.documentElement.dataset.theme; // back to OS preferenceThen use DevTools' "Emulate prefers-color-scheme" to cover path 2. Inspect the element and read the computed value of the custom properties — a rule that looks right in the Styles pane may be losing to a more specific one, and the computed value is what tells you.