# Dark mode is a second theme, not an inverted one > Flip a light palette and you get a dark mode that glares and fails contrast. Build dark as its own theme on the same semantic names instead. - Source: https://flashdsm.com/blog/dark-mode-is-a-second-theme - Published: 2026-09-11 - Author: Flash DSM Team - Track: Fundamentals - Reading time: 6 min read --- ## Why inverting fails The first dark mode most teams ship is the light theme flipped: white becomes near-black, near-black becomes white, and the ramp steps swap ends. It looks right for about a minute. Then the problems show up: - **Lightness isn't symmetrical.** A muted grey like `#64748B` gives 4.76:1 on white, which passes AA for body text. Put it on a dark slate like `#0F172A` and it drops to 3.75:1, which fails. Mid-tones are exactly where a flip breaks, and mid-tones are where secondary text, placeholders and borders live. - **Maximum contrast glares.** White on pure black is 21:1. It sounds ideal and reads badly: bright text on black seems to bloom, especially for people with astigmatism. Most dark themes use an off-black and an off-white. `#E6E6E6` on `#121212` is still 15:1, far above any requirement. - **Shadows vanish.** A shadow darkens what's under it. On a near-black surface there's nothing left to darken, so your cards and menus lose their edges. - **Brand colours buzz.** A saturated blue that sits calmly on white looks like neon on black, and it often fails contrast too. `#2563EB` is 5.17:1 on white and 3.45:1 on `#0F172A`. - **Images break.** Logos with dark ink on a transparent background disappear. Run `filter: invert(1)` over a photo and you get a negative. None of this is fixed by tuning the flip. Dark needs its own decisions, made on the same names. ## Raise surfaces by lightening In a light theme, raised things cast shadows: the card sits above the page, the menu above the card. In a dark theme you raise things by making them lighter. The closer a surface is to the viewer, the lighter it gets. Material's dark theme did this with white overlays that grew more opaque with elevation. | Semantic token | Light | Dark | |---|---|---| | `--color-surface` | `#FFFFFF` | `#0F172A` | | `--color-surface-raised` | `#FFFFFF` + shadow | `#1E293B` | | `--color-surface-overlay` | `#FFFFFF` + larger shadow | `#334155` | Shadows can stay in dark mode for large overlays like modals, but don't rely on them. A 1px border one step lighter than the surface does more work than any shadow on a dark page. Each step up the ladder lowers the contrast of the text on it. That's where most dark themes fail; the numbers are below. ## Calm the brand colours In dark mode, move brand colours up the ramp: lighter, and a little less saturated. If light mode uses `--blue-600` (`#2563EB`) for links and primary buttons, dark mode usually wants something around `--blue-400` (`#60A5FA`). On `#0F172A` that lighter blue gives 7.02:1 where the original gave 3.45:1. Filled components flip their foreground too. White text on `#2563EB` is 5.17:1 in light mode. In dark mode the fill is the lighter blue, so the text on it has to go dark: `#0F172A` on `#60A5FA` is 7.02:1. That's why you want an `--color-on-primary` token and not a hard-coded `white`. If you build ramps in OKLCH, the pattern is easy to see: the dark-theme step has higher lightness and lower chroma than the light-theme one. Status colours follow the same rule. A red that works as an error message on white is usually too dark and too loud on slate. ## Contrast rules don't change The WCAG AA thresholds are the same in both themes: 4.5:1 for body text, 3:1 for large text and for UI components like input borders and focus rings. What changes is where you tend to fail. For a deeper look, see [contrast and WCAG AA in practice](/blog/contrast-wcag-aa-in-practice). Here's one muted-text token, `#94A3B8`, measured on each dark surface from the table above: | Surface | Value | Muted text contrast | AA body text | |---|---|---|---| | `--color-surface` | `#0F172A` | 6.96:1 | Pass | | `--color-surface-raised` | `#1E293B` | 5.71:1 | Pass | | `--color-surface-overlay` | `#334155` | 4.04:1 | Fail | The same token passes on the page, passes on a card, and fails inside a menu. Your primary link blue (`#60A5FA`) does the same thing: 7.02:1 on the page, 4.07:1 on the overlay. Checking text only against the page background is how this ships. Check every text token on every surface it can land on. ## Aliases point at different steps The fix for all of the above is structural. Keep one set of primitive ramps that never change per theme. Build semantic aliases on top, and let each theme point each alias at a different ramp step. Components only ever use the aliases. ```css :root { /* Primitives: one set of ramps, never themed */ --neutral-0: #FFFFFF; --neutral-100: #F1F5F9; --neutral-400: #94A3B8; --neutral-500: #64748B; --neutral-700: #334155; --neutral-800: #1E293B; --neutral-900: #0F172A; --blue-400: #60A5FA; --blue-600: #2563EB; /* Semantic aliases: light */ color-scheme: light; --color-surface: var(--neutral-0); --color-surface-raised: var(--neutral-0); --color-text: var(--neutral-900); --color-text-muted: var(--neutral-500); --color-primary: var(--blue-600); --color-on-primary: var(--neutral-0); --shadow-raised: 0 1px 3px rgb(15 23 42 / 0.12); } /* Dark, following the OS unless the user picked light */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; --color-surface: var(--neutral-900); --color-surface-raised: var(--neutral-800); --color-text: var(--neutral-100); --color-text-muted: var(--neutral-400); --color-primary: var(--blue-400); --color-on-primary: var(--neutral-900); --shadow-raised: 0 0 0 1px var(--neutral-700); } } /* Dark, chosen explicitly */ :root[data-theme="dark"] { color-scheme: dark; --color-surface: var(--neutral-900); --color-surface-raised: var(--neutral-800); --color-text: var(--neutral-100); --color-text-muted: var(--neutral-400); --color-primary: var(--blue-400); --color-on-primary: var(--neutral-900); --shadow-raised: 0 0 0 1px var(--neutral-700); } .card { background: var(--color-surface-raised); color: var(--color-text); box-shadow: var(--shadow-raised); } ``` - `:root:not([data-theme="light"])` means the OS setting wins by default, but a user who picked light in your settings stays in light. - `color-scheme` tells the browser to draw native form controls, scrollbars and date pickers in the matching theme. Leave it out and you get white checkboxes on a dark page. - The dark shadow is a 1px ring, because a blurred shadow on `#1E293B` does nothing. - Yes, the dark block is written twice. CSS can't share one declaration block between a media query and an attribute selector. A build step or a preprocessor mixin removes the repetition; the output looks like this either way. This is the same primitive-and-alias split covered in [primitive vs semantic colour tokens](/blog/primitive-vs-semantic-colour-tokens). Dark mode is where it pays for itself. ## Images and illustrations Colour tokens don't reach inside images, so plan for them separately: - **Logos and icons:** draw them as inline SVG with `fill="currentColor"`, so they take the text colour of whatever they sit in. If a logo can't do that, ship a light-ink version for dark mode. - **Photos:** never invert them. A slight dim on large photos in dark mode (`filter: brightness(0.9)`) takes the glare off without anyone noticing it happened. - **Illustrations:** build them from CSS variables if they're SVG, so they theme like everything else. If they're raster, you need a second set or a background that works in both themes. - **Product screenshots:** show dark screenshots in the dark theme if you have them. A bright white screenshot on a dark docs page is like opening the fridge at 2am. > [!TIP] > `` with `media="(prefers-color-scheme: dark)"` only follows the OS setting. If your site has its own theme toggle, swap images with your `[data-theme]` selectors instead, or the image and the page will disagree. ## Test before you ship - Every text token on every surface step it can sit on, in both themes. 4.5:1 for body text, 3:1 for large text and UI. - Focus rings on dark surfaces. A ring picked on white often disappears on slate. - Input borders and toggles, which need 3:1 against their background. - Status colours: error, warning and success text on dark surfaces and on their own tinted backgrounds. - Chart series colours against the dark background. - All three theme states: system, forced light, forced dark. Change the OS setting with the page open. - No flash of the wrong theme on load. Set `data-theme` before the first paint, not after your app boots. - A grep for hard-coded hex values in components. They're the elements that stay white when everything else goes dark. ## How Flash handles it Flash builds colour this way by default. Each hue is a primitive ramp with steps from 0 to 1000, and semantic tokens like surface, text, primary and border alias those steps separately for light and dark. You edit each side under [[Foundations > Colors]] with "Edit light mode values" and "Edit dark mode values". Text and fill pairs are checked against WCAG AA, and you can ask Ask Flash to "Check my text contrast for accessibility" whenever you change a surface.