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.

  • Design & UX
  • $dark-mode-theming
  • 199 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- dark-mode-theming

Installs to ~/.agents/skills/dark-mode-theming/ for every project on this machine.

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

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.

~/.agents/skills/dark-mode-theming/SKILL.md

--- name: dark-mode-theming description: Designs and implements dark mode properly - system, light and dark preference with persistence, no flash of the wrong theme, color-scheme and… license: MIT ---

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-tokens and color-systems).

Theme model

Three user states, two rendered themes:

PreferenceRendered themeStored
System (default)From prefers-color-scheme, updates liveNothing, or "system"
LightLight"light"
DarkDark"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 add suppressHydrationWarning to <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-themes needs 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

TopicRule
BackgroundDark gray with a hint of brand hue (L 0.15–0.20), not #000; offer true black only as an explicit OLED option
ElevationHigher surfaces get lighter (bg < surface < raised < overlay), roughly +0.03–0.05 L per level; shadows barely show on dark
TextOff-white (L 0.92–0.96), not pure white; muted text still at least 4.5:1
AccentRaise lightness and reduce chroma versus light mode; saturated colors vibrate on dark
BordersSlightly more visible than in light mode; they replace shadows for separation
Status colorsUse lighter steps for text (danger-300) and dark tinted backgrounds for banners
WeightThin fonts look thinner on dark; avoid weights below 400 for body
Large areasAvoid 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 currentColor so 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 localStorage in 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.yamlgenerated from this page

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.

esc