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.
- name
dark-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.
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
| Role | Light | Dark | Why |
|---|---|---|---|
| canvas | L 0.99 | L 0.17–0.19, C ≈ 0.01 tinted | Pure black causes smearing on OLED scroll and harsh contrast |
| surface | L 1.00 | L 0.21 | Higher surfaces are lighter |
| raised (menus) | L 1.00 + shadow | L 0.25 + subtle border | Shadows barely read on dark |
| border | L 0.90 | L 0.30 | Borders do the separating work in dark |
| text | L 0.21 | L 0.93 | Off-white reduces halation |
| text-muted | L 0.45 | L 0.70 | Recheck 4.5:1 on each surface |
| accent fill | L 0.54 | L 0.68, chroma × 0.85 | Saturated mid-tones vibrate on dark |
| on-accent text | white | often near-black | Recheck; the lighter accent may need dark text |
| danger bg | L 0.97 red tint | L 0.25, C 0.05 red | Tints, 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-themeshandles the inline script; addsuppressHydrationWarningto<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-themeserver-side; still keepsystemas 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
| Element | Fix |
|---|---|
| Native inputs, selects, scrollbars, date pickers | color-scheme: dark on the root; never restyle only the background |
| Inline SVG icons | fill="currentColor" / stroke="currentColor" so they inherit text color |
| Logos with dark ink | Ship a light variant: <picture><source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)"><img …></picture>, or swap via the theme attribute |
| Photos | Optionally dim slightly: [data-theme=dark] img:not([data-no-dim]) { filter: brightness(.9); } |
| Screenshots of light UI | Provide dark screenshots or frame them with a border so they do not glare |
| Shadows | Replace with a 1px border at L 0.30 plus a darker, larger shadow (0 8px 24px oklch(0 0 0 / .5)) for overlays |
| Charts | Separate series palette for dark: raise lightness, lower chroma; gridlines L 0.30 |
| Code blocks | Pair syntax themes (e.g. github-light / github-dark) via CSS variables |
| Third-party embeds / iframes | Pass a theme param if supported; otherwise frame them |
| Focus ring | Recheck 3:1 against the dark surface |
Gotchas
prefers-color-schemealone ignores the user’s in-app override; always combine with the attribute.- Reading
localStoragein a React effect to set the theme guarantees a flash. - Forgetting
color-schemeleaves white scrollbars and form controls on dark pages. transition: background-coloron*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
#000canvas plus pure#ffftext (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
systemfollows 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.