loading...
Cover image for Theming in Svelte with CSS Variables

Theming in Svelte with CSS Variables

josefaidt profile image josef aidt ・6 min read

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:

  1. Set/Collection of theme objects
  2. 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 be theme
  • 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

CodeSandbox

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.

Posted on by:

josefaidt profile

josef aidt

@josefaidt

JavaScript dev and garlic bread connoisseur

Discussion

markdown guide
 

Thanks for the write-up. Cool combination between context and store!
I do have 2 questions/ thoughts:

  1. Why did you set the css variables at the document level? Can't it result in conflicts if your app is rendered alongside other elements on the page? Wouldn't it be safer/ cleaner to set them on the ThemeContext element (it is wrapping the app anyway so they will be accessible to the entire app and would override any external values for the app)? I tweaked your code here to demonstrate it. It is a tiny change: codesandbox.io/s/blog-svelte-theme...).
  2. I didn't have time to try it out yet but... since the only thing that matters (for the appearance of the page) is the css variables, I wonder whether this could work without using context at all. The ThemeContext component would subscribe to a 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:

  1. Typically we see CSS variables set on :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.

[ThemeContext] is wrapping the app anyway so they will be accessible to the entire app and would override any external values for the app

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.

2. I didn't have time to try it out yet but... since the only thing that matters (for the appearance of the page) is the css variables, I wonder whether this could work without using context at all. The ThemeContext component would subscribe to a 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?

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 just getContext. To reinforce your thought, though, it can absolutely be done that way.

 

Thanks for the detailed reply. Makes sense!