Dark Mode Theming skill for Codex
Right theme on first paint, designed not inverted. Codex implements dark mode as a three-state preference that follows the system by default, sets the theme before first paint with a head script or next-themes, and drives everything through semantic tokens and color-scheme. Dark surfaces get lighter with elevation, accents lose chroma, and images, charts, code blocks and native controls are handled before both themes are screenshotted and contrast-checked.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- dark-mode-theming Installs to ~/.agents/skills/dark-mode-theming/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install dark-mode-theming The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project dark-mode-theming Installs to .agents/skills/dark-mode-theming/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/dark-mode-theming.zipunzip -o dark-mode-theming.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) dark-mode-theming PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $dark-mode-theming, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and implements dark mode properly - system, light and dark preference with persistence, no flash of the wrong theme, color-scheme and light-dark(), data-theme tokens, Tailwind v4 custom dark variant, next-themes for SSR, dark surface elevation, desaturated accents, images, charts, code blocks and form controls, and contrast checks in both themes. Use when adding dark mode, fixing a white flash on load, hydration mismatch with themes, unreadable or glaring dark UI, or a theme toggle that forgets the user's choice.
Use it when
- Adding dark mode or a theme switcher
- A flash of the wrong theme appears on load
- Hydration warnings appear after adding themes in an SSR app
- The dark theme feels glaring, flat or low-contrast
Use something else when
- Building the token architecture itself: use design-tokens
- Auditing contrast ratios and non-color cues formally: use color-contrast-accessibility
How to check the result
A hard reload with dark preference shows no white flash, an explicit choice survives reload, System follows OS changes live, and text pairs pass 4.5:1 in both themes.
The mistake it prevents
Setting the theme in a React effect after hydration, which paints the wrong theme first and causes a visible flash.
Dark mode is done when it follows the system by default, remembers an explicit user choice, renders the correct theme on the very first paint, and every surface, text, border, image, chart, code block and native control was designed for dark rather than inverted. Text contrast passes in both themes, and nothing glows.
Before changing anything
- Read
AGENTS.md; find how colors are defined (tokens,@theme, Tailwind config, CSS variables, CSS-in-JS theme objects) and how many hard-coded colors exist:rg -o '#[0-9a-fA-F]{3,8}\b|rgba?\(' src | wc -l. - Identify rendering: static HTML, SPA, or SSR (Next.js, Remix, Nuxt, SvelteKit). SSR determines how you avoid the flash.
- Dark mode needs semantic color roles. If components reference raw palette values,
introduce roles first (see
design-tokensandcolor-systems).
Theme model
Three user states, two rendered themes:
| Preference | Rendered theme | Stored |
|---|---|---|
| System (default) | From prefers-color-scheme, updates live | Nothing, or "system" |
| Light | Light | "light" |
| Dark | Dark | "dark" |
Use data-theme="light|dark" on <html> as the single switch. Keep a toggle with three
options (or a menu); a two-state toggle cannot return to “follow system”.
Implementation
Tokens
:root {
color-scheme: light;
--color-bg: oklch(0.985 0.003 260);
--color-surface: oklch(1 0 0);
--color-surface-raised: oklch(1 0 0);
--color-text: oklch(0.22 0.02 260);
--color-text-muted: oklch(0.48 0.02 260);
--color-border: oklch(0.90 0.008 260);
--color-action: oklch(0.55 0.17 260);
--shadow-raised: 0 1px 2px oklch(0.2 0.02 260 / .08), 0 4px 12px oklch(0.2 0.02 260 / .06);
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg: oklch(0.17 0.01 260);
--color-surface: oklch(0.21 0.012 260);
--color-surface-raised: oklch(0.25 0.014 260);
--color-text: oklch(0.94 0.008 260);
--color-text-muted: oklch(0.72 0.015 260);
--color-border: oklch(0.32 0.014 260);
--color-action: oklch(0.70 0.14 260);
--shadow-raised: 0 0 0 1px oklch(1 0 0 / .06);
}
color-scheme makes native controls, scrollbars and the default canvas follow the
theme. For one-off values, light-dark() picks by the computed color-scheme:
border-color: light-dark(oklch(0.9 0 0), oklch(0.3 0 0)); (requires color-scheme to
be set on the element or an ancestor; supported in current evergreen browsers).
No flash on first paint (static and SPA)
Put this inline in <head>, before stylesheets render, not in a deferred bundle:
<meta name="color-scheme" content="light dark">
<script>
(function () {
var pref = "system";
try { pref = localStorage.getItem("theme") || "system"; } catch (e) {}
var dark = pref === "dark" ||
(pref === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
})();
</script>
Toggle logic:
const mq = matchMedia("(prefers-color-scheme: dark)");
export function setTheme(pref) { // "system" | "light" | "dark"
localStorage.setItem("theme", pref);
apply(pref);
}
function apply(pref) {
const dark = pref === "dark" || (pref === "system" && mq.matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
}
mq.addEventListener("change", () => {
if ((localStorage.getItem("theme") ?? "system") === "system") apply("system");
});
If the project has a strict Content Security Policy, the inline script needs a nonce or hash; check the CSP config before adding it.
SSR frameworks
- Next.js and similar React SSR: use
next-themes(<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>) and addsuppressHydrationWarningto<html>, because the server cannot know the theme and the injected script changes the attribute before hydration. Render theme-dependent UI (the toggle icon) only after mount. - To render the right theme on the server, store the explicit choice in a cookie and read it in the root layout; “system” still needs the client script.
- Installing
next-themesneeds network and may need approval in the sandbox.
Tailwind v4
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
Prefer semantic utilities mapped to the variables (bg-surface, text-fg) over
sprinkling dark: on every element; reserve dark: for genuine exceptions. In Tailwind
v3, the equivalent is darkMode: ["selector", '[data-theme="dark"]'].
Design rules for dark
| Topic | Rule |
|---|---|
| Background | Dark gray with a hint of brand hue (L 0.15–0.20), not #000; offer true black only as an explicit OLED option |
| Elevation | Higher surfaces get lighter (bg < surface < raised < overlay), roughly +0.03–0.05 L per level; shadows barely show on dark |
| Text | Off-white (L 0.92–0.96), not pure white; muted text still at least 4.5:1 |
| Accent | Raise lightness and reduce chroma versus light mode; saturated colors vibrate on dark |
| Borders | Slightly more visible than in light mode; they replace shadows for separation |
| Status colors | Use lighter steps for text (danger-300) and dark tinted backgrounds for banners |
| Weight | Thin fonts look thinner on dark; avoid weights below 400 for body |
| Large areas | Avoid big blocks of saturated color; tint surfaces instead |
Media and content
- Images with white backgrounds (screenshots, diagrams): provide dark variants with
<picture><source srcset="diagram-dark.svg" media="(prefers-color-scheme: dark)">(note: this follows the system, not a manual toggle; for toggles, swap via[data-theme="dark"] .light-only { display:none }pairs). - Photos: leave them; optionally
filter: brightness(.9)on decorative ones. - Icons and inline SVG use
currentColorso they follow text color. - Charts: separate palette per theme; gridlines low contrast, data high contrast.
- Code blocks: pair light and dark syntax themes (Shiki supports dual themes).
- Third-party embeds (maps, video, widgets): check for a theme option; otherwise frame them so a white box looks intentional.
- Emails and PDFs generated by the app are separate surfaces; do not assume they follow.
Workflow
- [ ] 1. Ensure semantic color roles exist; replace raw colors
- [ ] 2. Define dark values per role (elevation, text, accent, status)
- [ ] 3. Add color-scheme, the head script or SSR handling, and the 3-state toggle
- [ ] 4. Handle images, charts, code, embeds
- [ ] 5. Check contrast for every text/bg pair in dark
- [ ] 6. Screenshot both themes and compare screen by screen
Verification
npx playwright screenshot --color-scheme dark --full-page http://localhost:3000 .shots/home-dark.png
npx playwright screenshot --color-scheme light --full-page http://localhost:3000 .shots/home-light.png
- Hard reload with a dark preference and cache disabled: no white flash (record with DevTools Performance screenshots if unsure).
- Choose Light while the OS is dark, reload: stays light. Choose System, flip the OS setting: the page follows live.
- No hydration warnings in the console on SSR apps.
- Contrast: body and muted text at least 4.5:1, borders of inputs at least 3:1 in dark.
- Search for remaining hard-coded light values:
rg 'bg-white|text-black|#fff\b|#ffffff' src.
Gotchas
- Reading
localStoragein a React effect to set the theme: the first paint is wrong. - Using
filter: invert(1)on the whole page: images, brand colors and shadows break. - Forgetting
color-scheme, which leaves white scrollbars, date pickers and autofill backgrounds in dark mode. - Shadows as the only separation; they disappear on dark surfaces.
- Testing only the home page; settings, modals, toasts and empty states hide most bugs.
Output contract
**Model:** system default + light/dark override, stored in localStorage (+ cookie for SSR)
**Switch:** data-theme on <html>; flash prevented via <head script | next-themes>
**Roles updated:** <n> roles with dark values; <n> raw colors replaced
**Media:** <images/charts/code handled>
**Checks:** no flash, live system follow, contrast pass (lowest pair <x:1>), screenshots .shots/*-dark.png What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Dark Mode Theming"
short_description: "Right theme on first paint, designed not inverted"
default_prompt: "$dark-mode-theming Add dark mode to our app with a toggle that remembers the user's choice" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.