Dark Mode skill for Claude Code

Right theme on the first frame, legible everywhere. Claude treats dark mode as a remapping of semantic tokens rather than a pile of dark: overrides, wires a three-state preference that defaults to the OS, and sets the theme in a blocking head script so the first frame is correct. It then works through the components that usually break, from native inputs and scrollbars to logos, shadows and chart colors.

  • namedark-mode
  • size195 lines · ≈2,000 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements dark themes properly — semantic color tokens remapped per theme, prefers-color-scheme with a system/light/dark toggle, no flash of the wrong theme on load or SSR hydration, color-scheme for native controls, and dark-aware images, shadows, charts, and SVGs. Use when adding dark mode, fixing a white flash on page load, building a theme switcher, or when dark mode looks muddy, too contrasty, or has unreadable components.

Try asking

  • Add dark mode to this app with a toggle that remembers the choice
  • There's a white flash before dark mode kicks in on our Next.js site. Fix it
  • Our dark theme looks muddy and the inputs are still white. Clean it up

Use it when

  • Adding a dark theme or theme switcher
  • Pages flash light before switching to dark
  • Dark mode has unreadable or unstyled components
  • Native controls or scrollbars ignore the theme

Skip it when

  • You still need to pick the palette and contrast-safe scales, use color-systems first
  • Your problem is token architecture across many brands, use design-system-tokens instead

How to check the result

With dark emulated and CPU throttled, reload shows no light frame; screenshots of both themes pass review and every semantic pair passes contrast.

The common mistake it prevents

Setting the theme in a React effect after hydration, which guarantees a flash of the light theme on every page load.

~/.claude/skills/dark-mode/SKILL.md

--- name: dark-mode description: Implements dark themes properly — semantic color tokens remapped per theme, prefers-color-scheme with a system/light/dark toggle, no flash… license: MIT ---

Dark mode is done when the theme follows the OS by default, a user override persists, the first painted frame is already the right theme, every component (including native inputs, scrollbars, images, and charts) is legible, and contrast passes in both themes. filter: invert(1) on <html> is not dark mode.

Ground rules

  • Theme lives in semantic tokens only. Components never contain dark: overrides for colors that a token could express; dark mode is a remapping, not a second stylesheet.
  • Three-state preference: system (default), light, dark. A two-state toggle that forgets the OS setting annoys users who switch at sunset.
  • Set the theme before first paint. Any theme decision that waits for JS hydration produces a flash.
  • Do not invert: dark surfaces get lighter as they rise in elevation, accents lose some chroma, text is off-white, not #fff.

Workflow

- [ ] 1. Audit: find hard-coded colors and component-level overrides
- [ ] 2. Define dark mapping of semantic tokens
- [ ] 3. Wire theme selection (CSS-only or attribute-based)
- [ ] 4. Prevent flash (inline head script or server-rendered attribute)
- [ ] 5. Fix native controls, images, shadows, SVGs, charts, embeds
- [ ] 6. Verify both themes, all states, contrast

1. Audit

grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' src --include='*.{css,scss,tsx,jsx,vue,svelte}' | grep -v tokens
grep -rn 'bg-white\|text-black\|bg-gray-\|text-gray-' src | wc -l   # Tailwind hard-codes

Each hit is either migrated to a token or justified (e.g. a brand logo color).

2. Dark token mapping

RoleLightDarkWhy
canvasL 0.99L 0.17–0.19, C ≈ 0.01 tintedPure black causes smearing on OLED scroll and harsh contrast
surfaceL 1.00L 0.21Higher surfaces are lighter
raised (menus)L 1.00 + shadowL 0.25 + subtle borderShadows barely read on dark
borderL 0.90L 0.30Borders do the separating work in dark
textL 0.21L 0.93Off-white reduces halation
text-mutedL 0.45L 0.70Recheck 4.5:1 on each surface
accent fillL 0.54L 0.68, chroma × 0.85Saturated mid-tones vibrate on dark
on-accent textwhiteoften near-blackRecheck; the lighter accent may need dark text
danger bgL 0.97 red tintL 0.25, C 0.05 redTints, not saturated blocks

Check every pair with the contrast script in color-systems.

3. Wire theme selection

Pattern A — CSS only, follows OS, no toggle:

:root {
  color-scheme: light dark;
  --canvas: light-dark(oklch(0.99 0.004 250), oklch(0.18 0.01 250));
  --text:   light-dark(oklch(0.21 0.015 250), oklch(0.93 0.01 250));
}

light-dark() resolves from the element’s used color-scheme; it is Baseline since 2024.

Pattern B — with a toggle (recommended for apps). Use an attribute on <html> so system can be expressed by the attribute’s absence:

:root { color-scheme: light; --canvas: oklch(0.99 0.004 250); --text: oklch(0.21 0.015 250); }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { color-scheme: dark; --canvas: oklch(0.18 0.01 250); --text: oklch(0.93 0.01 250); }
}
:root[data-theme="dark"] { color-scheme: dark; --canvas: oklch(0.18 0.01 250); --text: oklch(0.93 0.01 250); }

Tailwind v4: point the dark: variant at the attribute and the media query together:

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

(For system support with Tailwind, set data-theme from the head script below, resolving system to the OS value, rather than leaving it absent.)

4. Prevent the flash

Inline, blocking, in <head> before any stylesheet-dependent content:

<script>
  (function () {
    try {
      var pref = localStorage.getItem("theme");           // "light" | "dark" | null
      if (pref === "light" || pref === "dark") document.documentElement.dataset.theme = pref;
    } catch (e) {}
  })();
</script>

Toggle logic:

function setTheme(pref) {                // "system" | "light" | "dark"
  const root = document.documentElement;
  if (pref === "system") { delete root.dataset.theme; localStorage.removeItem("theme"); }
  else { root.dataset.theme = pref; localStorage.setItem("theme", pref); }
}

SSR frameworks:

  • Next.js: next-themes handles the inline script; add suppressHydrationWarning to <html> because the attribute differs between server and client. Render the toggle’s current state only after mount to avoid hydration mismatch.
  • If the server must render the right theme (e.g. no-JS, emails of the page), store the preference in a cookie and set data-theme server-side; still keep system as the default when no cookie exists.

Also declare the browser chrome color per theme:

<meta name="theme-color" content="#fbfbfd" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#16181d" media="(prefers-color-scheme: dark)">

5. The parts that usually break

ElementFix
Native inputs, selects, scrollbars, date pickerscolor-scheme: dark on the root; never restyle only the background
Inline SVG iconsfill="currentColor" / stroke="currentColor" so they inherit text color
Logos with dark inkShip a light variant: <picture><source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)"><img …></picture>, or swap via the theme attribute
PhotosOptionally dim slightly: [data-theme=dark] img:not([data-no-dim]) { filter: brightness(.9); }
Screenshots of light UIProvide dark screenshots or frame them with a border so they do not glare
ShadowsReplace with a 1px border at L 0.30 plus a darker, larger shadow (0 8px 24px oklch(0 0 0 / .5)) for overlays
ChartsSeparate series palette for dark: raise lightness, lower chroma; gridlines L 0.30
Code blocksPair syntax themes (e.g. github-light / github-dark) via CSS variables
Third-party embeds / iframesPass a theme param if supported; otherwise frame them
Focus ringRecheck 3:1 against the dark surface

Gotchas

  • prefers-color-scheme alone ignores the user’s in-app override; always combine with the attribute.
  • Reading localStorage in a React effect to set the theme guarantees a flash.
  • Forgetting color-scheme leaves white scrollbars and form controls on dark pages.
  • transition: background-color on * makes the theme switch (and first paint) animate; disable transitions while switching or scope them to specific elements.
  • Opacity-based text colors that passed on white often fail on dark surfaces.
  • Pure #000 canvas plus pure #fff text (21:1) is fatiguing; aim for roughly 14–16:1 for body text.
  • Emails: many clients force their own dark transformations; test in real clients and avoid transparent PNG logos with dark ink.

Verification

  • DevTools → Rendering → Emulate prefers-color-scheme: dark; reload with cache disabled and throttled CPU: no light frame before dark.
  • Playwright check for both themes:
for (const scheme of ["light", "dark"] as const) {
  await page.emulateMedia({ colorScheme: scheme });
  await page.goto("/");
  await expect(page).toHaveScreenshot(`home-${scheme}.png`);
}
  • Toggle through system → light → dark → system and reload after each; preference persists and system follows the OS again.
  • Run axe or the contrast script against every semantic pair in dark.
  • Walk every state in dark: hover, focus, disabled, selected, error, empty, skeleton.

Output contract

## Dark mode implementation
- Strategy: data-theme attribute + prefers-color-scheme fallback, 3-state toggle
- Tokens remapped: <list>; contrast table attached (light / dark)
- Flash prevention: inline head script (or cookie + SSR)
- Fixed components: inputs (color-scheme), logo variant, chart palette, code theme
- Known exceptions: <embed X has no dark mode; framed>

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc