Flicker-free darkmode in SvelteKit with Tailwind v4

tailwindsveltekit

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

This approach eliminates flickering by rendering the correct theme server-side, removing the need for post-mount JavaScript.

Implementation steps

Add darkmode variant

This custom variant respects explicit theme choices while gracefully falling back to system preferences.

src/app.css
@import 'tailwindcss';

@custom-variant dark {
	/* Explicitly set dark theme */
	&:where([data-theme='dark'], [data-theme='dark'] *) {
		@slot;
	}
	/* System preference fallback */
	@media (prefers-color-scheme: dark) {
		&:where(html:not([data-theme]), html:not([data-theme]) *) {
			@slot;
		}
	}
}

Create a theme handler

This server-side handler renders the correct theme on initial load. Without a stored preference, it defers to system settings via CSS media queries.

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

	// No stored theme → defer to system preference
	if (!cookieTheme) return resolve(event)

	// Apply stored theme
	return resolve(event, {
		transformPageChunk: ({ html }) => html.replace('<html', `<html data-theme="${cookieTheme}"`)
	})
}

Create theme endpoint

Create an endpoint that persists the theme preference in a cookie. This example is simplified—add proper validation and security for production use.

src/routes/api/settings/theme/+server.ts
import { dev } from '$app/environment'
import { json, type Cookies } from '@sveltejs/kit'

const cookiesConfig: Parameters<Cookies['set']>[2] = {
	path: '/',
	sameSite: 'lax',
	secure: !dev,
	httpOnly: true
}

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

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

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

	return json({ success: true })
}

Settings page

Create a UI for theme selection by calling the endpoint. This can live in settings or anywhere in your app.

src/routes/settings/+page.svelte
<script lang="ts">
	const setTheme = async (newTheme: string) => {
		const { ok } = await fetch(`/api/settings/theme?newTheme=${newTheme}`)
		if (ok && newTheme === 'system') document.documentElement.removeAttribute('data-theme')
		else 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!

Use the dark: variant throughout your app for theme-aware styles.

<div class="bg-white dark:bg-gray-800">
	<h3 class="text-gray-900 dark:text-white">Writes upside-down</h3>
	<p class="text-pretty text-gray-500 dark:text-gray-400">
		The Zero Gravity Pen can be used to write in any orientation, including upside-down. It even
		works in outer space.
	</p>
</div>

Test it using the theme selector in the footer below 👇

© 2025 vigerust.dev. All rights reserved.