Setting And Persisting Color Scheme Preferences With CSS And A\u00a0\u201cTouch\u201d\u00a0Of JavaScript<\/h1>\nHenry Bley-Vroman<\/address>\n 2024-03-25T12:00:00+00:00
\n 2024-03-27T17:36:37+00:00
\n <\/header>\n
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 <body><\/code> element in response, and write the styles for that class to override design with a different color scheme.<\/p>\nCSS\u2019s new :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 without JavaScript<\/em>. Where previously we have used JavaScript to toggle classes or attributes (or to set styles directly), we can now pair :has()<\/code> selectors with HTML\u2019s native interactive elements.<\/p>\nSupporting a color scheme preference, like \u201cDark Mode,\u201d is a great use case. We can use a <select><\/code> element anywhere that toggles color schemes based on the selected <option><\/code> — no JavaScript needed, save for a sprinkle to save the user\u2019s choice, which we\u2019ll get to further in.<\/p>\nRespecting System Preferences<\/h2>\n
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
The prefers-color-scheme<\/code><\/a> media feature detects the user\u2019s system preference. Wrap \u201cdark\u201d styles in a prefers-color-scheme: dark<\/code> media query.<\/p>\nselector {\n \/* light styles *\/\n\n @media (prefers-color-scheme: dark) {\n \/* dark styles *\/\n }\n}\n<\/code><\/pre>\nNext, set the color-scheme<\/code><\/a> property to match the preferred color scheme. Setting 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 color-scheme: light<\/code> and color-scheme: dark<\/code> would work fine.<\/p>\n: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>\nGiving Users Control<\/h2>\n
Now, to support overriding<\/em> the system preference, let users choose between light (default) and dark color schemes at the page level.<\/p>\nHTML has native elements for handling user interactions. Using one of those controls, rather than, say, a <div><\/code> nest, improves the chances that assistive tech users will have a good experience. I\u2019ll use a <select><\/code> menu with options for \u201csystem,\u201d \u201clight,\u201d and \u201cdark.\u201d A group of <input type="radio"><\/code> would work, too, if you wanted the options right on the surface instead of a dropdown menu.<\/p>\n<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>\nBefore CSS gained :has()<\/code>, responding to the user\u2019s selected <option><\/code> required JavaScript, for example, setting an event listener on the <select><\/code> to toggle a class or attribute on <html><\/code> or <body><\/code>.<\/p>\nBut now that we have :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>\nWhat we need is a selector that applies to the page when it :has()<\/code> a select<\/code> menu with a particular [value]:checked<\/code>. Let\u2019s translate that into CSS:<\/p>\n:root:has(select option[value=\"dark\"]:checked)<\/code><\/pre>\nWe\u2019re defaulting to a light color scheme, so it\u2019s enough to account for two possible dark color scheme scenarios:<\/p>\n
\n- The page-level color preference is \u201csystem,\u201d and the system-level preference is \u201cdark.\u201d<\/li>\n
- The page-level color preference is \u201cdark\u201d.<\/li>\n<\/ol>\n
The first one is a page-preference-aware iteration of our 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 prefers-color-scheme<\/code> media query dark scheme styles with the :has()<\/code> selector we just wrote:<\/p>\n\n: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>\nNotice that I\u2019m using CSS Nesting<\/a> in that last snippet. 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 Baseline\u2019s core browser set<\/a> is limited<\/a>. You can get the same result without nesting.<\/p>\n\n: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>\nFor the second dark mode scenario, we\u2019ll use nearly the exact same :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\n: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>\nNow the page\u2019s styles respond to both changes in users\u2019 system settings and<\/em> user interaction with the page\u2019s color preference UI — all with CSS!<\/p>\nBut the colors change instantly<\/em>. Let\u2019s smooth the transition.<\/p>\nRespecting Motion Preferences<\/h2>\n
Instantaneous style changes can feel inelegant in some cases, and this is one of them. So, let\u2019s apply a CSS transition on the :root<\/code> to \u201cease\u201d the switch between color schemes. (Transition styles at the :root<\/code> will cascade down to the rest of the page, which may necessitate adding transition: none<\/code> or other transition overrides.)<\/p>\nNote that the CSS color-scheme<\/code> property does not support transitions.<\/p>\n\n:root {\n transition-duration: 200ms;\n transition-property: \/* properties changed by your light\/dark styles *\/;\n}\n<\/code><\/pre>\n<\/div>\nNot all users will consider the addition of a transition a welcome improvement. Querying the prefers-reduced-motion<\/code><\/a> media feature allows us to account for a user\u2019s motion preferences. If the value is set to reduce<\/code>, then we remove the transition-duration<\/code> to eliminate unwanted motion.<\/p>\n\n: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>\nTransitions 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 update<\/code><\/a> media feature. If its value is slow<\/code>, then we remove the transition-duration<\/code>.<\/p>\n\n: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>\nLet\u2019s try out what we have so far in the following demo. Notice that, to work around color-scheme<\/code>\u2019s lack of transition support, I\u2019ve explicitly styled the properties that should transition during theme changes.<\/p>\n
\n 2024-03-27T17:36:37+00:00
\n <\/header>\n
<body><\/code> element in response, and write the styles for that class to override design with a different color scheme.<\/p>\nCSS\u2019s new :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 without JavaScript<\/em>. Where previously we have used JavaScript to toggle classes or attributes (or to set styles directly), we can now pair :has()<\/code> selectors with HTML\u2019s native interactive elements.<\/p>\nSupporting a color scheme preference, like \u201cDark Mode,\u201d is a great use case. We can use a <select><\/code> element anywhere that toggles color schemes based on the selected <option><\/code> — no JavaScript needed, save for a sprinkle to save the user\u2019s choice, which we\u2019ll get to further in.<\/p>\nRespecting System Preferences<\/h2>\n
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
The prefers-color-scheme<\/code><\/a> media feature detects the user\u2019s system preference. Wrap \u201cdark\u201d styles in a prefers-color-scheme: dark<\/code> media query.<\/p>\nselector {\n \/* light styles *\/\n\n @media (prefers-color-scheme: dark) {\n \/* dark styles *\/\n }\n}\n<\/code><\/pre>\nNext, set the color-scheme<\/code><\/a> property to match the preferred color scheme. Setting 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 color-scheme: light<\/code> and color-scheme: dark<\/code> would work fine.<\/p>\n: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>\nGiving Users Control<\/h2>\n
Now, to support overriding<\/em> the system preference, let users choose between light (default) and dark color schemes at the page level.<\/p>\nHTML has native elements for handling user interactions. Using one of those controls, rather than, say, a <div><\/code> nest, improves the chances that assistive tech users will have a good experience. I\u2019ll use a <select><\/code> menu with options for \u201csystem,\u201d \u201clight,\u201d and \u201cdark.\u201d A group of <input type="radio"><\/code> would work, too, if you wanted the options right on the surface instead of a dropdown menu.<\/p>\n<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>\nBefore CSS gained :has()<\/code>, responding to the user\u2019s selected <option><\/code> required JavaScript, for example, setting an event listener on the <select><\/code> to toggle a class or attribute on <html><\/code> or <body><\/code>.<\/p>\nBut now that we have :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>\nWhat we need is a selector that applies to the page when it :has()<\/code> a select<\/code> menu with a particular [value]:checked<\/code>. Let\u2019s translate that into CSS:<\/p>\n:root:has(select option[value=\"dark\"]:checked)<\/code><\/pre>\nWe\u2019re defaulting to a light color scheme, so it\u2019s enough to account for two possible dark color scheme scenarios:<\/p>\n
\n- The page-level color preference is \u201csystem,\u201d and the system-level preference is \u201cdark.\u201d<\/li>\n
- The page-level color preference is \u201cdark\u201d.<\/li>\n<\/ol>\n
The first one is a page-preference-aware iteration of our 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 prefers-color-scheme<\/code> media query dark scheme styles with the :has()<\/code> selector we just wrote:<\/p>\n\n: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>\nNotice that I\u2019m using CSS Nesting<\/a> in that last snippet. 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 Baseline\u2019s core browser set<\/a> is limited<\/a>. You can get the same result without nesting.<\/p>\n\n: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>\nFor the second dark mode scenario, we\u2019ll use nearly the exact same :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\n: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>\nNow the page\u2019s styles respond to both changes in users\u2019 system settings and<\/em> user interaction with the page\u2019s color preference UI — all with CSS!<\/p>\nBut the colors change instantly<\/em>. Let\u2019s smooth the transition.<\/p>\nRespecting Motion Preferences<\/h2>\n
Instantaneous style changes can feel inelegant in some cases, and this is one of them. So, let\u2019s apply a CSS transition on the :root<\/code> to \u201cease\u201d the switch between color schemes. (Transition styles at the :root<\/code> will cascade down to the rest of the page, which may necessitate adding transition: none<\/code> or other transition overrides.)<\/p>\nNote that the CSS color-scheme<\/code> property does not support transitions.<\/p>\n\n:root {\n transition-duration: 200ms;\n transition-property: \/* properties changed by your light\/dark styles *\/;\n}\n<\/code><\/pre>\n<\/div>\nNot all users will consider the addition of a transition a welcome improvement. Querying the prefers-reduced-motion<\/code><\/a> media feature allows us to account for a user\u2019s motion preferences. If the value is set to reduce<\/code>, then we remove the transition-duration<\/code> to eliminate unwanted motion.<\/p>\n\n: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>\nTransitions 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 update<\/code><\/a> media feature. If its value is slow<\/code>, then we remove the transition-duration<\/code>.<\/p>\n\n: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>\nLet\u2019s try out what we have so far in the following demo. Notice that, to work around color-scheme<\/code>\u2019s lack of transition support, I\u2019ve explicitly styled the properties that should transition during theme changes.<\/p>\n