Skip to content
Back to skills

Light Dark Mode

ASecurity

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.

  • 47 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
designjavascriptgojavagit

Works with

  • cli

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add mgifford/accessibility-skills --skill light-dark-mode --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Light Dark Mode?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Light Dark Mode
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/mgifford-light-dark-mode/badge)](https://www.skillsdirectory.com/skills/mgifford-light-dark-mode)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: light-dark-mode
description: >
  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.
---

# 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.**

---

## 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.

---

## Severity Scale (this skill)

| Level | Meaning |
| --- | --- |
| **Critical** | Colour theme makes content or interaction completely inaccessible |
| **Serious** | Contrast or mode failure significantly impairs access for a disability group |
| **Moderate** | Theme degrades usability but content remains partially accessible |
| **Minor** | Best-practice gap; marginal impact |

---

## Required: CSS Custom Properties Pattern

Always use CSS custom properties for theme tokens. Never hardcode colour values
in component styles — hardcoded values cannot be overridden by the theme system
or user preferences.

```css
:root {
  --color-text:       #1a1a1a;
  --color-background: #ffffff;
  --color-link:       #0066cc;
  --color-focus:      #004499;
  --color-border:     #cccccc;
  --color-hover:      #f5f5f5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-text:       #e8e8e8;
    --color-background: #1a1a1a;
    --color-link:       #66aaff;
    --color-focus:      #99ccff;
    --color-border:     #444444;
    --color-hover:      #2a2a2a;
  }
}

/* Manual override classes mirror the above values */
[data-theme="light"] { /* mirror :root light values */ }
[data-theme="dark"]  { /* mirror prefers-color-scheme dark values */ }
```

---

## Serious: Contrast Ratios in Both Modes

**Check all of the following in both light and dark modes.** Contrast that
passes in light mode frequently fails in dark mode when colours are inverted
naively.

| Element | Minimum ratio | Severity if failing |
| --- | --- | --- |
| Normal text | 4.5:1 | Serious |
| Large text (18pt+ / 14pt+ bold) | 3:1 | Moderate |
| UI components and graphical objects | 3:1 | Moderate |
| Focus indicators | 3:1 against adjacent colours | Serious |

---

## Serious: Manual Theme Toggle

If providing a theme toggle button, **it must be keyboard accessible and
correctly labelled**.

* Default to `prefers-color-scheme`; fall back to `light`
* Persist user choice — see localStorage section below
* `aria-label` must describe the **action** ("Switch to dark mode"), not the current state
* In dark mode: show sun icon (action = switch to light); in light mode: show moon icon (action = switch to dark)
* Place toggle **after** nav items in DOM order so it does not interrupt navigation
* Do not make the toggle fixed/sticky

```html
<button id="theme-toggle" type="button" aria-label="Switch to dark mode">
  <svg aria-hidden="true" focusable="false" class="sun-icon"
       viewBox="0 0 24 24" width="20" height="20">
    <circle cx="12" cy="12" r="5" fill="currentColor"/>
    <path fill="currentColor"
          d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12
             M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
  </svg>
  <svg aria-hidden="true" focusable="false" class="moon-icon"
       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>
</button>
```

---

## Moderate: Theme Persistence — localStorage with `try/catch`

`localStorage` is unavailable in some private browsing modes, cross-origin
iframes, and storage-restricted environments. Always wrap access in `try/catch`
to prevent a JavaScript error from breaking the theme toggle entirely.

```js
const toggle  = document.getElementById('theme-toggle');
const mq      = window.matchMedia('(prefers-color-scheme: dark)');

// Safe localStorage helpers
function getStoredTheme() {
  try {
    return localStorage.getItem('theme');
  } catch {
    return null; // Private browsing or storage blocked
  }
}

function setStoredTheme(theme) {
  try {
    localStorage.setItem('theme', theme);
  } catch {
    // Storage unavailable — preference will not persist across sessions.
    // The toggle still works for the current session.
  }
}

let userOverride = !!getStoredTheme();
let current      = getStoredTheme() || (mq.matches ? 'dark' : 'light');

function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  toggle.setAttribute(
    'aria-label',
    theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
  );
}

toggle.addEventListener('click', () => {
  current      = current === 'light' ? 'dark' : 'light';
  userOverride = true;
  setStoredTheme(current);
  applyTheme(current);
});

// Follow OS preference if no user override
mq.addEventListener('change', (e) => {
  if (!userOverride) {
    current = e.matches ? 'dark' : 'light';
    applyTheme(current);
  }
});

applyTheme(current);
```

---

## Serious: Forced-Colors / Windows High Contrast Mode

Windows High Contrast Mode (WHCM) is a critical accessibility mode for users
with low vision and light sensitivity. It overrides all CSS colours and
backgrounds with a small set of system colours, and removes many visual effects.

### What WHCM removes

The following CSS features are **silently discarded** in forced-colors mode —
if your UI relies on them to convey meaning, it will be broken for WHCM users:

* `box-shadow` (including focus rings implemented as box-shadow)
* `text-shadow`
* `background-image` (gradients, patterns, decorative images)
* `background-color` on non-interactive elements
* `border-color` set to `transparent`
* CSS `filter` and `backdrop-filter`
* `opacity` partially (elements may become fully opaque)

### Do not use these to convey meaning

Never rely on any of the above to communicate state, boundaries, hierarchy,
or interactivity. Examples of **problematic patterns**:

```css
/* Bad: focus ring implemented with box-shadow — invisible in WHCM */
:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px #005fcc;
}

/* Bad: disabled state shown only by reduced opacity */
button:disabled { opacity: 0.4; }

/* Bad: card boundary shown only by box-shadow */
.card { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
```

```css
/* Good: focus ring uses outline — survives forced-colors */
:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Good: disabled state has both opacity AND border change */
button:disabled {
  opacity: 0.4;
  border: 2px solid currentColor;
}

/* Good: card boundary uses border, not shadow */
.card { border: 1px solid var(--color-border); }
```

### Forced-colors CSS

Use the `forced-colors` media query to restore meaning lost when colours are
overridden. Use only [CSS system colour keywords](https://www.w3.org/TR/css-color-4/#css-system-colors):

```css
@media (forced-colors: active) {
  /* Restore card boundaries lost when background-color is overridden */
  .card {
    border: 1px solid CanvasText;
  }

  /* Restore table row separation lost when zebra-stripe backgrounds disappear */
  tbody tr {
    border-bottom: 1px solid CanvasText;
  }

  /* Restore focus ring if it was implemented as box-shadow */
  :focus-visible {
    outline: 2px solid Highlight;
    outline-offset: 2px;
  }

  /* Restore button appearance */
  button {
    color: ButtonText;
    background-color: ButtonFace;
    border: 1px solid ButtonText;
    forced-color-adjust: none; /* Opt out for elements needing full control */
  }

  /* Status indicators that used background colour */
  .status-error   { border: 2px solid LinkText; }
  .status-success { border: 2px solid CanvasText; }
  .status-warning { border: 2px solid Highlight; }
}
```

### `forced-color-adjust: none`

Use `forced-color-adjust: none` sparingly — only on elements where you need
to preserve specific colours (e.g., a colour swatch tool, a data visualisation).
Overusing it defeats the purpose of forced-colours mode.

---

## Required: Colour Independence

Never convey information by colour alone. Every status indicator needs icon +
text label + colour:

```html
<!-- Good: three independent signals -->
<div class="status status-error">
  <svg role="img" aria-label="Error" focusable="false">
    <use href="#icon-warning"/>
  </svg>
  <span>Payment failed</span>
</div>

<!-- Bad: colour is the only signal — invisible in WHCM and to CVD users -->
<div class="status-error">Payment failed</div>
```

---

## Required: SVG Icons

Use `currentColor` so icons inherit theme colour and respond to forced-colours:

```html
<svg viewBox="0 0 24 24" class="icon" aria-hidden="true" focusable="false">
  <path fill="currentColor" d="…"/>
</svg>
```

For images with transparency that need to work across modes:

```html
<picture>
  <source srcset="logo-dark.svg"
          media="(prefers-color-scheme: dark)">
  <img src="logo-light.svg" alt="Company logo">
</picture>
```

---

## Required: Focus Indicators in All Modes

```css
:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}
```

* Minimum 3:1 contrast against adjacent colours
* At least 2px thick
* Must be visible in light mode, dark mode, and forced-colours mode
* **Never implement focus rings as `box-shadow` alone** — they vanish in WHCM

---

## Required: Motion

```css
* { transition: background-color 0.2s ease, color 0.2s ease; }

@media (prefers-reduced-motion: reduce) {
  * { transition: none; }
}
```

Never auto-animate theme changes based on time of day.

---

## Moderate: `color-mix()` for Relative Colour Computation

`color-mix()` lets you compute colours relative to a base token, which is
especially useful for zebra stripes and hover states that need to adapt to
both light and dark backgrounds.

```css
--color-table-row-even: color-mix(in srgb, var(--color-background) 95%, black);
--color-table-row-odd:  color-mix(in srgb, var(--color-background) 90%, black);
```

**Browser support:** `color-mix()` is baseline 2023 (Chrome 111+, Firefox 113+,
Safari 16.2+). It is **not supported** in older browsers — notably IE 11 and
older Safari versions still in use on some platforms.

There is no direct polyfill for `color-mix()` because it involves runtime colour
computation. The practical fallback is to define explicit values for each theme
and accept the duplication:

```css
/* Fallback: explicit values for each theme */
:root {
  --color-table-row-even: #f2f2f2;
  --color-table-row-odd:  #e5e5e5;
}
@media (prefers-color-scheme: dark) {
  :root {
    --color-table-row-even: #272727;
    --color-table-row-odd:  #343434;
  }
}

/* Progressive enhancement: override with color-mix() where supported */
@supports (color: color-mix(in srgb, red, blue)) {
  :root {
    --color-table-row-even: color-mix(in srgb, var(--color-background) 95%, black);
    --color-table-row-odd:  color-mix(in srgb, var(--color-background) 90%, black);
  }
}
```

Use `@supports` to apply `color-mix()` only where the browser supports it,
with explicit values as the universal baseline.

---

## Moderate: Data Table Zebra Stripes

Define stripe colours relative to the page background — a 5–10% luminance step.
Absolute colour values that look right in light mode will look wrong in dark mode.

```css
tbody tr:nth-child(even) { background-color: var(--color-table-row-even); }
tbody tr:nth-child(odd)  { background-color: var(--color-table-row-odd);  }
```

In forced-colours mode, zebra stripe backgrounds are discarded — add a bottom
border in your `forced-colors` block to preserve row separation (see above).

---

## Definition of Done Checklist

* [ ] All text/UI elements meet WCAG 2.2 AA contrast in **light** mode
* [ ] All text/UI elements meet WCAG 2.2 AA contrast in **dark** mode
* [ ] `prefers-color-scheme` detected and respected by default
* [ ] Forced-colors mode: content comprehensible; no meaning conveyed by shadow, gradient, or background alone
* [ ] Focus rings use `outline`, not `box-shadow` alone
* [ ] Information not conveyed by colour alone — icon + text + colour
* [ ] Focus indicators visible and meeting 3:1 in all modes
* [ ] `localStorage` access wrapped in `try/catch`
* [ ] User preference persists across sessions where `localStorage` is available
* [ ] `prefers-reduced-motion` respected for theme transitions
* [ ] Zebra stripes use relative (5–10%) differences; fallback for forced-colors uses `border-bottom`
* [ ] SVGs use `currentColor`
* [ ] `color-mix()` gated behind `@supports` with explicit fallback values

---

## Key WCAG Criteria

* 1.4.1 Use of Color (A)
* 1.4.3 Contrast Minimum (AA) — **Serious if failing in either mode**
* 1.4.11 Non-text Contrast (AA)
* 2.4.11 Focus Appearance (AA, WCAG 2.2)

> Note: **1.4.12 Text Spacing** is a typography requirement — it belongs in a
> content-design or typography skill, not here. It is not specific to
> light/dark mode.

---

## References

* [Full best practices guide](https://github.com/mgifford/ACCESSIBILITY.md/blob/main/examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md)
* [WCAG 2.2 Understanding 1.4.3 Contrast Minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
* [WCAG 2.2 Understanding 2.4.11 Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html)
* [CSS prefers-color-scheme (Media Queries Level 5)](https://www.w3.org/TR/mediaqueries-5/#prefers-color-scheme)
* [CSS forced-colors (Media Queries Level 5)](https://www.w3.org/TR/mediaqueries-5/#forced-colors)
* [CSS System Colors (CSS Color Level 4)](https://www.w3.org/TR/css-color-4/#css-system-colors)
* [MDN: forced-color-adjust](https://developer.mozilla.org/en-US/docs/Web/CSS/forced-color-adjust)
* [Baseline 2023: color-mix() browser support](https://caniuse.com/mdn-css_types_color_color-mix)

> **Standards horizon:** WCAG 3.0's proposed **APCA** (Advanced Perceptual
> Contrast Algorithm) replaces the current 4.5:1 / 3:1 luminance-ratio model
> with a perceptual contrast model that treats light-on-dark and dark-on-light
> differently. This will directly affect light/dark mode contrast targets.
> Do not apply APCA to production work until WCAG 3.0 is a published standard.
> Monitor: <https://www.w3.org/TR/wcag-3.0/> and <https://git.apcacontrast.com/>

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.