Contrast, halation, and prefers-color-scheme done right
Dark mode is often treated as a pure taste question, but it is an accessibility topic with real tension built in: it relieves light-sensitive users while potentially worsening readability for users with astigmatism through the halation effect. Teams that recalculate contrast ratios correctly, respect prefers-color-scheme, and avoid pure black build a color scheme that genuinely works for both groups.
Table of Contents
- 1. Why dark mode is not just a design topic
- 2. Who dark mode helps: light sensitivity and migraine
- 3. Who dark mode hurts: astigmatism and the halation effect
- 4. Recalculating contrast ratios for a dark color scheme
- 5. Respecting prefers-color-scheme correctly in code
- 6. Why pure black with pure white is usually a mistake
- 7. Dark mode in Hyvä themes with Tailwind and Alpine.js
- 8. User control: toggle, persistence, and ARIA
- 9. Dark mode decisions in direct comparison
- 10. Summary
- 11. FAQ
1. Why dark mode is not just a design topic
Dark mode often gets treated in project meetings as a pure aesthetics question: dark color scheme on or off, depending on the trend. This view overlooks that a dark color scheme has an immediate effect on readability for very different groups of users. For part of the audience, dark mode is a genuine accessibility measure that reduces glare and makes extended reading tolerable in the first place. For another part of the audience, the same switch measurably worsens character recognition, because the human eye reacts physiologically differently to bright objects on a dark background than to dark objects on a bright background.
This ambivalence has a practical consequence: a dark mode that simply inverts the colors of a light design does not solve an accessibility problem, it merely shifts it. Anyone planning dark mode and accessibility together needs to keep both user groups in mind, calculate the contrast ratio for the dark scheme independently, and let the system decide which scheme is active initially. The following sections show how this is implemented concretely in Magento and Hyvä stores, from the physiological basis to the Alpine.js component for a manual toggle.
2. Who dark mode helps: light sensitivity and migraine
For users with photophobia, chronic migraine, or certain neurological conditions, a glaring white interface acts like a direct light source in the field of view. An average screen with a white background emits noticeably more light than the same screen with a dark background, especially in dark environments or during extended evening use. For this group, dark mode does not just reduce discomfort, it measurably prevents symptom escalation: studies on migraine triggers regularly cite glaring light as one of the most common triggers.
For people with certain forms of cataract or increased light sensitivity after eye surgery, a dark color scheme is not a comfort option but a functional necessity to be able to use a page for any length of time at all. The WCAG rules themselves do not mandate a specific color scheme, but Success Criterion 1.4.8 (Visual Presentation) requires that users be able to select foreground and background colors themselves when a text block alternative is offered. prefers-color-scheme is the technical implementation of this principle at the operating system level, without every website having to build its own color picker.
3. Who dark mode hurts: astigmatism and the halation effect
For users with astigmatism, a very common vision condition caused by an irregularly curved cornea, dark mode can have the opposite effect. The so-called halation effect describes how bright characters on a dark background scatter within the eye, creating a slight glow around each letter. With strong contrast between very bright text and a very dark background, character edges blur more than they would with dark text on a light background. For people with uncorrected or partially corrected astigmatism, reading in dark mode becomes more strenuous rather than easier.
This effect is not a footnote: astigmatism affects an estimated one third of the adult population to some degree, many of them unaware of it or only lightly corrected. The practical consequence for accessibility work is clear: a forced, non-toggleable dark mode is just as problematic as a forced light mode. The solution is not choosing one scheme, but implementing both schemes cleanly, letting the operating system make the initial choice, and always offering the user a manual toggle.
4. Recalculating contrast ratios for a dark color scheme
A common mistake in implementation: developers copy the color values of the light design, invert them mechanically, and assume the contrast ratio is preserved. That is often true mathematically, because the WCAG contrast formula is based on relative luminance and reacts symmetrically to a pure inversion, but it completely ignores the halation effect from section 3. A contrast ratio of 21:1 between pure white (#ffffff) and pure black (#000000) formally passes WCAG AAA with top marks, yet it is more uncomfortable to read for many users than a slightly reduced ratio of 15:1 to 17:1 with a softened white tone.
For body text, WCAG 2.2 Success Criterion 1.4.3 requires at least 4.5:1, and for large text (24px and above, or 19px bold) at least 3:1. These minimums apply unchanged in dark mode, but the practical recommendation deliberately sits above them, in the range of 7:1 to 15:1, to leave room for halation avoidance without dropping below the minimum. Tools like the WebAIM Contrast Checker or the Chrome DevTools contrast inspector must be run separately for every color combination in the dark scheme, an automatic carryover of the light values is not sufficient.
/* Contrast ratios calculated separately for light and dark scheme,
not simply inverted from one another */
:root {
--color-bg: #ffffff;
--color-text: #1c1917; /* ratio to bg: 17.9:1 */
--color-muted: #57534e; /* ratio to bg: 7.1:1 */
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #18181b; /* not pure #000000 */
--color-text: #e4e4e7; /* not pure #ffffff, ratio to bg: 15.8:1 */
--color-muted: #a1a1aa; /* ratio to bg: 7.3:1, still passes AA for large text */
}
}
body {
background-color: var(--color-bg);
color: var(--color-text);
}
5. Respecting prefers-color-scheme correctly in code
The media query prefers-color-scheme reads the user's operating system setting and is the correct starting point for every dark mode concept. The key principle: the website should follow the system, not the other way around. An interface that always starts in light mode on first visit and forces the user to switch manually ignores a deliberately made system setting and creates unnecessary friction, especially for users who work consistently in dark mode for medical reasons.
In CSS-first approaches like Tailwind CSS v4, prefers-color-scheme can be mapped directly through CSS variables and the dark: variant, without JavaScript on the first render. It is important to set the initial color scheme server-side or via an inline script before the first paint, otherwise a visible flash of wrong theme occurs, where the page briefly flashes light before JavaScript switches to dark mode afterward. This flash is not just unattractive, it is a real small burden for light-sensitive users on every page visit.
<!-- Inline script in <head>, before any CSS or content paints,
prevents a flash of the wrong color scheme on first load -->
<script>
(function () {
var stored = localStorage.getItem('color-scheme');
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = stored ? stored === 'dark' : systemDark;
document.documentElement.classList.toggle('dark', isDark);
})();
</script>
/* Tailwind CSS v4 CSS-first dark mode setup */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* Falls back to the OS setting when no class is set manually */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
color-scheme: dark;
}
}
6. Why pure black with pure white is usually a mistake
The practical takeaway from section 3 translates into a simple rule: #000000 as background and #ffffff as text should be avoided in a dark mode color scheme, even though this combination delivers the highest possible contrast ratio. The reason lies in the physiology of the eye, not in the math of the contrast formula: OLED screens with true black produce a particularly strong brightness jump at every character edge, which amplifies the halation effect and can additionally lead to perceived flicker during eye movement.
The proven solution, also used by major systems like Material Design and the macOS system design, is a dimmed gray instead of pure black for the background (roughly #18181b to #121212) and a slightly softened white instead of pure white for the text (roughly #e4e4e7 instead of #ffffff). This combination stays comfortably above the WCAG minimums while noticeably reducing the brightness jump at character edges. For users who still need maximum contrast, for example with severe visual impairment without astigmatism, a separate high-contrast mode via prefers-contrast: more should be available, rather than tuning the default dark mode to the extreme.
7. Dark mode in Hyvä themes with Tailwind and Alpine.js
Hyvä themes already bring the right tools for a clean dark mode concept via Tailwind CSS and Alpine.js, without needing to load additional JavaScript libraries. Color definitions belong centrally in the CSS variables of the tailwind.css, not scattered across individual .phtml templates, so every component automatically supports both color schemes. For Magento-specific elements such as prices, stock status, or discount badges, colors in the dark scheme need to be checked individually, since a strong red for "out of stock" often produces a different contrast ratio on a dark background than on a light one.
For the initial state without a flash of wrong theme, Hyvä only needs a small inline script in Magento_Theme::html/head.phtml that runs before the first Tailwind stylesheet. The actual toggle logic can then be encapsulated as a standalone Alpine.js component that persists the state in localStorage and reacts to system changes via a matchMedia listener, without requiring a page reload.
// Alpine.js component for manual dark mode toggle in a Hyvä theme,
// registered globally via Alpine.data() in default.phtml
document.addEventListener('alpine:init', () => {
Alpine.data('colorSchemeToggle', () => ({
isDark: document.documentElement.classList.contains('dark'),
init() {
// React to OS-level scheme changes when the user has no manual override
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (event) => {
if (!localStorage.getItem('color-scheme')) {
this.setScheme(event.matches);
}
});
},
toggle() {
this.setScheme(!this.isDark);
localStorage.setItem('color-scheme', this.isDark ? 'dark' : 'light');
},
setScheme(dark) {
this.isDark = dark;
document.documentElement.classList.toggle('dark', dark);
}
}));
});
8. User control: toggle, persistence, and ARIA
A color scheme toggle is itself an accessibility-relevant control and must meet the same requirements as any other interactive button. It needs an accessible name via aria-label or visible text, must be focusable and triggerable by keyboard, and its current state should be exposed via aria-pressed for screen reader users. A bare icon without a label, understandable only through visual interpretation as "sun" or "moon", does not meet this requirement.
The chosen setting should persist across page visits, typically in localStorage, so the user does not have to repeat the decision on every visit. At the same time, a "system default" state should remain available that clears the manual choice and falls back to prefers-color-scheme, for users who deliberately change their system setting situationally, for example light during the day and dark in the evening via an operating system schedule.
<!-- Accessible dark mode toggle button in a Hyvä .phtml template -->
<button
type="button"
x-data="colorSchemeToggle"
x-on:click="toggle()"
x-bind:aria-pressed="isDark.toString()"
aria-label="Toggle dark color scheme"
class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg x-show="!isDark" class="w-4 h-4" aria-hidden="true"><!-- sun icon --></svg>
<svg x-show="isDark" class="w-4 h-4" aria-hidden="true"><!-- moon icon --></svg>
<span x-text="isDark ? 'Dark' : 'Light'"></span>
</button>
9. Dark mode decisions in direct comparison
Many dark mode implementations fail not on the basic idea, but on the same recurring detail decisions. The following overview contrasts typical mistake patterns with the recommended solutions, each with the concrete accessibility benefit.
| Decision | Common mistake | Recommended approach | Benefit |
|---|---|---|---|
| Background color | #000000 | #18181b to #121212 | Less halation with astigmatism |
| Text color | #ffffff | #e4e4e7 | Less glow at character edges |
| Initial mode | Always forced light | Respect prefers-color-scheme | No friction for a deliberate system choice |
| Switch timing | After first paint via JS | Inline script before first paint | No flash of wrong theme |
| Toggle button | Icon without aria-label | aria-label + aria-pressed | Screen reader detects state |
The table shows a consistent pattern: almost every mistake stems from mechanically inverting a light design instead of treating the dark scheme as an independent design system with its own contrast check. Teams that consistently implement these five points already cover the majority of practically relevant dark mode barriers before a specialized accessibility test is even needed.
Mironsoft
Accessibility, contrast audits, and dark mode concepts for Magento and Hyvä stores
Ready to implement dark mode accessibly?
We audit existing color schemes for contrast problems in light and dark mode, recalculate the contrast ratios, and implement prefers-color-scheme cleanly, including a persistent toggle with correct ARIA markup.
Contrast audit
Check contrast ratios for light and dark mode separately
Dark mode concept
Color scheme without pure black, without halation problems
Hyvä implementation
Alpine.js toggle with persistence and aria-pressed
10. Summary
Combining dark mode and accessibility solves a problem with two opposing user groups: light-sensitive users and people with migraine benefit noticeably from a dark color scheme, while users with astigmatism can read worse due to the halation effect under strong light-dark contrast. Both groups are served by respecting prefers-color-scheme as the starting point, calculating the contrast ratio for the dark scheme independently, and replacing pure black with pure white by a dimmed gray and a slightly reduced white.
Technically, for Hyvä stores this means an inline script before the first paint against the flash of wrong theme, central CSS variables instead of scattered color values across individual templates, and an Alpine.js toggle with correct ARIA markup and localStorage persistence. Teams that plan for these points from the start save later rework and deliver a color scheme that actually works for significantly more users than a simple inversion.
Dark mode and accessibility, the essentials at a glance
Two user groups
Light-sensitive users benefit from dark mode, users with astigmatism suffer from the halation effect under strong contrast.
Recalculate contrast
WCAG minimums of 4.5:1 apply in dark mode too, 7:1 to 15:1 is recommended instead of the maximum values at #000000/#ffffff.
prefers-color-scheme
Respect the system setting, use an inline script before the first paint against a flash of wrong theme.
No pure black
#18181b instead of #000000, #e4e4e7 instead of #ffffff, reduces halation without dropping below WCAG minimums.