DEV Community

Cover image for prefers-color-scheme doesn't ask your OS. It asks your browser.
Thomas Sweet
Thomas Sweet

Posted on

prefers-color-scheme doesn't ask your OS. It asks your browser.

My OS was in light mode. GitHub was set to sync with the system. The README's images were dark.

I checked the srcsets. I suspected GitHub's HTML sanitizer. I cleared caches I had no reason to believe were involved. Then I opened the page in a private window and the images were correct, which ruled out my markup entirely and left me with no suspects at all.

The override was in my own browser. Chrome's appearance setting was pinned to Dark, and had been for months. Not the OS, not GitHub, not the markup.

The chain

I had been thinking about dark mode as one setting. It is a chain of at least three, and each link can silently override the one above it while reporting nothing to the one below.

The colour scheme chain: OS setting flows into the browser's appearance setting, which produces prefers-color-scheme, which the page consumes; any layer can pin a value and downstream layers never know

1. The operating system has a colour scheme setting. This is the one everybody means by "follows your system".

2. The browser defaults to following the OS, and can pin its own value instead. Chrome's appearance setting has Light and Dark options that override the system for every page you visit. This layer's output is what prefers-color-scheme reports — MDN is precise about it, if you read closely: the preference comes from "an operating system setting or a user agent setting". CSS never talks to your OS. It talks to this.

3. The site can follow the browser, via light-dark() or media queries, or run its own theme control on top. GitHub does the latter: its theme preference drives the page chrome, and only agrees with your images' media queries when it is set to sync.

My machine had layer 2 pinned. Every media query on every site I visited was being told "dark" while the OS said light, and nothing anywhere indicates that an override is active. If a user has ever told you your dark mode toggle does nothing, there is a fair chance nothing was broken except this chain.

The debugging order that would have saved me two hours: check a private window first, which isolates site-level state, then walk the chain upward from the page rather than downward from the OS. The layer you are least likely to suspect is the one in the middle, because you configured it once and forgot.

The part where I had shipped the wrong claim

My README stated that the images "follow your OS colour scheme".

That is not what they do. They follow prefers-color-scheme, which usually reflects the OS and, on my own machine at that moment, did not. The claim was false on the very machine I wrote it on.

For a repository whose subject is accessibility education, naming the wrong mechanism is a defect like any other, so it got a commit. The README now says the images follow your colour-scheme preference as reported by your browser, which is usually your OS setting unless the browser overrides it. It also gained the troubleshooting line I wish I had read at the start: if a scheme toggle seems to do nothing, check your browser's own appearance setting.

Precision about mechanisms is not pedantry here. Documentation that names the wrong layer sends the next person to debug the wrong thing.

What actually works in a README

Worth stating plainly, because the constraints surprised me and the capability is better than I expected.

GitHub's sanitizer allows <picture>, which is the whole scheme-swap:

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/hub-dark.png">
  <img alt="The overview hub" src="docs/screenshots/hub-light.png">
</picture>
Enter fullscreen mode Exit fullscreen mode

And an SVG referenced as a plain image still evaluates its own internal CSS, including media queries. One file can adapt to colour scheme and gate its animation behind prefers-reduced-motion. It cannot load external resources, so fonts have to be system stacks and everything has to be self-contained, but within that boundary it is a real stylesheet.

One consequence worth thinking through: an animation inside an <img> has nowhere to put a pause button. WCAG expects moving content to be pausable, and an image cannot offer controls. So the banner's animation is finite by design. It draws, holds, replays twice, then settles permanently into its end state. Reduced-motion users get the settled state with no motion at all, through the same media query that handles the colours. When you cannot give motion an off switch, the alternative is giving it an ending.

Two sharp edges in the same file

Neither of these is about colour schemes. Both cost me hours in the same banner, and both are the kind of thing you only meet by walking into them.

Never put an angle bracket inside an SVG <style> element. I wrote a CSS comment that mentioned an <img> tag — literally, inside the stylesheet. In XML that < starts markup, so the parser silently truncated the stylesheet at that point and discarded everything after it.

The consequences were disproportionate to the typo. The banner's animation was dead in every version I had shipped. Every fix I tried was invisible, because the code I was fixing sat after the comment and never ran. And there was no error, in any console, at any point — a truncated stylesheet is not malformed, it is just short. I was debugging keyframes that the parser had never seen. The comment now says "an image", and the file carries a warning for future me.

textLength needs lengthAdjust="spacing". The banner's headline has to fit its plate on macOS, Windows and Linux system fonts, so textLength pins the width. But lengthAdjust decides what absorbs the difference: spacingAndGlyphs stretches the letterforms themselves, which came out around 25 percent in my case and reads as a strange skew rather than a font substitution. spacing flexes only the gaps and leaves the glyphs alone. Measure the natural width on your reference platform, set textLength to that, and declare lengthAdjust explicitly rather than trusting whatever you inherit — it is the difference between a banner that adapts and one that looks subtly wrong on every platform except the one you designed it on.

Go and flip a setting

The adaptive banner, the scheme-swapping screenshots and the corrected wording are all on the repo now. Change your browser's appearance setting while looking at it — not your OS. That is the demo, and if the images change while your system stays put, you have just watched layer 2 do its thing.

GitHub logo ThomasSweet / a11y-foundation

An accessibility-first styling playground — how much of accessibility the modern web platform handles natively, with little to no JavaScript. WCAG rules you can break, cutting-edge CSS showcases, tokens and cascade layers.

Built in, not bolted on — with 'bolted on' struck through

Accessible by default

CI Live site WCAG License: MIT

How much of accessibility does the modern web platform handle natively — with little to no JavaScript? This site is that question, answered as one argument in four parts: what the standard (WCAG) asks for, the craft of meeting it with modern CSS and HTML, what cutting-edge CSS makes possible next, and the proof that it holds up.

Underneath it is an accessibility-first styling foundation — SCSS mixins design tokens, and a cascade-layer architecture — where components adapt to user preferences (reduced motion, high contrast, forced colors, dark mode reduced transparency) and input capabilities (hover, touch) by default, with the cascade doing the work instead of !important.

Note

The design vote is settled. The blueprint restructure — an overview hub plus four chapter pages, wearing a technical-drawing look — won the review round and is now the live design. The previous single-page design is kept under…

The site the banner belongs to has since been rebuilt as an overview hub plus four chapter pages, making the same argument with rather more room for it. If you find a barrier anywhere in it, the issues tab is open.

Top comments (0)