Dark Mode CSS: Fixing color-scheme and Contrast Issues
Dark mode goes wrong in a handful of recognisable ways: white input boxes on a dark page, dark text on a dark background, a logo that disappears, a white flash before a dark page appears, or a theme toggle that forgets the visitor's choice.
Most of these come from a site that half-supports dark mode, changing some colours while the browser still assumes a light page. This guide explains how browsers decide which scheme to use, then shows how to declare your schemes properly, organise colours so a dark theme is one set of values rather than hundreds of overrides, and check that everything stays readable.
How the browser decides between light and dark
Three separate things are involved, and dark mode problems usually come from one of them being missing or out of step with the others.
- The visitor's preference. The operating system or browser tells the page whether
the visitor prefers a light or dark appearance. CSS reads this with the
prefers-color-schememedia feature. - The page's declaration. The
color-schemeproperty, or a<meta name="color-scheme">tag, tells the browser which schemes the page is designed for. Without it, the browser assumes the page is light only. - Your colours. The actual background, text, border and accent colours you set in CSS.
The declaration matters more than people expect. When a page declares that it supports dark, the browser switches its own built-in styling to dark as well: the default page background, text inputs, select menus, checkboxes, scrollbars and the default link colours. If you set a dark background yourself but never declare the scheme, the browser still draws light form controls and scrollbars, which is where the familiar white input boxes on a dark page come from.
| Symptom | Usual cause |
|---|---|
| White input boxes and scrollbars on a dark page | Dark colours set, but no color-scheme declaration |
| Dark text on a dark background | Some text colours hard-coded, so they were not included in the dark theme |
| White flash before a dark page appears | Scheme only applied by a stylesheet or script that arrives after the first paint |
| Logo or diagram disappears | Transparent image with dark artwork, and no dark version |
| Toggle choice lost on the next page | Choice not stored, or applied too late in the page load |
| Embedded widget shows a white box | The frame's scheme differs from the page, so the browser gives it an opaque background |
| Shadows and dividers vanish | Dark shadows on a dark surface; depth needs lighter surfaces or borders instead |
Testing both modes without changing your computer
You do not need to switch your whole operating system back and forth. In Chrome or Edge, open DevTools, open the command menu (Ctrl+Shift+P, or Cmd+Shift+P on a Mac), type "Rendering", and in the Rendering panel set Emulate CSS media feature prefers-color-scheme to dark or light. Firefox has buttons for the same thing in the Inspector's rules view. Then walk through the pages that matter:
- Home, a content page, a product or service page, the contact form and any checkout or booking step.
- Every form control: text boxes, drop-downs, checkboxes, radio buttons, date pickers and file inputs.
- Hover, focus and active states for links and buttons, and any error or success messages.
- Menus, modals, cookie banners, tables, code blocks and embedded maps or videos.
Also test on a real phone with the system dark theme switched on, and reload with the cache disabled on a throttled connection. The flash of a light page before dark styles arrive often only shows when the network is slow.
The CSS Checker flags dark-mode issues alongside its validation, render-blocking, unused-CSS, specificity, prefix and size checks, so run it before and after to see what it reports for your stylesheets.
The fix, step by step
Step 1
Test the site in both colour schemes
Use the Rendering panel in Chrome or Edge DevTools to emulate a dark preference, then go through your main templates, forms, menus and messages, writing down every unreadable element, white box and missing image. Repeat in light mode to be sure nothing regressed there. Run the CSS Checker on the same pages. This list becomes your checklist, and it shows whether you need full dark support or should declare light only for now.
Step 2
Declare which schemes the site supports
If the site supports both, add color-scheme: light dark to the :root
rule and a matching meta tag in the head, so the browser knows before your stylesheet arrives. That switches
form controls, scrollbars and the default canvas to match. If you are not ready to support dark properly,
declare light only, which is far better than a half-dark page with unreadable parts. Either
way, be explicit rather than leaving the browser to guess.
Step 3
Move hard-coded colours into custom properties
Search your stylesheets for hex, rgb and hsl values and named colours, and replace
each one with a custom property named for its role, such as --surface, --text,
--text-muted, --border and --accent. Aim for a short list of roles
rather than one variable per colour you happen to use. Check inline colours in templates and colours
set by JavaScript too, because those are the ones most often missed in a dark theme.
Step 4
Give every colour token a dark value
Redefine each role inside a prefers-color-scheme: dark media query, or use
the light-dark() function to give both values in one place, which works in current versions
of all major browsers. Do not simply invert colours: use a very dark grey rather than pure black for
surfaces, lighten muted text so it stays readable, and reduce the saturation of bright accents, which
can look harsh against dark backgrounds.
Step 5
Fix logos, images and icons
For
logos and diagrams with transparent backgrounds, provide a dark-mode version and swap it with a picture
element whose source has a prefers-color-scheme: dark media condition. Make inline SVG icons
use currentColor so they follow the text colour automatically. Photographs normally need
no change, although a very bright image can be toned down slightly in dark mode with a gentle brightness
filter.
Step 6
Add a manual toggle without a flash
If
you offer a switch, store the visitor's choice in localStorage and apply it with a tiny
script at the very top of the head, before any stylesheet, by setting a data-theme attribute
and the color-scheme on the root element. Because it runs before the first paint, the page
appears in the right scheme straight away. Offer three options, light, dark and system, and fall back
to the system preference when nothing is stored.
Step 7
Check contrast and focus states in both modes
Measure text contrast in each scheme with a contrast checker: WCAG 2.2 asks for at least 4.5 to 1 for normal text and 3 to 1 for large text, and 3 to 1 for the visual boundaries of form controls and focus indicators. Pay attention to muted text, placeholders, disabled buttons and links, which often fail in dark mode. Then re-run the CSS Checker and repeat your test list from step one.
A complete, minimal implementation
This setup for a fictional cafรฉ, Juniper Lane Coffee, follows the system preference by default, lets visitors override it, and avoids a light flash on dark pages.
In the head, before any stylesheet
<meta name="color-scheme" content="light dark">
<script>
(function () {
try {
var t = localStorage.getItem('theme');
if (t === 'light' || t === 'dark') {
document.documentElement.dataset.theme = t;
document.documentElement.style.colorScheme = t;
}
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/css/site.css">
In the stylesheet
:root {
color-scheme: light dark;
--surface: light-dark(#ffffff, #16181d);
--surface-2: light-dark(#f4f1ec, #22252c);
--text: light-dark(#1f2328, #e6e6e3);
--text-muted: light-dark(#5b6168, #a9adb3);
--border: light-dark(#d9d4cc, #3a3e46);
--accent: light-dark(#8a3b12, #f0a36b);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
body { background: var(--surface); color: var(--text); }
.card { background: var(--surface-2); border: 1px solid var(--border); }
a { color: var(--accent); }
input, select, textarea { accent-color: var(--accent); }
Because light-dark() picks its value from the element's used colour scheme, setting color-scheme
on the root is all the toggle needs to do; every token follows. If you must support browsers older than
the ones that introduced light-dark(), define the light values on :root and
repeat the dark values inside a prefers-color-scheme: dark media query and a [data-theme="dark"]
rule instead.
Swapping a logo
<picture>
<source srcset="/img/logo-light-on-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/img/logo.svg" alt="Juniper Lane Coffee" width="180" height="48">
</picture>
picture media condition follows the system preference, not your
toggle. If you offer a manual switch, use an inline SVG with currentColor for the logo,
or swap the image in CSS based on the data-theme attribute, so the logo follows the visitor's
choice.Edge cases
- Embedded frames. When an iframe's colour scheme differs from the page around it, the browser gives the frame an opaque background so its content stays readable. That is why a light-only booking or map widget shows as a white rectangle on a dark page. Check the provider's options for a dark theme parameter.
- Automatic darkening. Some browsers can darken light-only pages automatically. Results vary and can make brand colours look wrong. Declaring your supported schemes properly is the reliable way to keep control of how the page looks.
- Browser toolbar colour. A
theme-colormeta tag can take amediaattribute, so you can give one colour for light and another for dark in browsers that support it. - Charts and maps. Chart libraries and map tiles have their own colours. Pass them your custom property values, or choose a dark style, rather than leaving a bright white chart in the middle of a dark page.
- Printing. Visitors who print a page in dark mode rarely want a black page. Reset to light colours in a print media query.
- Themes and builders. WordPress themes, Shopify colour schemes and page builders may apply colours per section. Change them at the theme level rather than overriding each block. See the WordPress and Shopify CSS guides.
Keeping dark mode working
- Ban raw colour values outside the token definitions with a Stylelint rule, so new components cannot bypass the theme.
- Add dark-mode screenshots to your visual checks, or at least test new pages in both schemes before they go live.
- Keep the token list short and named by role, so a designer can adjust the whole dark theme in one place.
- Re-run the CSS Checker after theme or plugin updates, which often bring in hard-coded colours.
- Check contrast whenever brand colours change; a new accent colour that works on white may fail on dark grey.
For accessibility checks beyond colour, see the CSS Checker Guide and the other fixes in the CSS Checker Fixes hub.
1. Do I have to support dark mode on my website?
No. There is no rule saying a site must offer a dark theme, and search engines do not rank pages on it. What matters is that the page is readable for everyone. A light-only site that declares itself light only is perfectly acceptable. The problems come from half-supported dark mode, where some colours change and others do not, leaving unreadable text or white boxes. If you support dark mode, support it fully and test it; otherwise declare light only.
2. What does the color-scheme property actually do?
It tells the browser which colour schemes your page is designed for. With color-scheme set to light dark, the browser adapts its own built-in styling to the visitor's preference: the default page background and text colour, form controls, scrollbars and default link colours. It also decides which value the light-dark() function returns. It does not change the colours you set yourself. Use the property on the root element and a matching meta tag in the head, so it applies before your stylesheet loads.
3. Why are my form fields white in dark mode?
Because the browser still thinks the page is light. Form controls such as text inputs, drop-downs and checkboxes are drawn with the browser's own styles, which follow the page's declared colour scheme, not the background colour you set. Add color-scheme: light dark to the root element and a color-scheme meta tag, and the controls switch to a dark appearance automatically. If you have styled the controls yourself, give them colours from your theme's custom properties as well.
4. How do I stop a white flash when the page loads in dark mode?
Make sure the browser knows the page is dark before the first paint. Put the color-scheme meta tag near the top of the head, so the default canvas is dark even before your stylesheet arrives. If you have a manual toggle, apply the stored choice with a very small script in the head, before any stylesheet, rather than in a script at the end of the page. Test on a slow connection, where the flash is most visible.
5. Should I use light-dark() or a prefers-color-scheme media query?
Both work, and they can be combined. The light-dark() function keeps each colour's light and dark values together in one declaration, and it follows the used colour scheme, which makes a manual toggle simple because you only change color-scheme on the root. It is supported in current versions of all major browsers. A media query approach works in older browsers too, but needs extra rules to support a toggle. For most new work, light-dark() on custom properties is the tidiest option.
6. What contrast do I need in dark mode?
The same as in light mode. WCAG 2.2 asks for a contrast ratio of at least 4.5 to 1 for normal body text, 3 to 1 for large text, and 3 to 1 for the visual boundaries of form controls and focus indicators. Dark themes often fail on muted grey text, placeholder text, disabled buttons and thin borders. Measure each colour pairing in both schemes with a contrast checker rather than judging by eye, because screens and eyesight vary a great deal.
7. Why does my logo disappear in dark mode?
Most likely it is a transparent image with dark artwork, so on a dark background there is nothing to see. Provide a light version of the logo for dark mode and swap it with a picture element that uses a prefers-color-scheme media condition, or use an inline SVG whose colours come from currentColor or your custom properties. The SVG approach also works with a manual theme toggle, which the picture element does not follow, since it only reads the system preference.
The CSS Checker flags dark-mode issues alongside CSS validation against W3C standards, render-blocking and unused rules, specificity issues, prefix gaps and stylesheet size, so you can see what to fix first.
Open CSS Checker Fixes โ