Skip to content
Agentmart
Submit a tool

dark-mode

Add dark mode, light mode, and a system-default appearance control to a web app.

Skill by MaxAnderson95

IndexedMIT
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/maxanderson95-dark-mode

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

Add dark mode, light mode, and a system-default appearance control to a web app. Use when the user asks for a dark or light theme, a theme switcher or toggle, or reports a flash of the wrong theme on load, a theme that resets on reload, a control that loses "system", scrollbars or form controls stuck on the wrong side, or a hydration mismatch on <html>. Carries the CSS token structure, the pre-paint script, and the wiring for Astro, Vite, and Next.js.

Use cases: frontend

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md. Checked Oct 25, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found

Network hosts it talks to 1

  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/dark-mode

What's inside

Skill name
dark-mode
Description
Add dark mode, light mode, and a system-default appearance control to a web app. Use when the user asks for a dark or light theme, a theme switcher or toggle, or reports a flash of the wrong theme on load, a theme that resets on reload, a control that loses "system", scrollbars or form controls stuck on the wrong side, or a hydration mismatch on <html>. Carries the CSS token structure, the pre-paint script, and the wiring for Astro, Vite, and Next.js.
License
MIT
Instructions
172 lines in SKILL.md · view on GitHub

Files (3)

  • SKILL.md
  • astro.md
  • react.md

SKILL.md (first lines)

# Dark mode

Two values, never one.

**Preference** is what the user chose: `dark`, `light`, or `system`. You store it, and the control draws it.
**Resolved theme** is what the page paints: `dark` or `light`. It equals the preference, except on `system`, where the OS decides.

Collapsing them into a boolean is the root of most theme bugs. `system` stops being reachable after the first press, the control cannot say which state it is in, and a machine that flips at dusk keeps yesterday's answer.

Carry both on `<html>`, where every stylesheet and every component can reach them:

| What | Where | Read by |
|---|---|---|
| preference | `data-theme="dark \| light \| system"` | the control, and any framework store |
| resolved theme | `class="dark"`, present only when dark | your CSS |
| resolved theme | `style="color-scheme: dark"` | the browser's own widgets |

Work through the four steps in order. Each one is checkable, and step 4 is what proves the feature.

## 1. One token set, two values

Every colour the page paints goes through a custom property. A hard-coded hex inside a component is a colour that cannot change with the theme, so hunt those first and promote them to tokens.

```css
:root {
  --bg: #faf9f7;
  --surface: #ffffff;
  --ink: #16202b;
  --ink-soft: #5b6b7c;
  --line: #e3ded6;
}
/* Resolved by the pre-paint script in step 2. */
html.dark {
  --bg: #0d1a2b;
  --surface: #14263c;
  --ink: #e9eff6;
  --ink-soft: #93a5b8;
  --line: #23374e;
}
```

- `html.dark` (0,1,1) outranks `:root` (0,1,0), so the override needs no `!important`.
- Components that need more than a token swap take a `html.dark .thing { }` rule. Keep those few; a long tail of them means the token set is too thin.
- **Once the class exists, `@media (prefers-color-scheme: dark)` must not appear anywhere in your stylesheets.** It answers a different question (what the OS wants) than the page is now asking (what the user chose), and the two disagree the moment anyone forces a theme. The media query survives in exactly one place: inside the script in step 2. Converting an existing site means rewriting every one of those blocks into a `html.dark` selector.
- Add `<meta name="color-scheme" content="light dark" />` to `<head>` so the browser knows both exist before any CSS parses.
- **Tailwind v4:** `@custom-variant dark (&:where(.dark, .dark *));` in your CSS entry. **v3:** `darkMode: 'class'` in the config. Then `dark:` utilities key off the class instead of the OS.

**Done when:** the whole page renders correctly with `dark` toggled by hand in devtools, and a search for `prefers-color-scheme` across the stylesheets returns nothing.

## 2. The pre-paint script

This is the only thing standing between the user and a **flash** of the wrong theme. It has to be inline and blocking, in `<head>`: the answer depends on `localStorage` and the OS setting, neither of which the server or the build can know, so anything deferred paints light first and then snaps to dark.

```html
<script>
  (function () {
    var KEY = 'theme';
    var root = document.documentElement;
    var media = window.matchMedia('(prefers-color-scheme: dark)');

    function read() {
      try {
        var stored = localStorage.getItem(KEY);
        if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
      } catch (e) {
        // Storage throws outright in some privacy modes; an unreadable
        // preference is the same situation as an unset one.
      }
      return 'system';
    }

    function apply(preference) {
      var dark = preference === 'dark' || (preference === 'system' && media.matches);
      root.dataset.theme = preference;
      root.classList.toggle('dark', dark);
      root.style.colorScheme = dark ? 'dark' : 'light';
    }

    apply(read());

    // On 'system' the OS can flip while the page is open, and nothing writes
    // to storage when it does.
    media.addEventListener('change', function () {
      if (root.dataset.theme === 'system') apply('system');
    });

    // Fires only in OTHER tabs, which is exactly the case this covers.
    window.addEventListener('storage', function (e) {
      if (e.key === KEY) apply(read());
    });

    window.__theme = {
      set: function (preference) {
        try {
          localStorage.setItem(KEY, preference);
        } catch (e) {
          // The choice still applies to this page, it just is not remembered.
        }
        apply(preference);
      },
    };
  })();
</script>
```

Nothing else in the app may write the class, the attribute, or the storage key. `window.__theme.set` is the one door in.

**Read the wiring file for the framework in play now** — placement is where this step actually goes wrong:

- Astro → [`astro.md`](astro.md)
- React (Next.js, Vite, Remix, anything with hydration) → [`react.md`](react.md)
- Plain HTML or a template language → paste the block above into `<head>` as-is, ahead of the stylesheet links.

**Done when:** hard-reloading with a dark preference on a light OS shows no light frame, in a throttled profile with the cache disabled.

## 3. The control

One button that cycles `dark` → `light` → `system` → `dark`. One button rather than a menu because there are three states and the icon already says which one is active; a menu would cost a press and a popover to say the same thing. Conventional glyphs: moon, sun, monitor.

From MaxAnderson95/my-opencode-setup, MIT.

More from MaxAnderson95/my-opencode-setup

Signals

Stars
0
Forks
0
Open issues
0
Last commit (this folder)
Aug 9, 2026 (2 months ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from MaxAnderson95

Cite this page

dark-mode skill. Agent Mart. https://agentmart.dev/skills/maxanderson95-dark-mode (last updated 2026-10-24)