I’m excited to share my discovery on managing theme switching between light and dark modes, or even following the system theme, using pure CSS without any libraries. This approach works with any JavaScript framework for the web, but since I’ve recently spent significant time working with Svelte, I’ll demonstrate it using the Svelte 5 framework with TypeScript. Let’s dive in!
Add data theme attribute to the root HTML
If you create a Svelte app, you will have a src/app.html
file that contains the root HTML. In the <html>
tag, you can add a data-theme
attribute. Set it to “light” as the default theme, so it will look like this:
<html lang="en" data-theme="light"></html>
Define the CSS variables
In your app.css
, which will be imported into the src/routes/+layout.svelte
, you can define CSS variables for each data-theme
attribute. It will look like this:
:root {
--md: 768px;
--lg: 1024px;
--xl: 1280px;
}
[data-theme="light"] {
--primary-color: 10, 105, 218;
--primary-highlight-color: 221, 244, 255;
--on-primary-color: 255, 255, 255;
--secondary-color: 161, 64, 0;
--bg-color: 245, 245, 247;
--surface-color: 255, 255, 255;
--surface-highlight-color: 249, 249, 249;
--on-surface-color: 27, 27, 27;
--subtitle-color: 110, 110, 115;
--button-color: 242, 242, 242;
--neutral-color: 215, 221, 228;
}
[data-theme="dark"] {
--primary-color: 68, 147, 248;
--primary-highlight-color: 19, 29, 46;
--on-primary-color: 255, 255, 255;
--secondary-color: 255, 181, 151;
--bg-color: 2, 4, 10;
--surface-color: 13, 17, 22;
--surface-highlight-color: 21, 27, 35;
--on-surface-color: 255, 255, 255;
--subtitle-color: 240, 246, 252;
--button-color: 25, 30, 36;
--neutral-color: 43, 50, 60;
}
Prepare the shared rune
To persist the current theme at the framework level, we’re going to define a shared rune. Place this in src/lib/shared.svelte.ts
. Here’s the content:
export const app = $state({
theme: ''
})
Initialize the theme
Initially, we will read the system theme and set it as the default theme. To make this logic reusable across all components, let’s define it in a new shared file, src/state.svelte.ts
. Here’s the content:
import { app } from "$lib/shared.svelte"
export class LayoutPageState {
setTheme = (to = '') => {
if (to === 'light') {
app.theme = 'light'
} else if (to === 'dark') {
app.theme = 'dark'
} else {
app.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.setAttribute('data-theme', app.theme)
}
}
Import and call the setTheme
function in src/+layout.svelte
on mounted. Here’s how it look like:
<script lang="ts">
import '../app.css'
import { onMount } from 'svelte'
import { LayoutPageState } from './state.svelte'
let pageState = new LayoutPageState()
let { children } = $props()
onMount(() => pageState.setTheme())
</script>
{@render children()}
Toggle the theme
To allow users to toggle the theme via a button, let’s create a component in src/lib/components/LightSwitch.svelte
and reuse the resources we created earlier. Here’s how it looks:
<script lang="ts">
import { app } from '$lib/shared.svelte'
import { LayoutPageState } from '../../routes/state.svelte'
let pageState = new LayoutPageState()
let to = $derived(app.theme === 'light' ? 'dark' : 'light')
</script>
<button onclick={() => pageState.setTheme(to)}>
{#if app.theme === 'light'}
<span>Dark</span>
{:else}
<span>Light</span>
{/if}
</button>
And that’s it! With these steps, you should be able to toggle the theme using the LightSwitch.svelte
component. Everything described above is implemented on this site and works perfectly.
Happy coding! Bye!