The proper way to darkmode with Tailwind in SvelteKit

Learn how to implement flicker-free darkmode in SvelteKit using Tailwind CSS v4 with server-side rendering support.

The key advantage of this approach is that it doesn’t rely on client-side JavaScript after page mount, which is typically the cause of theme flickering.

Implementation steps

Add custom variant

This custom variant handles both explicit theme choices and system preferences as a fallback.

app.css
@custom-variant dark {
	/* Explicit dark theme choice */
	&:where([data-theme='dark'], [data-theme='dark'] *) {
		@slot;
	}

	/* System preference when no theme is explicitly set */
	@media (prefers-color-scheme: dark) {
		&:where([data-theme='system'], [data-theme='system'] *) {
			@slot;
		}
	}
}

Add data-theme to html

Add the default theme attribute to your HTML root element.

app.html
<html lang="en" data-theme="system">
	<!-- all the basic stuff -->
</html>

Create a theme handler

This server-side handler ensures the correct theme is rendered on initial page load. When no theme cookie is found, it defaults to system preference, allowing CSS media queries to handle the theme selection.

hooks.server.ts
export const handle = async ({ event, resolve }) => {
	const cookieTheme = event.cookies.get('theme')

	// Continue as normal if no stored theme → system preference
	if (!cookieTheme) return resolve(event)

	// Set the preferred theme
	return resolve(event, {
		transformPageChunk: ({ html }) =>
			html.replace('data-theme="system"', `data-theme="${cookieTheme}"`)
	})
}

Create theme endpoint

This example is simplified for clarity. In production, you should add proper validation, error handling, and security measures.

api/settings/theme/+server.ts
import { json } from '@sveltejs/kit'
import { cookiesConfig } from '$config'

export const GET = async ({ url, cookies }) => {
	const newTheme = url.searchParams.get('newTheme')

	// Simplified validation
	if (!newTheme) return json({ success: false }, { status: 400 })

	// Clear if system-value
	if (newTheme === 'system') cookies.delete('theme', cookiesConfig)
	// Set cookie for explicit theme
	else cookies.set('theme', newTheme, { ...cookiesConfig, maxAge: 60 * 60 * 24 * 365 })

	return json({ success: true })
}

Settings page

Finally, create a settings page where users can select their preferred theme by calling the endpoint created in the previous step.

settings/+page.svelte
<script lang="ts">
	// Call endpoint with selected theme
	async function setTheme(newTheme: string) {
		const { ok } = await fetch(`/api/settings/theme?newTheme=${newTheme}`)
		if (ok) document.documentElement.setAttribute('data-theme', newTheme)
	}
</script>

<button onclick={() => setTheme('light')}> Light </button>
<button onclick={() => setTheme('dark')}> Dark </button>
<button onclick={() => setTheme('system')}> System </button>

That's it! 🙇🏿

You can now use the dark: variant throughout your application to create theme-aware styles.

<article class="prose dark:prose-invert">
	<h1>Like and subscribe!</h1>
</article>