Theming in Svelte with CSS Variables

Peacock - Photo by Steve Harvey on Unsplash

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

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.