$npx -y skills add mgifford/accessibility-skills --skill light-dark-modeLoad 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
| 1 | # Light/Dark Mode Accessibility Skill |
| 2 | |
| 3 | > **Canonical source**: `examples/LIGHT_DARK_MODE_ACCESSIBILITY_BEST_PRACTICES.md` in `mgifford/ACCESSIBILITY.md` |
| 4 | > This skill is derived from that file. When in doubt, the example is authoritative. |
| 5 | |
| 6 | Apply these rules whenever implementing or reviewing colour theme support in |
| 7 | HTML, CSS, or JavaScript. |
| 8 | **Only load this skill if the project supports light/dark mode or user theme switching.** |
| 9 | |
| 10 | --- |
| 11 | |
| 12 | ## Critical Rule: Focus Is Navigation, Not Selection |
| 13 | |
| 14 | **A theme must not change until the user explicitly activates an option.** |
| 15 | |
| 16 | Moving keyboard focus or pointer hover across theme choices MUST NOT preview, |
| 17 | select, or apply a theme. Tabbing across System, Light, and Dark MUST leave the |
| 18 | page colours unchanged until the user presses Enter or Space. |
| 19 | |
| 20 | This is a **Critical** requirement. Violations cause colour flashing during |
| 21 | keyboard navigation and disorient users with vestibular disorders, cognitive |
| 22 | disabilities, and low vision. |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ## Core Mandate |
| 27 | |
| 28 | All colour themes **must** meet WCAG 2.2 Level AA contrast in **both** light and |
| 29 | dark modes, including forced-colours / high contrast modes. Test all three — |
| 30 | not just the default. |
| 31 | |
| 32 | ## Required: Three-Option Theme Selector Pattern |
| 33 | |
| 34 | Use a visible three-option selector that shows all three options at the same time: |
| 35 | |
| 36 | * System |
| 37 | * Light |
| 38 | * Dark |
| 39 | |
| 40 | ### Interaction pattern requirements |
| 41 | |
| 42 | * MUST use a labelled group containing three native `<button type="button">` elements |
| 43 | * MUST use `aria-pressed="true"` for the selected option and `aria-pressed="false"` for others |
| 44 | * MUST NOT use a single cycling button |
| 45 | * MUST NOT use a two-state light/dark toggle |
| 46 | * MUST NOT use a menu that hides the available options |
| 47 | * MUST NOT use custom `role="radio"` elements |
| 48 | * MUST NOT use a radiogroup that changes theme when arrow-key focus moves |
| 49 | * MUST NOT use hover or focus previews |
| 50 | * MUST NOT automatically select when a user merely tabs to an option |
| 51 | |
| 52 | ### Keyboard requirements |
| 53 | |
| 54 | The selector MUST work with standard native button behaviour. Users MUST be able to: |
| 55 | |
| 56 | * Tab to each option |
| 57 | * Use Shift+Tab to move backwards |
| 58 | * Activate an option with Enter |
| 59 | * Activate an option with Space |
| 60 | * Activate an option with mouse, touch, switch device, voice control, or other pointer |
| 61 | |
| 62 | Moving focus between the options MUST NOT change the theme. The theme MUST |
| 63 | change only after deliberate activation. |
| 64 | |
| 65 | Do NOT add unnecessary custom keyboard handlers where native button behaviour |
| 66 | already provides the required functionality. |
| 67 | |
| 68 | ### Prevent colour flashing during keyboard navigation |
| 69 | |
| 70 | Theme changes MUST occur only after explicit activation. Moving keyboard focus |
| 71 | or pointer hover across theme choices MUST NOT preview, select, or apply a theme. |
| 72 | |
| 73 | Tabbing across System, Light, and Dark MUST leave the page colours unchanged |
| 74 | until the user presses Enter or Space. |
| 75 | |
| 76 | --- |
| 77 | |
| 78 | ## Required: HTML Structure |
| 79 | |
| 80 | Use a group with an accessible label containing three native buttons: |
| 81 | |
| 82 | ```html |
| 83 | <div role="group" aria-label="Colour theme"> |
| 84 | <button |
| 85 | type="button" |
| 86 | class="theme-mode-btn" |
| 87 | aria-pressed="false" |
| 88 | data-theme-value="system"> |
| 89 | <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20"> |
| 90 | <path fill="none" stroke="currentColor" stroke-width="2" d="M3 4h18v12H3zM8 20h8"/> |
| 91 | </svg> |
| 92 | <span>System</span> |
| 93 | </button> |
| 94 | |
| 95 | <button |
| 96 | type="button" |
| 97 | class="theme-mode-btn" |
| 98 | aria-pressed="false" |
| 99 | data-theme-value="light"> |
| 100 | <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20"> |
| 101 | <circle cx="12" cy="12" r="5" fill="currentColor"/> |
| 102 | </svg> |
| 103 | <span>Light</span> |
| 104 | </button> |
| 105 | |
| 106 | <button |
| 107 | type="button" |
| 108 | class="theme-mode-btn" |
| 109 | aria-pressed="true" |
| 110 | data-theme-value="dark"> |
| 111 | <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20"> |
| 112 | <path fill="currentColor" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/> |
| 113 | </svg> |
| 114 | <span>Dark</span> |
| 115 | </button> |
| 116 | </div> |
| 117 | ``` |
| 118 | |
| 119 | --- |
| 120 | |
| 121 | ## Required: Default Behaviour |
| 122 | |
| 123 | Use `system` as the default preference when the user has not made an explicit choice. |
| 124 | |
| 125 | Do NOT convert the system preference into a stored light or dark preference. |
| 126 | |
| 127 | For example, when the user selects system while their operating system is |
| 128 | currently dark, store: |
| 129 | |
| 130 | ``` |
| 131 | system |
| 132 | ``` |
| 133 | |
| 134 | Do NOT store: |
| 135 | |
| 136 | ``` |
| 137 | dark |
| 138 | ``` |
| 139 | |
| 140 | This distinction is required so that later operating-system changes continue to |
| 141 | affect the page. |
| 142 | |
| 143 | --- |
| 144 | |
| 145 | ## Required: Storage Requirements |
| 146 | |
| 147 | Use a clearly named storage key such as `theme-mode`. Valid stored values are |
| 148 | only: `system`, `light`, `dark`. |
| 149 | |
| 150 | * Validate stored values before usi |