In React there are numerous theming solutions to choose from; styled-components, Emotion, styled-system, theme-ui – the list goes on. But in Svelte, a framework that feels like you have a front-row spot on The Platform™, those kinds of solutions don't exist. When I cracked open my brand new Svelte project I knew I wanted I knew I wanted to allow visitors to set (and persist) their preferred theme so they don't have to feel the pain of light mode if they don't want to.
Enter svelte-themer, a solution I originally implemented in as part of my website, but something I recently turned into an npm package.
What is Svelte?
Svelte has been labeled as the "new framework on the block", touted for being effective and efficient for building web applications quickly. Compared to the big players in the game — React, Angular, and Vue — it certainly brings a unique approach to the build process while also component-based.
First of all, it feels very close to the platform meaning fewer frills or abstractions than a framework like React; the platform being the web (plain HTML, CSS, JavaScript). It feels like what natively supported web modules should feel like. Svelte has a few frills; check out this small snippet:
<!-- src/components/Heading.svelte -->
<script>
export let name = 'World'
</script>
<h1>Hello {name}</h1>
<style>
h1 {
color: green;
}
</style>
That's it for a stateful heading component. There's a few things going on here:
<!-- src/components/Heading.svelte -->
<script>
// define a prop, `name`, (just like React)
// give it a default value of `World`
export let name = 'World'
</script>
<!-- use curly braces to refer to `name` value -->
<h1>Hello {name}</h1>
<style>
/* scoped style */
h1 {
color: green;
}
</style>
Now when we want to use it, it'll feel like using any other React component:
<!-- src/App.svelte -->
<script>
import Heading from './components/Heading.svelte'
</script>
<main>
<Heading name="Hansel" />
</main>
For more information I highly recommend checking out the tutorial on Svelte's site.
Theming
Thinking about how we want to shape the theme structure we immediately think of two things:
- Set/Collection of theme objects
- Toggle function
This means we'll need a way to store the toggle function, provide it to the rest of our app, and consume it somewhere within the app.
Here this component will be a button. If you're coming from React this may seem all too familiar, and it is. We're going to be using two of Svelte's features:
- context: framework API to provide & consume throughout the app with the help of a wrapper component
- writable stores: store data (themes, current theme)
Svelte's tutorial demonstrates their writable stores by separating the store into its own JavaScript file. This would be preferable if we were to later import the theme values to use in a component's <script>
section and use the methods that come along with writable stores such as .set()
and .update()
, however the colors should not change and the current value will be toggled from the same file. Therefore we're going to include the store right in our context component.
The Context Component
<!-- src/ThemeContext.svelte -->
<script>
import { setContext, onMount } from 'svelte'
import { writable } from 'svelte/store'
import { themes as _themes } from './themes.js'
</script>
<slot>
<!-- content will go here -->
</slot>
Let's take a quick look at these imports:
-
setContext
: allows us to set a context (key/value), here this will betheme
-
onMount
: function that runs on component mount -
writable
: function to set up a writable data store -
_themes
: our themes!
After the script block you'll notice the <slot>
tag, and this is special to Svelte. Coming from React think of this as props.children
; this is where the nested components will go.
Presets
A quick look at the preset colors for this demo.
// src/themes.js
export const themes = [
{
name: 'light',
colors: {
text: '#282230',
background: '#f1f1f1',
},
},
{
name: 'dark',
colors: {
text: '#f1f1f1',
background: '#27323a',
},
},
]
Writable Store
<!-- src/ThemeContext.svelte -->
<script>
import { setContext, onMount } from 'svelte'
import { writable } from 'svelte/store'
import { themes as _themes } from './themes.js'
// expose props for customization and set default values
export let themes = [..._themes]
// set state of current theme's name
let _current = themes[0].name
// utility to get current theme from name
const getCurrentTheme = name => themes.find(h => h.name === name)
// set up Theme store, holding current theme object
const Theme = writable(getCurrentTheme(_current))
</script>
<slot>
<!-- content will go here -->
</slot>
It's important to note that _current
is prefixed with an underscore as it will be a value we use internally to hold the current theme's name. Similarly with _themes
, they are used to populate our initial themes
state. Since we'll be including the current theme's object to our context, it is unnecessary to expose.
setContext
<!-- src/ThemeContext.svelte -->
<script>
import { setContext, onMount } from 'svelte'
import { writable } from 'svelte/store'
import { themes as _themes } from './themes.js'
// expose props for customization and set default values
export let themes = [..._themes]
// set state of current theme's name
let _current = themes[0].name
// utility to get current theme from name
const getCurrentTheme = name => themes.find(h => h.name === name)
// set up Theme store, holding current theme object
const Theme = writable(getCurrentTheme(_current))
setContext('theme', {
// provide Theme store through context
theme: Theme,
toggle: () => {
// update internal state
let _currentIndex = themes.findIndex(h => h.name === _current)
_current = themes[_currentIndex === themes.length - 1 ? 0 : (_currentIndex += 1)].name
// update Theme store
Theme.update(t => ({ ...t, ...getCurrentTheme(_current) }))
},
})
</script>
<slot>
<!-- content will go here -->
</slot>
Now we have the context theme
set up, all we have to do is wrap the App component and it will be accessible through the use of:
<!-- src/MyComponent.svelte -->
<script>
import { getContext } from 'svelte'
let theme = getContext('theme')
</script>
By doing so, providing access to the Theme
store and our theme toggle()
function.
Consuming Theme Colors - CSS Variables
Since Svelte feels close to The Platform™️ we'll leverage CSS Variables. In regards to the styled
implementations in React, we will ignore the need for importing the current theme and interpolating values to CSS strings. It's fast, available everywhere, and pretty quick to set up. Let's take a look:
<!-- src/ThemeContext.svelte -->
<script>
import { setContext, onMount } from 'svelte'
import { writable } from 'svelte/store'
import { themes as _themes } from './themes.js'
// expose props for customization and set default values
export let themes = [..._themes]
// set state of current theme's name
let _current = themes[0].name
// utility to get current theme from name
const getCurrentTheme = name => themes.find(h => h.name === name)
// set up Theme store, holding current theme object
const Theme = writable(getCurrentTheme(_current))
setContext('theme', {
// providing Theme store through context makes store readonly
theme: Theme,
toggle: () => {
// update internal state
let _currentIndex = themes.findIndex(h => h.name === _current)
_current = themes[_currentIndex === themes.length - 1 ? 0 : (_currentIndex += 1)].name
// update Theme store
Theme.update(t => ({ ...t, ...getCurrentTheme(_current) }))
setRootColors(getCurrentTheme(_current))
},
})
onMount(() => {
// set CSS vars on mount
setRootColors(getCurrentTheme(_current))
})
// sets CSS vars for easy use in components
// ex: var(--theme-background)
const setRootColors = theme => {
for (let [prop, color] of Object.entries(theme.colors)) {
let varString = `--theme-${prop}`
document.documentElement.style.setProperty(varString, color)
}
document.documentElement.style.setProperty('--theme-name', theme.name)
}
</script>
<slot>
<!-- content will go here -->
</slot>
Finally we see onMount
in action, setting our theme colors when the context component mounts, by doing so exposing the current theme as CSS variables following the nomenclature --theme-prop
where prop
is the name of the theme key, like text
or background
.
Toggle Button
For the button toggle we'll create another component, ThemeToggle.svelte
:
<!-- src/ThemeToggle.svelte -->
<script>
import { getContext } from 'svelte'
const { theme, toggle } = getContext('theme')
</script>
<button on:click={toggle}>{$theme.name}</button>
And we're ready to put it all together! We've got our theme context, a toggle button, and presets set up. For the final measure I'll leave it up to you to apply the theme colors using the new CSS variables.
Hint
main {
background-color: var(--theme-background);
color: var(--theme-text);
}
Theming Result
Moving Forward
Themes are fun, but what about when a user chooses something other than the default set on mount? Try extending this demo by applying persisted theme choice with localStorage
!
Conclusion
Svelte definitely brings a unique approach to building modern web applications. For a slightly more comprehensive codebase be sure to check out svelte-themer.
If you're interested in more Svelte goodies and opinions on web development or food check me out on Twitter @josefaidt.
Top comments (6)
For anyone that's super lazy and just wants to get CSS variables working quickly like me:
Some time ago i was looking for a way to switch themes in SvelteKit and ended up in having separate CSS files in public dir and just linking them.
It sucked b/c it needed full webpage reload to get new css to be applied.
So this solution with base64'ing content might do that automatically.
(I think getting css file as text and sending whole text as base64 might work).
Hey I'm just seeing this! This looks like an excellent approach and I hadn't thought about base64 encoding! Would love to hear your thoughts over at svelte-themer 🙂
Thanks for the write-up. Cool combination between context and store!
I do have 2 questions/ thoughts:
selected-theme
store variable (which the toggle button can write to) and reactively set the css variables. Probably there is something I am missing here... can you shed some light pls?Hi Isaac! Thanks for the feedback I really appreciate it. Disclaimer, this was the first time I built a theming solution from scratch like this and I wouldn't go as far to say what I've done is the best practice. To provide an explanation for your thoughts:
:root
, so that is my way to provide it to the entire document allowing elements like HTML and Body to access and apply these colors. In your example we see the colors start with the content, so only the content gets styled.This is very true, and could definitely be done that way, however as noted above I was aiming to provide the colors to HTML and Body as well.
That is also true, I chose context to provide a single import for both the consumption and dispatching updates, which in this example was provided by a pre-made function,
toggle
. Since the store is still provided with the context I suppose the toggle function can be re-rolled as well. When thinking of growth I tried this pattern to cut down on the multiple stores that may be imported into a single component, this way we can access with justgetContext
. To reinforce your thought, though, it can absolutely be done that way.Thanks for the detailed reply. Makes sense!