light-dark-mode
Skill20 starsChanged 14 days agoMITView on GitHub
Implement, fix or audit light/dark mode on any website or web app, on any stack (static HTML, Astro, Vite/React, Next.js, SvelteKit, Vue): a data-theme attribute on the html element, semantic CSS variables with a dark override block, a blocking boot script so there is no flash of the wrong theme, a small controller for localStorage, OS preference, browser chrome color and cross-tab sync, and an accessible toggle or Light/Dark/System control. Use whenever the user asks for dark mode, a theme toggle or switcher, prefers-color-scheme, data-theme, color-scheme, theme-color, a flash of the wrong theme on load, dark mode for code blocks, canvas or images, or says the site "should follow the system theme", even if they only say "add a dark version". Not for Quantum Design token files (`system-light.json`, `system-dark.json`), which belong to the `quantum-design-tokens` skill.
2 other projects use a version of this.
Adopted by
Most recently taken up by
- mgifford/accessibility-skillstheir own version, first seen Jul 27, 2026
- miovrag/claude-skillstheir own version, first seen Jun 12, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- mgifford/accessibility-skills★ 48same name
- miovrag/claude-skills★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository21 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- a11y-page-auditSkill
- branch-comment-cleanupSkill
- branch-test-planSkill
- context7-mcpSkill
- env-example-syncSkill
- feature-based-architectureSkill
- great-readmeSkill
- harvest-reviewsSkill
- localizationSkill
- minimal-code-changesSkill
- prod-page-auditSkill
- quantum-design-tokensSkill
- reviewable-commitsSkill
- shell-aliases-socialSkill
- skill-best-practices-syncSkill
- skills-auditSkill
- skills-socialSkill
- stacked-pr-splitterSkill
- team-reviewerSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 2 are distinctive, and 9 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
9
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 24
If the project uses Quantum Design tokens (system-light.json / system-dark.json, --components-tokens--* variables), quantum-design-tokens …
- Very specific: line 66
If a maintained library already does this (for example next-themes with attribute="data-theme"), keep it, check it against the hard rules, …
- Very specific: line 190
Read references/theme-controller.md when writing the controller or its framework binding.
- Very specific: line 197
Signed-in sync (optional): When the user profile loads, call setThemePreference(profile.theme) if it is a valid preference: the saved …
- Very specific: line 260
CSP without inline scripts: Add the boot script's hash or a per-request nonce.
- Very specific: line 267
Syntax highlighting: Use dual themes and switch on the attribute.
- Very specific: line 281
Canvas, WebGL, charts: Read colors from the variables with getComputedStyle(document.documentElement).getPropertyValue("--color-…") at draw …
- Distinctive: line 296
Iframes and embeds: don't inherit the attribute.
- Very specific: line 301
Transitions: Don't put transition on colors globally.
- Distinctive: line 306
Run the site and check each case, in both themes:
- Very specific: line 323
If the project has a test runner: unit tests mock window.matchMedia with a controllable matches and a change listener, clear storage before …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Light/Dark Mode
One mechanism for every site: the resolved theme lives in data-theme on <html>,
colors live in CSS variables, and an inline script sets the attribute before first
paint. Components never know which theme is active; they read variables, and the
variables change under them.
If the project uses Quantum Design tokens (system-light.json / system-dark.json,
--components-tokens--* variables), quantum-design-tokens owns the token files and the
boot script (its Phase 3). Skip Phases 3 and 4 here and keep that boot script, upgrade its
toggle in Phase 6 instead of adding a second one, read theme-color from a component
token instead of a literal, and do everything else.
Hard rules (never violate)
- One attribute.
<html data-theme="light|dark">is the only switch. Never a.darkclass somewhere else, a theme prop threaded through components, or two mechanisms side by side. - Three preferences, two themes. The stored preference is
light,darkorsystem. What lands on<html>is always resolved tolightordark. Resolution order: stored choice → the default (normallysystem, which means the OS setting) → light. - Colors are variables. Light values on
:root, [data-theme="light"], dark overrides on[data-theme="dark"], each block with its owncolor-scheme. Any literal color outside the token block is a bug. - No flash. A blocking inline script in
<head>, ahead of every stylesheet, sets the attribute and thetheme-colormeta. Hydration only takes over from it. - Validate what you read. Storage can hold anything or throw. Accept only the three values; wrap every storage access in try/catch.
- Follow the OS live only while the preference is
system. An explicit choice wins until the user changes it. - One set of constants. Storage key, default preference and
theme-colorvalues are identical in the boot script and the controller. In SSR frameworks, build the boot script string from the same constants. - Everything follows. Native controls and scrollbars (
color-scheme), the mobile browser bar (theme-color), code blocks, canvas, images and Storybook switch too.
Phase 1 — Inspect
- Stack and where the document
<head>is authored:index.html,BaseHead.astro,_document.tsx,app/layout.tsx,app.html, a template partial. - Existing mechanisms: grep for
prefers-color-scheme,data-theme,.dark,color-scheme,theme-color,next-themes,darkMode,localStorage+theme. - Hardcoded colors in component CSS/JS:
#hex,rgb(,hsl(, Tailwind palette classes (bg-white,text-gray-900). - Things that won't follow CSS variables: syntax highlighting,
<canvas>/WebGL/chart libraries,<picture>sources, iframes, inline SVG with fills. - A Content-Security-Policy that blocks inline scripts (needs a nonce or hash).
If a maintained library already does this (for example next-themes with
attribute="data-theme"), keep it, check it against the hard rules, and do only the
missing steps. Don't add a second mechanism.
Phase 2 — Decide (ask only if the user hasn't said)
| Decision | Default | Alternative |
| Default preference | system (follow the OS until the user picks) | light for products whose design is light-first; only an explicit System choice follows the OS |
| Control | Two-state toggle in the header (flips what is on screen and stores it) | Light / Dark / System segmented control in settings |
| Storage key | theme | <app>:theme when several apps share one origin |
| Signed-in sync | none | Save the preference on the user profile so it follows them across devices |
Phase 3 — CSS tokens
Primitives first, then semantic tokens that point at them. Components only use the semantic ones.
:root {
/* Primitives (theme-independent) */
--gray-0: #ffffff;
--gray-50: #f4f6f8;
--gray-400: #9aa4ad;
--gray-500: #6b7680;
--gray-600: #59636c;
--gray-800: #1f262c;
--gray-900: #121212;
--brand-500: #2a9dad;
--brand-600: #1c7480;
}
/* Light: the default, and the fallback for any token the dark block omits */
:root,
[data-theme="light"] {
color-scheme: light;
--color-bg: var(--gray-50);
--color-surface: var(--gray-0);
--color-text: var(--gray-800);
--color-text-muted: var(--gray-600);
--color-border: var(--gray-500);
--color-accent: var(--brand-600);
--shadow: 0 2px 8px rgb(0 0 0 / 0.12);
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg: var(--gray-900);
--color-surface: var(--gray-800);
--color-text: var(--gray-50);
--color-text-muted: var(--gray-400);
--color-border: var(--gray-500);
--color-accent: var(--brand-500);
--shadow: 0 2px 8px rgb(0 0 0 / 0.4);
}
body {
background: var(--color-bg);
color: var(--color-text);
}Rules:
- The dark block overrides colors (and color-carrying values like shadows) only. Spacing, type and radius never change per theme.
[data-theme="light"]sits in the light selector so a light island inside a dark page (<section data-theme="light">) resolves correctly, and vice versa.- A color that must not change between themes (dark text on a yellow button, a band that stays dark in both) gets its own token with the same value in both blocks, or a locally scoped variable on that section. Don't let it inherit by accident.
- A component needing a one-off dark tweak uses
[data-theme="dark"] .component(:global([data-theme="dark"]) .componentin scoped styles). If it keeps happening, the token is missing. - Migrate hardcoded colors to tokens as part of the job, and report anything left.
Tailwind. Map utility colors to the semantic variables under their own names (a
key that references a variable of the same name is circular) and point the dark:
variant at the attribute. v4:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); plus
@theme inline { --color-page: var(--color-bg); --color-ink: var(--color-text); … }
(gives bg-page, text-ink; inline makes utilities emit the variable, so a theme
switch reaches them). v3: darkMode: ['selector', '[data-theme="dark"]'] and
theme.colors: { page: "var(--color-bg)", … }. With colors swapping through
variables, dark: variants are rarely needed.
Phase 4 — Boot script (no flash)
Inline, blocking, the first script in <head>, after the theme-color meta it updates
and before any stylesheet. ES5, no imports, everything in try/catch.
<meta name="theme-color" content="#f4f6f8" />
<script>
(function () {
var KEY = "theme";
var DEFAULT = "system";
var COLOR = { light: "#f4f6f8", dark: "#121212" };
var p = DEFAULT;
try {
var s = localStorage.getItem(KEY);
if (s === "light" || s === "dark" || s === "system") p = s;
} catch (e) {}
var dark =
p === "dark" ||
(p === "system" &&
!!window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
var t = dark ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute("content", COLOR[t]);
})();
</script>COLOR holds the page background of each theme (the --color-bg values), so the
mobile browser bar blends with the page. Never type="module", defer or async: a
deferred script runs after first paint, which is exactly the flash.
Phase 5 — Controller
One framework-agnostic module owns the attribute after load: storage, the OS listener,
cross-tab sync and the meta tag. UI code calls it and never touches data-theme.
Read references/theme-controller.md when writing the controller or its framework
binding; it holds the module to adapt. Skip it when a maintained library stays in place
(Phase 1).
Its exports, used in the phases below: STORAGE_KEY, DEFAULT_PREFERENCE, THEME_COLOR,
getThemeState, setThemePreference, toggleTheme, subscribeTheme.
Signed-in sync (optional). When the user profile loads, call
setThemePreference(profile.theme) if it is a valid preference: the saved choice wins
over the device cache, and writing it to storage makes the next boot right on this
device. On change, call setThemePreference(next) and then save next to the profile.
Phase 6 — The control
Two-state toggle. A real <button type="button"> showing the icon of the theme you
will switch to (moon in light, sun in dark).
- Client-rendered UI (SPA, React): the label states the action and comes from state:
aria-label={resolved === "dark" ? "Switch to light theme" : "Switch to dark theme"}. - Server-rendered or static markup: render both icons and swap them with CSS, so the HTML is identical in both themes and correct before any JS runs:
.theme-toggle__sun { display: none; }
[data-theme="dark"] .theme-toggle__sun { display: block; }
[data-theme="dark"] .theme-toggle__moon { display: none; }Give it a fixed accessible name ("Dark mode") with aria-pressed set from
getThemeState().resolved === "dark" on load and in subscribeTheme. Wire it with
button.addEventListener("click", toggleTheme).
Light / Dark / System control. A role="radiogroup" with three role="radio"
buttons (aria-checked from preference, not resolved), arrow-key movement between
options, and setThemePreference(option) on select. Label the options with text, not
only icons.
Both need a visible :focus-visible outline using a token, a hit area of at least
24×24 px, and translated labels if the site is localized.
Phase 7 — Wire it into the stack
- Static HTML / any template engine. Boot script in the shared
<head>partial; controller loaded as a module at the end of<body>. - Astro. Boot script as
<script is:inline>in the base head component; the toggle component's<script>imports the controller. With<ClientRouter />, the new page's<html>replaces the attribute on every navigation, so re-apply it:
import { THEME_COLOR, getThemeState } from "./theme";
document.addEventListener("astro:after-swap", () => {
const { resolved } = getThemeState();
document.documentElement.dataset.theme = resolved;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", THEME_COLOR[resolved]);
});- Vite / client-rendered SPA. Boot script in
index.html, theuseThemehook (or equivalent) in components. - Next.js, Pages Router. In
pages/_document.tsx:<Html data-theme="light">, the<meta name="theme-color">, then<script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />first in<Head>, withTHEME_BOOTbuilt as a template string from the exported constants. Next hydrates only#__next, so touching<html>causes no mismatch. - Next.js, App Router. In
app/layout.tsx:<html suppressHydrationWarning>(the attribute legitimately differs from the server HTML) and the same inline script in<head>. Put the theme-color meta in the layout's<head>too, not inmetadata, so it exists before the script runs. - SvelteKit. Boot script in
src/app.html's<head>. - Other SSR. Emit the boot script as the first inline script of the
server-rendered
<head>. - CSP without inline scripts. Add the boot script's hash or a per-request nonce. If
that is impossible, mirror the choice into a
themecookie and renderdata-themeon the server;systemthen falls back to the CSS default until the controller starts.
Phase 8 — Things that don't follow CSS variables
- Syntax highlighting. Use dual themes and switch on the attribute. Shiki (Astro
markdown.shikiConfig):themes: { light: "github-light", dark: "<dark theme>" },defaultColor: "light", then:
[data-theme="dark"] .shiki,
[data-theme="dark"] .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}(Astro's class is .astro-code.) Other highlighters: load both stylesheets, scope
each under its [data-theme].
- Canvas, WebGL, charts. Read colors from the variables with
getComputedStyle(document.documentElement).getPropertyValue("--color-…")at draw time and redraw on change. Observe the attribute rather than a theme prop; framework effects can run in an order that leaves a prop one render stale:
new MutationObserver(redraw).observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});- Images.
<picture><source media="(prefers-color-scheme: dark)">follows the OS, not the toggle. Use inline SVG withcurrentColor/variables, or two images swapped by[data-theme]rules. - Iframes and embeds don't inherit the attribute. Pass a theme parameter if the embed supports one; otherwise accept and note it.
- Storybook. A
themeglobal with a toolbar (light/dark) and a decorator that setsdocument.documentElement.dataset.themefromcontext.globals.theme. Import the same token CSS inpreview. - Transitions. Don't put
transitionon colors globally; every element animating at once makes the switch feel slow.
Phase 9 — Verify
Run the site and check each case, in both themes:
- Hard reload with no stored value, OS light and OS dark (DevTools → Rendering →
emulate
prefers-color-scheme); then withlightanddarkstored. First paint is already correct: no flash, including with CPU throttling and the cache disabled. - Toggle at runtime: every component follows without a reload. Anything that doesn't
still has a literal color; grep for
#hex/rgb(outside the token file. - Form controls, scrollbars and autofill look native in dark (
color-scheme). - The mobile browser bar matches the page background; the boot
COLORmap matches--color-bgin both themes. - With preference
system, flip the OS setting with the page open: it follows. With an explicit choice, it doesn't. - Two tabs: change the theme in one, the other follows.
- Storage blocked (private window, or throw from
localStoragein a test): no errors, the OS theme applies. - Text contrast at least 4.5:1 (3:1 for large text and UI borders) in both themes.
- Code blocks, canvas, images and Storybook switch.
- If the project has a test runner: unit tests mock
window.matchMediawith a controllablematchesand achangelistener, clear storage before each test, and cover: default, stored choice wins, invalid value ignored, OS change followed only undersystem.
Fix any failing case and rerun the checks before the report. A case that can't pass or
can't be run goes under Verified in the report, with the reason.
Checklist
- [ ] One mechanism: data-theme on <html>; old .dark/media-only blocks removed
- [ ] Light block on :root, [data-theme="light"]; dark overrides on [data-theme="dark"]
- [ ] color-scheme set in both blocks
- [ ] No literal colors outside the token block (or listed in the report)
- [ ] Blocking inline boot script first in <head>, after the theme-color meta
- [ ] Stored value validated; every storage access in try/catch
- [ ] Same key, default and theme-color values in boot script and controller
- [ ] OS changes followed only while preference is system; cross-tab sync works
- [ ] Toggle/segmented control: native button/radios, accessible name, focus ring, translated
- [ ] SSR: no hydration warnings; App Router has suppressHydrationWarning on <html>
- [ ] Code blocks, canvas, images, Storybook follow the theme
- [ ] All applicable verification cases passReport
Light/dark mode — <project>
Stack: <framework> · Default: <system|light> · Control: <toggle|segmented> · Key: <key>
Files: <boot location>, <controller>, <token CSS>, <control component>
Migrated: <n> hardcoded colors → tokens · Left: <list with file:line, or none>
Special cases: <code blocks / canvas / images / embeds and how each was handled>
Verified: <cases 1–10, any that could not be run and why>Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

