mgifford/accessibility-skills/light-dark-mode

Claude Code and agent skill by mgifford/accessibility-skills

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Load this skill whenever the project supports light/dark mode, colour theme switching, high-contrast mode, or responds to prefers-color-scheme. Under no circumstances hard-code colours that break in alternative themes. Absolutely always test colour contrast in both light and dark themes, and respect user OS-level colour preferences via CSS media queries.

Install

npx vskill@latest install mgifford/accessibility-skills/light-dark-mode

SKILL.md

Light/Dark Mode Accessibility Skill

Canonical source: examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md in mgifford/ACCESSIBILITY.md
This skill is derived from that file. When in doubt, the example is authoritative.

Apply these rules whenever implementing or reviewing colour theme support in HTML, CSS, or JavaScript. Only load this skill if the project supports light/dark mode or user theme switching.


Critical Rule: Focus Is Navigation, Not Selection

A theme must not change until the user explicitly activates an option.

Moving keyboard focus or pointer hover across theme choices MUST NOT preview, select, or apply a theme. Tabbing across System, Light, and Dark MUST leave the page colours unchanged until the user presses Enter or Space.

This is a Critical requirement. Violations cause colour flashing during keyboard navigation and disorient users with vestibular disorders, cognitive disabilities, and low vision.


Core Mandate

All colour themes must meet WCAG 2.2 Level AA contrast in both light and dark modes, including forced-colours / high contrast modes. Test all three — not just the default.

Required: Three-Option Theme Selector Pattern

Use a visible three-option selector that shows all three options at the same time:

  • System
  • Light
  • Dark
Interaction pattern requirements
  • MUST use a labelled group containing three native <button type="button"> elements
  • MUST use aria-pressed="true" for the selected option and aria-pressed="false" for others
  • MUST NOT use a single cycling button
  • MUST NOT use a two-state light/dark toggle
  • MUST NOT use a menu that hides the available options
  • MUST NOT use custom role="radio" elements
  • MUST NOT use a radiogroup that changes theme when arrow-key focus moves
  • MUST NOT use hover or focus previews
  • MUST NOT automatically select when a user merely tabs to an option
Keyboard requirements

The selector MUST work with standard native button behaviour. Users MUST be able to:

  • Tab to each option
  • Use Shift+Tab to move backwards
  • Activate an option with Enter
  • Activate an option with Space
  • Activate an option with mouse, touch, switch device, voice control, or other pointer

Moving focus between the options MUST NOT change the theme. The theme MUST change only after deliberate activation.

Do NOT add unnecessary custom keyboard handlers where native button behaviour already provides the required functionality.

Prevent colour flashing during keyboard navigation

Theme changes MUST occur only after explicit activation. Moving keyboard focus or pointer hover across theme choices MUST NOT preview, select, or apply a theme.

Tabbing across System, Light, and Dark MUST leave the page colours unchanged until the user presses Enter or Space.


Required: HTML Structure

Use a group with an accessible label containing three native buttons:

html
<div role="group" aria-label="Colour theme">
<button
type="button"
class="theme-mode-btn"
aria-pressed="false"
data-theme-value="system">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="none" stroke="currentColor" stroke-width="2" d="M3 4h18v12H3zM8 20h8"/>
</svg>
<span>System</span>
</button>
<button
type="button"
class="theme-mode-btn"
aria-pressed="false"
data-theme-value="light">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<circle cx="12" cy="12" r="5" fill="currentColor"/>
</svg>
<span>Light</span>
</button>
<button
type="button"
class="theme-mode-btn"
aria-pressed="true"
data-theme-value="dark">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
<span>Dark</span>
</button>
</div>

Required: Default Behaviour

Use system as the default preference when the user has not made an explicit choice.

Do NOT convert the system preference into a stored light or dark preference.

For example, when the user selects system while their operating system is currently dark, store:

system

Do NOT store:

dark

This distinction is required so that later operating-system changes continue to affect the page.


Required: Storage Requirements

Use a clearly named storage key such as theme-mode. Valid stored values are only: system, light, dark.

  • Validate stored values before using them
  • Wrap all localStorage reads and writes in try/catch
  • The theme control MUST continue to work for the current page when storage is unavailable
  • Do NOT allow a storage failure to stop script execution

Storage can be unavailable in:

  • Privacy-restricted browsers
  • Sandboxed contexts
  • Private browsing modes
  • Environments where storage access throws an exception

Required: Theme Application Model

Maintain separate values for the selected mode and resolved theme:

javascript
selectedMode = "system" | "light" | "dark";
resolvedTheme = "light" | "dark";

Apply both to the root element using explicit attributes:

html
<html data-theme-mode="system" data-theme="dark">
  • data-theme-mode represents the user's actual selection
  • data-theme represents the currently rendered light or dark scheme

When system is active, listen for changes to:

javascript
window.matchMedia("(prefers-color-scheme: dark)")

Update the resolved theme when that preference changes. Do NOT overwrite the stored mode when the system preference changes.


Required: Initial Rendering and Flash Prevention

Provide an early initialization pattern that runs before the primary stylesheet or before the page is visibly rendered. The pattern MUST:

  1. Safely read the stored preference
  2. Validate the stored value
  3. Default to system
  4. Resolve system using prefers-color-scheme
  5. Set the root theme attributes before the first significant paint
  6. Set an appropriate color-scheme value

Include early in <head>:

html
<meta name="color-scheme" content="light dark">

Explain that this early script is intended to reduce a flash of the incorrect colour scheme during page load. Do NOT claim that a script can eliminate every possible visual transition in every browser. Describe it as reducing or preventing the common flash caused by applying the preference too late.


Required: CSS Custom Properties Pattern

Use semantic theme tokens through CSS custom properties. Do NOT hard-code component colours independently throughout the example.

css
:root {
color-scheme: light dark;
--color-text: #1a1a1a;
--color-background: #ffffff;
--color-surface: #f7f7f7;
--color-link: #0066cc;
--color-focus: #004499;
--color-border: #cccccc;
--color-hover: #f5f5f5;
}
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e8e8e8;
--color-background: #121212;
--color-surface: #1b1b1b;
--color-link: #66aaff;
--color-focus: #9bd1ff;
--color-border: #444444;
--color-hover: #262626;
}
}
@supports (color: light-dark(#000, #fff)) {
:root {
--color-text: light-dark(#1a1a1a, #e8e8e8);
--color-background: light-dark(#ffffff, #121212);
--color-surface: light-dark(#f7f7f7, #1b1b1b);
--color-link: light-dark(#0066cc, #66aaff);
--color-focus: light-dark(#004499, #9bd1ff);
--color-border: light-dark(#cccccc, #444444);
--color-hover: light-dark(#f5f5f5, #262626);
}
}
/* Manual overrides narrow the active scheme when a user chooses one. */
[data-theme="light"] {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}

Component styles MUST read from the tokens above rather than hardcode colour values.


Required: Selected and Focus State Styling

The selected state MUST NOT be communicated through colour alone. Use multiple cues such as:

  • aria-pressed
  • A visible checkmark or icon
  • A thicker or otherwise distinct border
  • Font-weight
  • Text labels

Focus and selected state MUST be visually distinguishable from one another. A focused but unselected option MUST NOT look identical to the selected option.

css
.theme-mode-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font-size: 1rem;
cursor: pointer;
}
/* Focus indicator — visible outline, not box-shadow */
.theme-mode-btn:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* Selected state — distinct from focus */
.theme-mode-btn[aria-pressed="true"] {
border-color: var(--color-link);
font-weight: 600;
background: var(--color-background);
}

Use :focus-visible with a real outline. Do NOT remove the outline unless it is replaced by an equal or stronger visible focus indicator.


Required: Forced-Colours Support

The selector MUST remain usable in Windows High Contrast Mode and other forced-colours environments. Do NOT rely only on:

  • Background colours
  • Gradients
  • Box shadows
  • Opacity
  • SVG fill colours
  • Colour differences

Use system colours where appropriate within @media (forced-colors: active).

Ensure that:

  • Button boundaries remain visible
  • The selected option remains identifiable
  • The focused option remains identifiable
  • Selected and focused states remain distinct
  • Icons using currentColor remain visible

Do NOT use forced-color-adjust: none unless there is a specific, documented reason and the resulting colours are tested.

css
@media (forced-colors: active) {
.theme-mode-btn {
border: 2px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.theme-mode-btn:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.theme-mode-btn[aria-pressed="true"] {
border-color: Highlight;
forced-color-adjust: none;
background: Highlight;
color: HighlightText;
}
}

Required: Accessible Naming and Status

Use visible text labels: System, Light, Dark. Icons MAY supplement the labels but MUST NOT replace them.

Decorative icons MUST use:

html
<svg aria-hidden="true" focusable="false">...</svg>

Do NOT dynamically replace stable button labels with phrases such as:

  • Switch to dark
  • Current dark mode
  • Use system dark

The button label SHOULD describe the preference the button selects. The selected state is communicated through aria-pressed.

It is acceptable to include a separate status message explaining the resolved system theme, for example:

html
<p class="visually-hidden" aria-live="polite" id="theme-status">
System preference currently uses dark mode.
</p>

Do NOT make this announcement excessively verbose. Avoid announcing the same state repeatedly when nothing has changed.


Required: Reduced Motion

Do NOT use animated colour transitions by default. Theme changes can affect most of the viewport and may be uncomfortable or disorienting.

If a transition example is included, it MUST:

  • Be subtle
  • Avoid transitioning every CSS property
  • Respect prefers-reduced-motion
  • Not delay the actual state change
  • Not create intermediate low-contrast states

Prefer no theme-transition animation in the canonical example.

css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

Required: Contrast Requirements

The skill MUST continue requiring WCAG 2.2 Level AA contrast testing in every relevant state. Test at least:

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
0

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/mgifford/accessibility-skills/light-dark-mode/badge)](https://verified-skill.com/skills/mgifford/accessibility-skills/light-dark-mode)

Something wrong with this skill or its page? Tell us.