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.
@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.
<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.
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.
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.
<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>