Dark mode is now a baseline expectation. Users toggle it in the OS, in their browsers, and increasingly in individual apps. The mistake most teams make is treating it as a feature to ship later, then shipping a naive invert that breaks contrast, flattens depth, and makes imagery look washed out. Getting it right requires rethinking your color system from the ground up.
Why a Simple Invert Fails
The most common shortcut is CSS filter: invert(1). It takes seconds to implement and looks terrible within minutes of real use. Here is what breaks:
- Photos and illustrations invert their hues. Skin tones go green. Brand photography becomes unrecognizable.
- Saturated accent colors that work on white become eye-scorching on near-black surfaces.
- Depth cues flip. Elements that appear elevated in light mode recede in dark mode, and vice versa.
- Shadow-based elevation is a light-mode concept. Shadows on dark backgrounds are nearly invisible.
The visual grammar of light and dark interfaces is fundamentally different. You are not remapping colors; you are redesigning for a different perceptual environment.
Build on Tokens, Not Hard-Coded Values
The structural prerequisite for maintainable dark mode design is a proper token layer. If your design files and codebase reference raw hex values, every color change is a manual hunt. If they reference semantic tokens, you flip a theme and the whole system responds.
The token hierarchy that works in practice:
- Primitive tokens are the raw palette:
color.gray.900,color.blue.500. These are the source of truth for every hue and shade. They never change between themes. - Semantic tokens are role-based references:
color.surface.base,color.text.primary,color.border.subtle. These point to primitives and are the only values components should consume. - Component tokens (optional) provide scoped overrides for specific components:
button.background,card.border. Useful when a component genuinely needs to deviate from the semantic default.
When you switch themes, you remap the semantic tokens to different primitives. Components never change. If you want to go deeper on how tokens fit into a broader system, the post on design tokens covers the full architecture.
Token-based theming is not about dark mode specifically. It is about making your color system intentional. Dark mode just makes the absence of tokens painful enough that teams finally do the work.
Contrast Is More Complex in the Dark
WCAG defines minimum contrast ratios of 4.5:1 for normal text and 3:1 for large text. Those numbers do not change between themes. But achieving them is a different problem in each direction.
In light mode, the challenge is usually keeping accent colors dark enough against white. In dark mode, the challenge is more layered:
Surface stacking. Dark interfaces use multiple surface levels to create elevation. A modal sits on a background; a tooltip sits on the modal. Each level needs to be distinguishable from the one below it without relying on shadows. Google’s Material Design uses a system where each elevated surface adds a small percentage of white overlay (the overlay method). A simpler approach is a five-step grayscale from your base background up to the highest-elevation surface.
Text on tinted surfaces. Pure white (#ffffff) on pure black (#000000) is technically high contrast but causes halation, a visual bleed that makes text harder to read over long passages. Off-white text on near-black backgrounds (#e2e2e2 on #121212) reduces eye strain without failing contrast checks.
State colors. Error red, success green, and warning amber all need separate dark-mode values. The saturated hues that work on white read as aggressive or unreadable on dark surfaces. Desaturate slightly and increase lightness.
Elevation Without Shadows
Light mode uses drop shadows heavily. Cards, modals, and tooltips float above the page via box-shadow. On dark backgrounds, shadows disappear or read as halos. There are two better approaches:
Surface lightness elevation. Each elevation step is a slightly lighter version of the base surface color. Assign named levels: surface.0 (background), surface.1 (cards), surface.2 (overlays), surface.3 (tooltips/popovers). Codify these as tokens. Components declare which surface level they live on; the theme file determines the actual color.
Border + background. A 1px border at 8–12% white opacity on an elevated surface, combined with a marginally lighter fill, creates clear separation without shadows. This technique works especially well for data-dense interfaces such as dashboards, tables, and modals with forms.
Avoid using both shadows and surface elevation simultaneously in dark mode. They fight each other and produce an undefined depth hierarchy.
Handling Images and Illustrations
Photography does not invert gracefully, but it does respond to other treatments.
For photos, reduce brightness slightly in dark mode and increase contrast. A CSS filter applied via the @media (prefers-color-scheme: dark) query is enough:
@media (prefers-color-scheme: dark) {
img:not([data-no-dim]) {
filter: brightness(0.88) contrast(1.05);
}
}
The data-no-dim attribute lets you exempt specific images (logos, screenshots, product photography) where you have already prepared a dark-appropriate version.
For illustrations and icons, the best outcome is separate assets: a light-mode SVG and a dark-mode SVG using <picture> with a prefers-color-scheme media query in the <source>. The engineering overhead is real, but illustration palettes rarely adapt cleanly via CSS alone.
For SVG icons used inline, set fill and stroke values to currentColor. The icon inherits text color from its parent and adapts for free.
Color Palette Decisions
A few specific color decisions that trip teams up:
Brand primaries. The brand blue that looks sharp on white may look washed-out or luminous on near-black. Test your primary brand color at its intended usage (CTA button, link text, active state) against your darkest surface. Often you need a slightly lighter or more saturated variant.
Focus rings. Keyboard focus indicators must be visible on every surface. Many teams style focus rings as a solid brand color. In dark mode, check that the ring still contrasts with the focused element’s background and the surrounding surface.
Gradients. Light-to-dark gradients need to be remapped, not inverted. A gradient from blue.200 to blue.700 in light mode might become blue.900 to blue.600 in dark mode. Gradients that reference semantic tokens automatically pick up theme-appropriate endpoints.
This overlaps significantly with building a full brand color system. The post on color theory for brands covers palette construction; the token layer described here is where that palette gets operationalized for UI.
Testing Both Themes
Shipping dark mode without a defined testing protocol produces regressions. The most common failure mode: designers review light mode thoroughly, approve dark mode as an afterthought, and the dark variant ships with broken contrast on three states nobody checked.
A minimal testing checklist:
- Run both themes through an automated contrast checker (Stark, Axe, or browser DevTools accessibility panel).
- Manually review every interactive state: default, hover, focus, active, disabled, error.
- Test form elements: inputs, checkboxes, selects, and toggles. These often pull OS defaults that look wrong in custom dark themes.
- Check charts and data visualizations. Every categorical color needs a dark-mode variant; a single palette rarely works both ways.
- Test on actual hardware in a dark physical environment. Monitor-calibrated design files lie.
- Verify
prefers-color-schememedia query behavior in the browser and, if you have a manual theme toggle, that it overrides the OS setting reliably.
The post on accessibility as a design decision is worth reading alongside this checklist. Contrast requirements are one part of a broader accessibility posture that dark mode design should not undermine.
System Preference vs. Manual Toggle
Most products should respect prefers-color-scheme by default and layer a manual override on top. The implementation decision is where to store the preference:
- CSS custom properties + a class on
<html>: fast, no flash on load, works with SSR as long as you inject the class server-side (or use a small blocking script). localStorage+ a blocking script: flexible, but introduces a small flash of unstyled content (FOUC) if not handled carefully. The script must run synchronously before the page renders.- No persistence: some interfaces (dashboards, tools) reset to system preference on reload. Acceptable when the context is clear.
If you are building on Astro, the rendering strategies post covers where the blocking script fits in the rendering lifecycle. Static sites and SSR handle this differently.
The Strynal Approach
At Strynal, dark mode is a design system question, not a feature question. When we work on UI/UX engagements, we define the token architecture before any component is built, so theme support is structural, not bolted on. Every project starts from first principles rather than a template, so the color system fits the brand rather than forcing the brand into a generic palette.
The work of building two coherent themes is the same work as building one coherent system. Teams that skip the token layer pay the cost twice: once when they ship the default theme and again when they try to add the alternate. Do it once, do it right, and both themes maintain themselves.
If you are building an interface that needs to hold up across themes and contexts, get in touch. It is exactly the kind of problem we scope carefully before we touch a single component.