Projects Blog

Theme selector component in Astro

Web themes are a feature that has become more popular in recent years. They are a predefined set of styles that change the visual interface of the website or application.

Lot of sites supply the user a way for controlling the color scheme. Usually, between two options: dark or light.

Potential problems during development

As web developers, we can encounter various problems when implementing this feature: the theme selector. In this blog post, we’ll delve into some of these challenges and discuss potential solutions.

Flickering

One common problem arises when using a component with islands or JavaScript as an asset. This leads to flickering if the selected theme differs from the initial page appearance. To prevent this flickering issue, we shouldn’t compile the JavaScript or pass it through an island.

The workaround involves embedding inline code in the HTML, ensuring it executes immediately upon loading.

User preferences and settings

We should effectively manage three points:

Has LocalStorage?Has prefers-color-scheme?Default themeSet themeUser visits the pageYesYesNoNo

Example Astro code

Here you can see a functional theme selector component code example.

It will handle the user preferences and then initialize the theme. This initialization consists on adding a class to the body .theme-dark.

Also, it will add a handler to the button that will persist this selection in the localStorage.

ThemeSelector.astro
<button
id='theme-toggle'
type='button'
>
<span class='light-mode'>Set light mode 🌞</span>
<span class='dark-mode'>Set dark mode 🌑</span>
</button>
<script is:inline>
// Inline: not bundled. It will execute as soon as possible
const STORAGE_THEME_KEY = 'theme'
const DARK_THEME_CLASS = 'theme-dark'
const DARK = 'dark'
const LIGHT = 'light'
const root = document.documentElement
let isDark = true
const getUserPreferences = (localStorageKey) => {
if (localStorage && localStorage.getItem(localStorageKey))
return localStorage.getItem(localStorageKey)
if (window.matchMedia('(prefers-color-scheme: light)').matches) return LIGHT
return DARK
}
function initTheme() {
const theme = getUserPreferences(STORAGE_THEME_KEY)
isDark = theme === DARK
root.classList.toggle(DARK_THEME_CLASS, isDark)
}
function handleClick() {
isDark = !isDark
root.classList.toggle(DARK_THEME_CLASS, isDark)
localStorage.setItem(STORAGE_THEME_KEY, isDark ? DARK : LIGHT)
}
function setButtonHandler() {
const button = document.getElementById('theme-toggle')
button?.addEventListener('click', () => handleClick())
}
// Initial navigation
initTheme()
setButtonHandler()
</script>
<style is:global>
.light-mode,
.theme-dark .dark-mode {
display: flex;
}
.theme-dark .light-mode,
.dark-mode {
display: none;
}
</style>
If you are using Astro ViewTransitions component, you should add few lines:
ThemeSelector.astro
<button
id='theme-toggle'
type='button'
>
<span class='light-mode'>Set light mode 🌞</span>
<span class='dark-mode'>Set dark mode 🌑</span>
</button>
<script is:inline>
// Inline: not bundled. It will execute as soon as possible
const STORAGE_THEME_KEY = 'theme'
const DARK_THEME_CLASS = 'theme-dark'
const DARK = 'dark'
const LIGHT = 'light'
const root = document.documentElement
let isDark = true
const getUserPreferences = (localStorageKey) => {
if (localStorage && localStorage.getItem(localStorageKey))
return localStorage.getItem(localStorageKey)
if (window.matchMedia('(prefers-color-scheme: light)').matches) return LIGHT
return DARK
}
function initTheme() {
const theme = getUserPreferences(STORAGE_THEME_KEY)
isDark = theme === DARK
root.classList.toggle(DARK_THEME_CLASS, isDark)
}
function handleClick() {
isDark = !isDark
root.classList.toggle(DARK_THEME_CLASS, isDark)
localStorage.setItem(STORAGE_THEME_KEY, isDark ? DARK : LIGHT)
}
function setButtonHandler() {
const button = document.getElementById('theme-toggle')
button?.addEventListener('click', () => handleClick())
}
// Initial navigation
initTheme()
setButtonHandler()
// On view transitions navigation
document.addEventListener('astro:after-swap', () => {
initTheme()
setButtonHandler()
})
</script>
<style is:global>
.light-mode,
.theme-dark .dark-mode {
display: flex;
}
.theme-dark .light-mode,
.dark-mode {
display: none;
}
</style>

Styles

Finally, you only have to add your styles. I plenty suggest managing them through CSS custom properties.

:root {
--color-text: #f7f7f7;
--color-background: #1c1c1c;
/* Add your light tokens here */
}
.theme-dark {
--color-text: #1c1c1c;
--color-background: #f7f7f7;
/* Add your dark tokens here */
}

Conclusion

Adding a theme selector feature in a web could be painless if you take into account flickering issues and user preferences persistance.

By the way, you have to consider if adding one will provide any value to your project. Maintaining multiple themes involves an effort, since you have to ensure a consistent user experience and a high accessibility. Always decide if it’s worth all the extra work compared to the value it provides.


I hope you found this article useful.

Happy coding! 🚀

Related posts