Why dark mode is now table stakes #
Dark mode used to be a developer novelty. Terminal windows, code editors, that was the whole territory. Then operating systems made it a first-class setting, apps followed, and users started expecting the choice everywhere.
The motivations mix. Some people read at night and want less glare. Some prefer the look. Some get real battery savings on OLED screens, where dark pixels draw less power. Material's Android theming docs list exactly these benefits: power conservation on OLED, reduced eye strain, and better visibility in low light, as stated in the Material dark theme docs (theming guide).
But good dark mode is not inverted light mode. Slapping a black background behind white text produces something harsh and flat. Real dark theme work means rethinking surfaces, depth, color, and contrast as one system.
Think of it like stage lighting. You do not light a night scene by turning the houselights backwards. You relight every surface for the dark.
| Choice | Light approach | Dark approach |
|---|---|---|
| Surface | White to light gray | Dark gray around 121212 not pure black |
| Elevation | Shadow defines height | Lighter surface defines height |
| Accent | Saturated color pops | Desaturated color stays legible |
| Text contrast | Dark on light at 4.5 to 1 | Light on dark at 4.5 to 1 |
| Toggle | None | Respect system setting plus manual override |
Why start from gray not black? #
The most common dark mode mistake is also the easiest to fix. Pure black (#000000) as the base surface creates three problems at once.
First, maximum contrast strains eyes. White text on pure black is a 21 to 1 ratio, far past what comfortable reading needs. Long sessions feel aggressive rather than calm.
Second, depth dies. Elevation needs room to move, and from pure black there is nowhere darker for shadows to go. Every card, sheet, and dialog flattens onto the same void.
Third, motion smears on OLED. Black pixels switch fully off, and white elements sliding across them leave visible trails as pixels wake back up.
Material guidance answers this directly: dark themes should use dark gray rather than black as the primary surface color, because gray surfaces express a wider range of color, elevation, and depth, and light text on dark gray strains eyes less than light text on black, as stated in the Material spec (dark theme spec). The recommended surface color is #121212.
In prompts, name the value. "Base background #121212" beats "dark background" every time, because the model stops guessing and starts matching.
How does elevation work in dark mode? #
Light interfaces show depth with shadows. A card floats because it throws shade on what sits below. That physics breaks in the dark. A dark shadow on a dark surface is nearly invisible, so the cue vanishes.
Dark themes replace shadow with lightness. Higher surfaces get lighter. The base sits darkest, cards render a step lighter, dialogs lighter still. Material implements this as white overlays whose transparency grows with elevation, from 0% at the lowest level to 16% at the highest, as stated in the Material spec (dark theme spec). The Android theming docs explain why: shadows lose contrast against dark backgrounds, so surfaces become lighter and more colorful as they approach the implied light source, as stated in the Material dark theme docs (theming guide).
A practical ladder looks like this. Base at #121212. Cards around #1E1E1E. Dialogs and sheets around #2C2C2C. Borders do quiet work too: a 1px line of white at 10% opacity separates surfaces that lightness alone cannot.
/* Surface ladder for a dark theme */
:root {
--surface-base: #121212; /* page background */
--surface-card: #1e1e1e; /* cards, panels */
--surface-dialog: #2c2c2c; /* modals, sheets */
--border-subtle: rgb(255 255 255 / 0.1);
}Short rule. Lighter means closer. If two surfaces share a value, they read as one plane, so promote one of them.
Why desaturate every accent? #
Colors tuned for white backgrounds misbehave on dark ones. A saturated blue that looks confident on white starts to glow against charcoal. It vibrates, pulls focus, and tires eyes.
The fix is to lighten and mute accents for dark surfaces. Where light mode uses a Blue 500, dark mode reaches for Blue 300 or 200, same family, less shout. Material guidance says primary colors should be desaturated so body text set in them still passes accessibility contrast at every elevation level, as stated in the Material spec (dark theme spec).
Apply this across the system, to more than buttons. Links, focus rings, chart series, success and error states, badges. Each needs a dark variant. Error red deserves care in particular: pure saturated red on dark gray looks alarming at small sizes, so shift toward a softer rose that still reads as danger.
One more trap. Never use bright colors for large surfaces. A full-width saturated banner emits too much light and destroys the calm the theme promises. Keep brights small and purposeful: an icon here, a link there, a single call to action per view.
Contrast is a number, not a feeling #
Eyes lie. Ratios do not. WCAG success criterion 1.4.3 requires text and images of text to hold a contrast ratio of at least 4.5 to 1, with large text allowed 3 to 1, as stated by W3C (contrast minimum). Large means at least 18 point, or 14 point bold. Everything else plays by the stricter number.
Gray body text on dark gray is where themes quietly fail. A medium gray that looks refined to a designer with a calibrated monitor can slide under 4.5 to 1 for everyone else. Test every text and surface pair with a contrast checker instead of squinting. Disabled controls and decorative text get exemptions, but body copy, labels, and placeholders do not.
Images need attention of a different kind. Transparent logos drawn for light backgrounds vanish on dark ones. Dark text marks with no backing disappear entirely. Ship dark variants of key marks, or set logos on subtle filled chips that survive either theme. Photography usually needs no change, though a slight brightness trim can keep bright photos from flashing against surrounding surfaces.
Focus states close the loop. Keyboard users need a visible marker, and thin blue outlines tuned for white pages can dissolve on charcoal. A soft glow ring reads well in the dark:
:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgb(99 102 241 / 0.45);
}Visible without shouting. That is the whole aesthetic in one declaration.
Try it: Color Contrast Checker
How do you wire the toggle properly? #
A theme switch sounds trivial and breaks in the same three places every time. Handle all three and the feature feels native.
First, respect the operating system. Browsers expose prefers-color-scheme, and Tailwind builds its dark variant on that media feature by default, as stated in the Tailwind docs (dark mode). Ship that behavior first so the site matches the device before the user touches anything:
<div class="bg-white text-gray-900 dark:bg-[#121212] dark:text-gray-100">
<!-- light by default, dark when the OS asks -->
</div>Second, offer a manual override. Some people run a dark OS with one stubborn light app, or the reverse. Tailwind supports selector-driven themes through a custom variant, so a dark class on the root element takes over from the media query, as stated in the Tailwind docs (dark mode):
/* app.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));Persist the choice in localStorage and apply it in an inline snippet in head before first paint. A theme that flashes white on every load in a dark room is a bug, not a preference.
Third, cover images and third-party embeds. A glowing white map widget or video frame inside a dark page ruins the effect the tokens worked to build. Where embeds offer dark styles, request them when the theme flips. Where they do not, contain them inside surfaces that make the boundary look deliberate.
Android developers get platform help here: DayNight themes and night-qualified resources switch automatically with the system setting, as described in the Android docs (implement dark theme). The web has no such runtime, so your toggle code is the runtime. Write it once, reuse it everywhere.
Test on hardware as well as your monitor. Calibrated desktop displays forgive sins that budget phones punish, so check the theme on a mid-range device in a dim room. W3C maintains a list of contrast tools that compute ratios rather than trusting eyes, as noted in the contrast understanding document (contrast minimum). Run every text and surface pair through one before calling the theme done.
How do you prompt dark mode into BYOB? #
BYOB renders what you describe, so describe the system instead of saying "dark mode please." Name surfaces, elevation steps, accent variants, and contrast targets. Specific prompts produce coherent themes. Vague prompts produce black rectangles.
A prompt that works:
"Style the feature cards with translucent dark fill rgba(30, 30, 30, 0.8), 1px border rgba(255, 255, 255, 0.1), 12px backdrop blur. Headings #EDEDED, body text #A1A1AA. Hero background gradients from #0F0F0F to #1A1A1A. Primary buttons #3B82F6 at rest brightening to #60A5FA on hover with a 200ms transition."
Then verify like a skeptic. Run the contrast checker on body text, secondary text, placeholders, and button labels. Toggle the theme five times and watch for flashes. Open the site on a phone in a dark room, because that is where your users will judge you.
Three patterns worth stealing. GitHub-style near-black gray (#0d1117) suits content-dense pages with code. Media apps can go nearer true black since video fills the frame. Gradient-washed dark heroes suit marketing pages that need mood without glare. Pick one personality per site. Themes with three moods have none.
What are the trade-offs? #
A real dark theme is a second design system, smaller than the first but not free.
| Where dark mode wins | Where it costs |
|---|---|
| Gray surfaces from specs like Material (https://m2.material.io/design/color/dark-theme.html) keep elevation readable where pure black flattens everything | Every surface, border, and accent now has two values to maintain and review |
| Desaturated accents plus measured contrast, 4.5 to 1 for body and 3 to 1 for large text (https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html), keep night reading comfortable | Contrast checks run per pair, and Tailwind variants (https://tailwindcss.com/docs/dark-mode) double the visual QA matrix |
| System setting plus manual override (https://developer.android.com/develop/ui/views/theming/darktheme) meets users where they already chose | Toggles, persistence, and flash free switching are runtime code you now own |
Pick the alternative, one light theme, for internal tools with few users and no night use. Ship dark when users expect the choice or read after hours, and verify on a mid range phone in a dim room before calling it done.
What we learned building this #
Our theme toggle flips between dark and light themes and persists the choice. The UI uses theme tokens with dark gray surfaces, not pure black, so shadows and elevation still read. Contrast guidance aligns with the built-in contrast checker which validates 4.5 to 1 for body and 3 to 1 for large text.
Who this is for (and who should skip it) #
This fits makers shipping apps or marketing sites where users expect a theme choice and where readability plus contrast matters. If you build in BYOB and pair it with the contrast checker you can ship a dark theme that still passes WCAG.
Skip a deep dark system for an internal tool with few users and one theme. Ship light first then add dark when users ask or when night use is common.
- Best for developers adding a readable dark theme that passes contrast checks.
- Best for designers tuning gray based surfaces and muted accents.
- Best for startups shipping apps where users expect a theme choice.
Ship the dark #
Dark mode done well feels premium because restraint reads as confidence. Fewer brights, deliberate lightness steps, text that never makes anyone squint.
Build the ladder, mute the accents, check the ratios, wire the toggle. Then turn the lights off and look at what you made. If it glows where it should whisper, you know exactly which token to fix.