May 10, 2025Mansoury6 min read

Designing Accessible Dark Mode Interfaces

Dark mode is no longer optional for enterprise products. This guide covers contrast ratios, semantic colour tokens, system preference detection, and the common pitfalls that make a dark mode feel unpolished — with code examples throughout.

Dark mode has moved from a trendy feature to a baseline user expectation in enterprise software. Users working in low-light environments, users with certain visual impairments, and users who simply prefer the aesthetic all benefit from a well-implemented dark mode. The problem is that 'dark mode' done poorly is not just visually unpleasant — it can actively harm accessibility by reducing contrast, creating optical illusions with certain colour combinations, or simply being inconsistent enough that users cannot predict how the interface will behave.

At wecopars, we have implemented dark mode across dozens of products and have developed a systematic approach that produces consistent, accessible results without requiring the design team to maintain two complete colour systems.

Semantic Colour Tokens, Not Direct Values

The foundational decision in any robust dark mode implementation is to never reference colour values directly in component styles. Instead, every colour in the codebase should be a semantic token — a named variable that describes the role of the colour rather than its value. --color-background, --color-text-primary, --color-border are semantic tokens. #1a1a2e and rgb(255, 255, 255) are not.

When you switch between light and dark mode, you are redefining the values of the semantic tokens, not changing which tokens are used. This means every component that uses semantic tokens correctly gets dark mode support automatically. Components that reference raw colour values need to be updated individually, which is why eliminating raw values from the codebase before implementing dark mode is worth the effort.

In a Tailwind CSS project, the recommended pattern is to define your colour system in CSS custom properties in your base layer, and then reference those properties in your Tailwind config. The .dark class on the html element (applied by next-themes or a similar library) redefines the custom properties to their dark values. Components use Tailwind utility classes that map to the custom properties, and they automatically adapt when the .dark class is toggled.

Contrast Ratios: The Numbers That Matter

WCAG 2.1 defines minimum contrast ratios for text legibility: 4.5:1 for normal text (below 18px or 14px bold) and 3:1 for large text. These ratios must be satisfied in both light and dark mode — a common mistake is to verify contrast only in light mode and assume that dark mode is 'obviously fine' because the text colour changed.

Dark mode introduces specific contrast challenges that light mode does not. Pure white text on a very dark background passes contrast ratio checks easily, but can cause the halation effect on OLED displays — bright text appears to bleed into surrounding dark areas, reducing readability. A slightly off-white like hsl(0, 0%, 93%) maintains sufficient contrast while significantly reducing halation.

Similarly, very dark backgrounds (below about #111) can make border and separator elements invisible in dark mode even when they are clearly visible in light mode. The solution is to use elevated surface colours for cards and panels — surfaces that are slightly lighter than the page background — rather than borders to create visual separation.

System Preference Detection and User Override

The prefers-color-scheme media query allows the browser to report the user's system-level preference. Respecting this preference by default is the right starting point — but it should be paired with a user-accessible override in the application itself. Not all users who use a system dark theme want their productivity applications to be dark.

The pattern we use with next-themes is: defaultTheme: 'system' to respect the OS preference out of the box, with a theme toggle in the application header that allows the user to override to light or dark explicitly. The user's explicit choice is stored in localStorage and takes precedence over the system preference on subsequent visits.

One subtlety to handle carefully is the flash of incorrect theme on initial page load. Server-rendered HTML is generated without access to the client's localStorage or system preference, so there is always a risk that the initially rendered theme differs from what the user expects. next-themes handles this with a script injected into the document head that reads the stored preference before the page paints, preventing the flash in most cases.

All articlesWritten by Mansoury