đ¨ Theme Picker
Introduction
When first creating this site in October 2020, introducing themes was always part of the future roadmap. As a result, I've tried my best to consider reusability, scalability, and flexibility when crafting styles, with CSS custom properties at the forefront. Depending on the size and complexity of your frontend ecosystem, including how well styles are centralized for reusable distribution across components, stylesheets, or applications, the tech debt effort to migrate over to custom properties will of course vary.
Backtracking to update everything may seem a little daunting at first. However, if you're seriously considering themes in any personal projects, or perhaps it's come up in a work meeting recently, I'd strongly advise taking some time to audit what you've got. CSS custom properties are your friend and will make theming much simpler.
I'm aware of the approach to set the theme based on the OS preferences, but I couldn't decide on a nice light them. Perhaps a future enhancement. Anyway, enough of this introduction, let's jump straight into CSS custom properties.
CSS Custom Properties
Syntax
We define custom properties using the -- prefix. Another property is set by retrieving the value of a custom property using the var() function. Additional fallback values can be provided as the second argument. To date, I've not needed to set any fallback values.
Note: This isn't a fallback value used by the browser if CSS custom properties aren't supported, just a fallback if the custom property hasn't been created yet.
1$font-fallback: #fff;23:root {4 --white: #fff;5 --purple: #e167ff;6 --lime: #01ff70;78 --primary-color: var(--lime);9 --secondary-color: var(--purple);10 --font-color: var(--white, $font-fallback);11}
Scope
We define our CSS custom properties under the :root selector, which matches the root element of the document tree, enabling access to properties globally. CSS custom properties also cascade, which is the algorithm that defines the combination of property values originating from different sources. (Learn more)
Support
You're good to use CSS custom properties in all modern browsers. However, as expected Internet Explorer has 0 support. There is viable options for providing fallbacks when using CSS custom properties. If you need to support IE you can use the @supports CSS rule. Learn more here.
Dynamic
CSS custom properties are dynamic, which in short means that any changes will be automatically picked up and reflected by the browser. This makes them perfect for updating a site's theme in combination with some JavaScript. đĨ
Using Body Theme Attributes
Here is a quick visual of how the whole cycle works, from a single click through to every themed element on the page updating.
Each click updates the body attribute. The matching CSS selector activates, setting --primary-color to the next colour. Every element using that variable re-paints automatically.
Styles
The styles and sass logic below are responsible for creating our CSS custom properties and various dark theme attribute class selectors. Don't worry, we will walk through each part.
1// CSS Custom Properties2:root {3 /* Core Colors */4 --white: #fff;5 --orange: #ff9752;6 --yellow: #faff73;7 --pink: #fc7eca;8 --purple: #e581fe;9 --lime: #6effad;10 --red: #ff4848;11 --blue: #7aa2ff;1213 --black-navy: #131a25;14 --navy: #182635;1516 --slate-blue: #6a5acd;17 --dark-slate-blue: #483d8b;18 --text-slate-blue: #9c8cff;19 --grey-blue: #4f5966;2021 --light-grey: #9ca8b4;2223 /* đ¨ Themes đ¨ */2425 --background-color-dark-one: var(--black-navy);26 --background-color-dark-two: var(--navy);27 --secondary-color: var(--purple);28 --font-color: var(--white);2930 /* Primary color dark themes */31 $dark-theme-colors: (32 'text-slate-blue',33 'lime',34 'purple',35 'blue',36 'yellow',37 'red',38 'orange',39 'pink'40 );4142 // create attribute selector class for each theme43 @for $i from 1 through length($dark-theme-colors) {44 $name: nth($dark-theme-colors, $i);45 body[dark-theme-color='#{$name}'] {46 --primary-color: var(--#{$name});47 }48 }4950 $total-dark-themes: length($dark-theme-colors);51 --dark-theme-colors: #{$dark-theme-colors}; // expose for JS to access52 --total-dark-themes: #{$total-dark-themes}; // expose for JS to access53}
First, we define our custom properties.
1// CSS Custom Properties2:root {3 /* Core Colors */4 --white: #fff;5 --orange: #ff9752;6 --yellow: #faff73;7 --pink: #fc7eca;8 --purple: #e581fe;9 --lime: #6effad;10 --red: #ff4848;11 --blue: #7aa2ff;1213 --black-navy: #131a25;14 --navy: #182635;1516 --slate-blue: #6a5acd;17 --dark-slate-blue: #483d8b;18 --text-slate-blue: #9c8cff;19 --grey-blue: #4f5966;2021 --light-grey: #9ca8b4;2223 /* đ¨ Themes đ¨ */2425 --background-color-dark-one: var(--black-navy);26 --background-color-dark-two: var(--navy);27 --secondary-color: var(--purple);28 --font-color: var(--white);29}
Next, we need to create an attribute class selector for each colour. We do this by using a scss list containing each of our custom property colours, without the custom property -- prefix. We'll add the -- prefix to each colour inside our scss loop using the @for scss directive.
@for definition
counts up or down from one number (the result of the first expression) to another (the result of the second) and evaluates a block for each number in between. Each number along the way is assigned to the given variable name. If to is used, the final number is excluded; if through is used, it's included. (Learn more)
1/* Primary color dark themes */2 $dark-theme-colors: (3 'text-slate-blue',4 'lime',5 'purple',6 'blue',7 'yellow',8 'red',9 'orange',10 'pink'11 );1213 // create attribute selector class for each theme14 @for $i from 1 through length($dark-theme-colors) {15 $name: nth($dark-theme-colors, $i);16 body[dark-theme-color='#{$name}'] {17 --primary-color: var(--#{$name});18 }19 }2021 $total-dark-themes: length($dark-theme-colors);22 --dark-theme-colors: #{$dark-theme-colors}; // expose for JS to access23 --total-dark-themes: #{$total-dark-themes}; // expose for JS to access
We can see each body element attribute selector class created from our scss loop inside our compiled css file.
1:root body[dark-theme-color="blue"] {2 --primary-color: var(--blue);3}45:root body[dark-theme-color="lime"] {6 --primary-color: var(--lime);7}89:root body[dark-theme-color="text-slate-blue"] {10 --primary-color: var(--text-slate-blue);11}1213:root body[dark-theme-color="light-yellow"] {14 --primary-color: var(--light-yellow);15}1617:root body[dark-theme-color="orange"] {18 --primary-color: var(--orange);19}2021:root body[dark-theme-color="red"] {22 --primary-color: var(--red);23}2425:root body[dark-theme-color="pink"] {26 --primary-color: var(--pink);27}
We also need to set our default theme value for when our app first loads. This uses the same dark-theme-color attribute on our body element, but this time in our index.html file.
1<body dark-theme-color="text-slate-blue">2 <div id="root"></div>3 <script type="module" src="./index.tsx"></script>4</body>
You could create your styles in a more verbose manner if you prefer the readability, dislike scss, or just need a more sophisticated setup like switching between light and dark themes:
1...2/* Without a sass loop */34body[dark-theme-color='red'] {5 --primary-color: var(--red);6}78body[dark-theme-color='blue'] {9 --primary-color: var(--blue);10}1112body[dark-theme-color='yellow'] {13 --primary-color: var(--yellow);14}1516/* Example light theme */17body[theme-type='light'] {18 --background-color-dark-one: white;19 --background-color-dark-two: var(--light-grey);20 --font-color: var(--black-navy);21}2223...
JavaScript
Now that we've created all the indexed theme CSS custom properties, we need some JavaScript to interact with the Window.getComputedStyle() method. This method provides us with an interface to retrieve and update our CSS custom properties. We can use the getPropertyValue() interface method to obtain a property value and the setProperty() interface method to update a property using the property name and the new value we want to update it with. We won't be using the setProperty() interface method, instead we will be updating the dark-theme-color attribute on the body element.
1const getProperty = (2 name: string,3 computedStyleInterface: Record<string, any>4): string => computedStyleInterface.getPropertyValue(name).trim();56const computedStyleInterface: Record<string, any> = getComputedStyle(7 document.documentElement8);910// get custom properties11getProperty('--total-dark-themes', computedStyleInterface)12getProperty('--dark-theme-colors', computedStyleInterface).split(',')
You can copy & paste the snippet above (without types) into the browser and see the output as: (7) ['blue', ' lime', ' text-slate-blue', ' light-yellow', ' orange', ' red', ' pink']
Before we look at the full component snippet. The gist of it is, the user clicks the change theme button and we add the next theme color as the value on our body elements dark-theme-color attribute.
1const changeTheme = (index: number) => {2 const nextThemeColor = themeColors[index].trim();3 body?.setAttribute('dark-theme-color', nextThemeColor);4};
Our component...
1import React, { FC, useState, useCallback, useLayoutEffect } from 'react';2import ThemeSvg from './ThemeSvg';34const getProperty = (5 name: string,6 computedStyleInterface: Record<string, any>7): string => computedStyleInterface.getPropertyValue(name).trim();89interface Props {10 className: string;11 tabbable?: boolean;12}1314const changeTheme = (index: number, themeColors: string[]): void => {15 const nextThemeColor = themeColors[index].trim();16 const body = document.querySelector('body');17 body?.setAttribute('dark-theme-color', nextThemeColor);18};1920const accessibilityText = 'change dark theme primary color';2122const ThemePicker: FC<Props> = ({ className, tabbable }) => {23 const [currentIndex, setCurrentIndex] = useState<number>(0);24 const [totalThemes, setTotalThemes] = useState<number>(0);25 const [themeColors, setThemeColors] = useState<string[]>([]);2627 useLayoutEffect(() => {28 const computedStyleInterface: Record<string, any> = getComputedStyle(29 document.documentElement30 );3132 setTotalThemes(33 Number(getProperty('--total-dark-themes', computedStyleInterface))34 );35 setThemeColors(36 getProperty('--dark-theme-colors', computedStyleInterface).split(',')37 );38 }, []);3940 const clickHandler = useCallback((): void => {41 let nextIndex = currentIndex + 1;4243 const isLastTheme: boolean = nextIndex === totalThemes;44 if (isLastTheme) nextIndex = 0;4546 changeTheme(nextIndex, themeColors);47 setCurrentIndex(nextIndex);48 }, [changeTheme, setCurrentIndex, currentIndex, themeColors, totalThemes]);4950 if (totalThemes === 0 || themeColors.length === 0) {51 <button className={className} />;52 }5354 return (55 <button56 className={className}57 aria-label={accessibilityText}58 title={accessibilityText}59 onClick={clickHandler}60 tabIndex={tabbable ? 0 : -1}61 >62 <ThemeSvg className={`${className}--svg`} />63 </button>64 );65};6667export default ThemePicker;
We use useLayoutEffect to read layout from the DOM, update our local state hook values with our total dark themes/dark theme colors, and then synchronously re-render.
When you change theme with Chrome dev tools open, you'll notice the new attribute class being added to the body element each time the color changes. For example, changing from pink to lime before and after:
1// before2:root body[dark-theme-color=pink] {3 --primary-color: var(--pink);4}56// after7:root body[dark-theme-color=lime] {8 --primary-color: var(--lime);9}
Here's another fun example using a native color picker input to change the CSS custom property used as the background color for this div element.
1const styleInterface = document.documentElement.style;23<label>4 Background Color:5 <input6 type="color"7 onChange={(event) =>8 styleInterface.setProperty(`--example`, event.target.value)9 }10 />11</label>;1213<div style={styles.example} />1415// css module16.example {17 background-color: var(--example);18 height: 100px;19 width: 100%;20 border-radius: 32px;21 margin: 16px;22}
Final Thoughts
That's it for this article, go forth and make some cool themes with CSS custom properties! đ Oh and make sure to try changing the theme / inverting the background colors in the settings toolbar (toggle the settings cog to view!).
Was this useful?
Thanks for reading! Time for one more?
const xmas = christmasChallenge()// 5 puzzles to unlock đxmas.santaSecretPassPhrase('???')xmas.giveSantaFood('???')
đ Christmas Challenge
16 Dec 2021An interactive christmas challenge. Use your web dev skills to help Santa at the northpole đ .
const urls = routes.map(r => `${baseUrl}${r}`)await Promise.all(urls.map(url => $`npx axe ${url}`))
đĒ Google ZX & Axe CLI
16 Oct 2021 âĸ đ 4 min read âĸ Updated 15 Mar 2026Running axe-core CLI with Google ZX.