{"id":939,"date":"2024-03-25T11:00:00","date_gmt":"2024-03-25T12:00:00","guid":{"rendered":"http:\/\/www.shtrik.me\/?p=939"},"modified":"2024-03-27T16:58:20","modified_gmt":"2024-03-27T16:58:20","slug":"setting-and-persisting-color-scheme-preferences-with-css-and-a-touch-of-javascript","status":"publish","type":"post","link":"http:\/\/www.shtrik.me\/index.php\/2024\/03\/25\/setting-and-persisting-color-scheme-preferences-with-css-and-a-touch-of-javascript\/","title":{"rendered":"Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript"},"content":{"rendered":"

Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript<\/title><\/p>\n<article>\n<header>\n<h1>Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript<\/h1>\n<address>Henry Bley-Vroman<\/address>\n<p> 2024-03-25T12:00:00+00:00<br \/>\n 2024-03-27T16:38:08+00:00<br \/>\n <\/header>\n<p>Many modern websites give users the power to set a site-specific color scheme preference. A basic implementation is straightforward with JavaScript: listen for when a user changes a checkbox or clicks a button, toggle a class (or attribute) on the <code><body><\/code> element in response, and write the styles for that class to override design with a different color scheme.<\/p>\n<p>CSS\u2019s new <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/:has\"><code>:has()<\/code> pseudo-class<\/a>, supported by major browsers since December 2023, opens many doors for front-end developers. I\u2019m especially excited about leveraging it to modify UI in response to user interaction <em>without JavaScript<\/em>. Where previously we have used JavaScript to toggle classes or attributes (or to set styles directly), we can now pair <code>:has()<\/code> selectors with HTML\u2019s native interactive elements.<\/p>\n<p>Supporting a color scheme preference, like \u201cDark Mode,\u201d is a great use case. We can use a <code><select><\/code> element anywhere that toggles color schemes based on the selected <code><option><\/code> — no JavaScript needed, save for a sprinkle to save the user\u2019s choice, which we\u2019ll get to further in.<\/p>\n<h2 id=\"respecting-system-preferences\">Respecting System Preferences<\/h2>\n<p>First, we\u2019ll support a user\u2019s system-wide color scheme preferences by adopting a \u201cLight Mode\u201d-first approach. In other words, we start with a light color scheme by default and swap it out for a dark color scheme for users who prefer it.<\/p>\n<p>The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/@media\/prefers-color-scheme\"><code>prefers-color-scheme<\/code><\/a> media feature detects the user\u2019s system preference. Wrap \u201cdark\u201d styles in a <code>prefers-color-scheme: dark<\/code> media query.<\/p>\n<pre><code class=\"language-css\">selector {\n \/* light styles *\/\n\n @media (prefers-color-scheme: dark) {\n \/* dark styles *\/\n }\n}\n<\/code><\/pre>\n<p>Next, set the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/color-scheme\"><code>color-scheme<\/code><\/a> property to match the preferred color scheme. Setting <code>color-scheme: dark<\/code> switches the browser into its built-in dark mode, which includes a black default background, white default text, \u201cdark\u201d styles for scrollbars, and other elements that are difficult to target with CSS, and more. I\u2019m using CSS variables to hint that the value is dynamic — and because I like the browser developer tools experience — but plain <code>color-scheme: light<\/code> and <code>color-scheme: dark<\/code> would work fine.<\/p>\n<pre><code class=\"language-css\">:root {\n \/* light styles here *\/\n color-scheme: var(--color-scheme, light);\n \n \/* system preference is \"dark\" *\/\n @media (prefers-color-scheme: dark) {\n --color-scheme: dark;\n \/* any additional dark styles here *\/\n }\n}\n<\/code><\/pre>\n<h2 id=\"giving-users-control\">Giving Users Control<\/h2>\n<p>Now, to support <em>overriding<\/em> the system preference, let users choose between light (default) and dark color schemes at the page level.<\/p>\n<p>HTML has native elements for handling user interactions. Using one of those controls, rather than, say, a <code><div><\/code> nest, improves the chances that assistive tech users will have a good experience. I\u2019ll use a <code><select><\/code> menu with options for \u201csystem,\u201d \u201clight,\u201d and \u201cdark.\u201d A group of <code><input type="radio"><\/code> would work, too, if you wanted the options right on the surface instead of a dropdown menu.<\/p>\n<pre><code class=\"language-html\"><select id=\"color-scheme\">\n <option value=\"system\" selected>System<\/option>\n <option value=\"light\">Light<\/option>\n <option value=\"dark\">Dark<\/option>\n<\/select>\n<\/code><\/pre>\n<p>Before CSS gained <code>:has()<\/code>, responding to the user\u2019s selected <code><option><\/code> required JavaScript, for example, setting an event listener on the <code><select><\/code> to toggle a class or attribute on <code><html><\/code> or <code><body><\/code>.<\/p>\n<p>But now that we have <code>:has()<\/code>, we can now do this with CSS alone! You\u2019ll save spending any of your performance budget on a dark mode script, plus the control will work even for users who have disabled JavaScript. And any \u201cno-JS\u201d folks on the project will be satisfied.<\/p>\n<p>What we need is a selector that applies to the page when it <code>:has()<\/code> a <code>select<\/code> menu with a particular <code>[value]:checked<\/code>. Let\u2019s translate that into CSS:<\/p>\n<pre><code class=\"language-css\">:root:has(select option[value=\"dark\"]:checked)<\/code><\/pre>\n<p>We\u2019re defaulting to a light color scheme, so it\u2019s enough to account for two possible dark color scheme scenarios:<\/p>\n<ol>\n<li>The page-level color preference is \u201csystem,\u201d and the system-level preference is \u201cdark.\u201d<\/li>\n<li>The page-level color preference is \u201cdark\u201d.<\/li>\n<\/ol>\n<p>The first one is a page-preference-aware iteration of our <code>prefers-color-scheme: dark<\/code> case. A \u201cdark\u201d system-level preference is no longer enough to warrant dark styles; we need a \u201cdark\u201d system-level preference and a \u201cfollow the system-level preference\u201d at the page-level preference. We\u2019ll wrap the <code>prefers-color-scheme<\/code> media query dark scheme styles with the <code>:has()<\/code> selector we just wrote:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n \/* light styles here *\/\n color-scheme: var(--color-scheme, light);\n \n \/* page preference is \"system\", and system preference is \"dark\" *\/\n @media (prefers-color-scheme: dark) {\n &:has(#color-scheme option[value=\"system\"]:checked) {\n --color-scheme: dark;\n \/* any additional dark styles, again *\/\n }\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>Notice that I\u2019m using <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/Nesting_selector\">CSS Nesting<\/a> in that last snippet. <a href=\"https:\/\/github.com\/web-platform-dx\/web-features\/blob\/main\/docs\/baseline.md\">Baseline 2023<\/a> has it pegged as \u201cNewly available across major browsers\u201d which means support is good, but at the time of writing, support on Android browsers not included in <a href=\"https:\/\/github.com\/web-platform-dx\/web-features\/blob\/main\/docs\/baseline.md#core-browser-set\">Baseline\u2019s core browser set<\/a> is <a href=\"https:\/\/caniuse.com\/css-nesting\">limited<\/a>. You can get the same result without nesting.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n \/* light styles *\/\n color-scheme: var(--color-scheme, light);\n \n \/* page preference is \"dark\" *\/\n &:has(#color-scheme option[value=\"dark\"]:checked) {\n --color-scheme: dark;\n \/* any additional dark styles *\/\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>For the second dark mode scenario, we\u2019ll use nearly the exact same <code>:has()<\/code> selector as we did for the first scenario, this time checking whether the \u201cdark\u201d option — rather than the \u201csystem\u201d option — is selected:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n \/* light styles *\/\n color-scheme: var(--color-scheme, light);\n \n \/* page preference is \"dark\" *\/\n &:has(#color-scheme option[value=\"dark\"]:checked) {\n --color-scheme: dark;\n \/* any additional dark styles *\/\n }\n \n \/* page preference is \"system\", and system preference is \"dark\" *\/\n @media (prefers-color-scheme: dark) {\n &:has(#color-scheme option[value=\"system\"]:checked) {\n --color-scheme: dark;\n \/* any additional dark styles, again *\/\n }\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>Now the page\u2019s styles respond to both changes in users\u2019 system settings <em>and<\/em> user interaction with the page\u2019s color preference UI — all with CSS!<\/p>\n<p>But the colors change <em>instantly<\/em>. Let\u2019s smooth the transition.<\/p>\n<h2 id=\"respecting-motion-preferences\">Respecting Motion Preferences<\/h2>\n<p>Instantaneous style changes can feel inelegant in some cases, and this is one of them. So, let\u2019s apply a CSS transition on the <code>:root<\/code> to \u201cease\u201d the switch between color schemes. (Transition styles at the <code>:root<\/code> will cascade down to the rest of the page, which may necessitate adding <code>transition: none<\/code> or other transition overrides.)<\/p>\n<p>Note that the CSS <code>color-scheme<\/code> property does not support transitions.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n transition-duration: 200ms;\n transition-property: \/* properties changed by your light\/dark styles *\/;\n}\n<\/code><\/pre>\n<\/div>\n<p>Not all users will consider the addition of a transition a welcome improvement. Querying the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/@media\/prefers-reduced-motion\"><code>prefers-reduced-motion<\/code><\/a> media feature allows us to account for a user\u2019s motion preferences. If the value is set to <code>reduce<\/code>, then we remove the <code>transition-duration<\/code> to eliminate unwanted motion.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n transition-duration: 200ms;\n transition-property: \/* properties changed by your light\/dark styles *\/;\n \n @media screen and (prefers-reduced-motion: reduce) {\n transition-duration: none;\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>Transitions can also produce poor user experiences on devices that render changes slowly, for example, ones with e-ink screens. We can extend our \u201cno motion condition\u201d media query to account for that with the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/@media\/update\"><code>update<\/code><\/a> media feature. If its value is <code>slow<\/code>, then we remove the <code>transition-duration<\/code>.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">:root {\n transition-duration: 200ms;\n transition-property: \/* properties changed by your light\/dark styles *\/;\n \n @media screen and (prefers-reduced-motion: reduce), (update: slow) {\n transition-duration: 0s;\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>Let\u2019s try out what we have so far in the following demo. Notice that, to work around <code>color-scheme<\/code>\u2019s lack of transition support, I\u2019ve explicitly styled the properties that should transition during theme changes.<\/p>\n<figure class=\"break-out\">\n<p data-height=\"480\" data-theme-id=\"light\" data-slug-hash=\"YzMVQja\" data-user=\"smashingmag\" data-default-tab=\"result\" class=\"codepen\">See the Pen [CSS-only theme switcher (requires :has()) [forked]](https:\/\/codepen.io\/smashingmag\/pen\/YzMVQja) by <a href=\"https:\/\/codepen.io\/henry\">Henry<\/a>.<\/p><figcaption>See the Pen <a href=\"https:\/\/codepen.io\/smashingmag\/pen\/YzMVQja\">CSS-only theme switcher (requires :has()) [forked]<\/a> by <a href=\"https:\/\/codepen.io\/henry\">Henry<\/a>.<\/figcaption><\/figure>\n<p>Not bad! But what happens if the user refreshes the pages or navigates to another page? The reload effectively wipes out the user\u2019s form selection, forcing the user to re-make the selection. That may be acceptable in some contexts, but it\u2019s likely to go against user expectations. Let\u2019s bring in JavaScript for a touch of progressive enhancement in the form of\u2026<\/p>\n<h2 id=\"persistence\">Persistence<\/h2>\n<p>Here\u2019s a vanilla JavaScript implementation. It\u2019s a naive starting point — the functions and variables aren\u2019t encapsulated but are instead properties on <code>window<\/code>. You\u2019ll want to adapt this in a way that fits your site\u2019s conventions, framework, library, and so on.<\/p>\n<p>When the user changes the color scheme from the <code><select><\/code> menu, we\u2019ll store the selected <code><option><\/code> value in a new <code>localStorage<\/code> item called <code>"preferredColorScheme"<\/code>. On subsequent page loads, we\u2019ll check <code>localStorage<\/code> for the <code>"preferredColorScheme"<\/code> item. If it exists, and if its value corresponds to one of the form control options, we restore the user\u2019s preference by programmatically updating the menu selection.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">\/*\n * If a color scheme preference was previously stored,\n * select the corresponding option in the color scheme preference UI\n * unless it is already selected.\n *\/\nfunction restoreColorSchemePreference() {\n const colorScheme = localStorage.getItem(colorSchemeStorageItemName);\n\n if (!colorScheme) {\n \/\/ There is no stored preference to restore\n return;\n }\n\n const option = colorSchemeSelectorEl.querySelector(`[value=${colorScheme}]`); \n\n if (!option) {\n \/\/ The stored preference has no corresponding option in the UI.\n localStorage.removeItem(colorSchemeStorageItemName);\n return;\n }\n\n if (option.selected) { \n \/\/ The stored preference's corresponding menu option is already selected\n return;\n }\n\n option.selected = true;\n}\n\n\/*\n * Store an event target's value in localStorage under colorSchemeStorageItemName\n *\/\nfunction storeColorSchemePreference({ target }) {\n const colorScheme = target.querySelector(\":checked\").value;\n localStorage.setItem(colorSchemeStorageItemName, colorScheme);\n}\n\n\/\/ The name under which the user's color scheme preference will be stored.\nconst colorSchemeStorageItemName = \"preferredColorScheme\";\n\n\/\/ The color scheme preference front-end UI.\nconst colorSchemeSelectorEl = document.querySelector(\"#color-scheme\");\n\nif (colorSchemeSelectorEl) {\n restoreColorSchemePreference();\n\n \/\/ When the user changes their color scheme preference via the UI,\n \/\/ store the new preference.\n colorSchemeSelectorEl.addEventListener(\"input\", storeColorSchemePreference);\n}\n<\/code><\/pre>\n<\/div>\n<p>Let\u2019s try that out. Open this demo (perhaps in a new window), use the menu to change the color scheme, and then refresh the page to see your preference persist:<\/p>\n<figure class=\"break-out\">\n<p data-height=\"480\" data-theme-id=\"light\" data-slug-hash=\"GRLmEXX\" data-user=\"smashingmag\" data-default-tab=\"result\" class=\"codepen\">See the Pen [CSS-only theme switcher (requires :has()) with JS persistence [forked]](https:\/\/codepen.io\/smashingmag\/pen\/GRLmEXX) by <a href=\"https:\/\/codepen.io\/henry\">Henry<\/a>.<\/p><figcaption>See the Pen <a href=\"https:\/\/codepen.io\/smashingmag\/pen\/GRLmEXX\">CSS-only theme switcher (requires :has()) with JS persistence [forked]<\/a> by <a href=\"https:\/\/codepen.io\/henry\">Henry<\/a>.<\/figcaption><\/figure>\n<p>If your system color scheme preference is \u201clight\u201d and you set the demo\u2019s color scheme to \u201cdark,\u201d you may get the light mode styles for a moment immediately after reloading the page before the dark mode styles kick in. That\u2019s because CodePen loads its own JavaScript before the demo\u2019s scripts. That is out of my control, but you can take care to improve this persistence on your projects.<\/p>\n<h2 id=\"persistence-performance-considerations\">Persistence Performance Considerations<\/h2>\n<p>Where things can get tricky is restoring the user\u2019s preference <em>immediately<\/em> after the page loads. If the color scheme preference in <code>localStorage<\/code> is different from the user\u2019s system-level color scheme preference, it\u2019s possible the user will see the system preference color scheme before the page-level preference is restored. (Users who have selected the \u201cSystem\u201d option will never get that flash; neither will those whose system settings match their selected option in the form control.)<\/p>\n<p>If your implementation is showing a <a href=\"https:\/\/css-tricks.com\/flash-of-inaccurate-color-theme-fart\/\">\u201cflash of inaccurate color theme\u201d<\/a>, where is the problem happening? Generally speaking, the earlier the scripts appear on the page, the lower the risk. The \u201cbest option\u201d for you will depend on your specific stack, of course.<\/p>\n<h2 id=\"what-about-browsers-that-don-t-support-has\">What About Browsers That Don\u2019t Support <code>:has()<\/code>?<\/h2>\n<p><a href=\"https:\/\/caniuse.com\/css-has\">All major browsers support <code>:has()<\/code> today<\/a> Lean into modern platforms if you can. But if you do need to consider legacy browsers, like Internet Explorer, there are two directions you can go: either hide or remove the color scheme picker for those browsers or make heavier use of JavaScript.<\/p>\n<p>If you consider color scheme support itself a progressive enhancement, you can entirely hide the selection UI in browsers that don\u2019t support <code>:has()<\/code>:<\/p>\n<pre><code class=\"language-css\">@supports not selector(:has(body)) {\n @media (prefers-color-scheme: dark) {\n :root {\n \/* dark styles here *\/\n }\n }\n\n #color-scheme {\n display: none;\n }\n}\n<\/code><\/pre>\n<p>Otherwise, you\u2019ll need to rely on a JavaScript solution not only for persistence but for the core functionality. Go back to that traditional event listener toggling a class or attribute.<\/p>\n<p>The CSS-Tricks \u201c<a href=\"https:\/\/css-tricks.com\/a-complete-guide-to-dark-mode-on-the-web\/\">Complete Guide to Dark Mode<\/a>\u201d details several alternative approaches that you might consider as well when working on the legacy side of things.<\/p>\n<div class=\"signature\">\n <img src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Smashing Editorial\" width=\"35\" height=\"46\" loading=\"lazy\" class=\"lazyload\" data-src=\"https:\/\/www.smashingmagazine.com\/images\/logo\/logo--red.png\"><br \/>\n <span>(gg, yk)<\/span>\n<\/div>\n<\/article>\n","protected":false},"excerpt":{"rendered":"<p>Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript Henry Bley-Vroman 2024-03-25T12:00:00+00:00 2024-03-27T16:38:08+00:00...<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[14],"tags":[],"_links":{"self":[{"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/posts\/939"}],"collection":[{"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/comments?post=939"}],"version-history":[{"count":1,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/posts\/939\/revisions"}],"predecessor-version":[{"id":940,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/posts\/939\/revisions\/940"}],"wp:attachment":[{"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/media?parent=939"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/categories?post=939"},{"taxonomy":"post_tag","embeddable":true,"href":"http:\/\/www.shtrik.me\/index.php\/wp-json\/wp\/v2\/tags?post=939"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}