Adding Dark Mode: Tailwind v4, Flash Prevention, and the Toggle That Did Nothing

The Goal

Dark mode is table stakes for any technical blog. Developers read code all day on dark backgrounds, and then a blog post hits them with a wall of blinding white. The requirements for this implementation were simple: default to whatever the user's browser or OS prefers, provide a manual toggle to override it, and remember the choice across sessions. Apply it to both the public blog and the admin panel.

The implementation was straightforward. The bug that followed was not.

The Strategy: Class-Based Dark Mode

There are two common approaches to dark mode with Tailwind CSS. The first uses the prefers-color-scheme media query — the browser handles everything, and you have no toggle. The second uses a CSS class on the <html> element, which gives you programmatic control. Since I wanted a manual toggle and system preference detection, class-based was the way to go.

The plan: add a dark class to <html>, sprinkle dark: variants onto every component, build a Svelte store for state management, and persist the choice in localStorage. The color palette would stay within Tailwind's gray scale — gray-950 for page backgrounds, gray-800 for surfaces, gray-100 for text. No custom colors, no design system, just the built-in palette shifted to the dark end.

Flash Prevention

The hardest part of dark mode in any server-rendered application is the flash. SvelteKit renders HTML on the server, sends it to the browser, then hydrates it with JavaScript. If the user prefers dark mode but the server sends light HTML, there is a visible flash of white before the client-side JavaScript can add the dark class. On a dark-themed OS, this is like a camera flash going off every time you navigate.

The fix is an inline script in app.html that runs before anything renders:

<script>
  (function () {
    const saved = localStorage.getItem('theme');
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    if (saved === 'dark' || (!saved && prefersDark)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

This runs synchronously in the <head>, before the browser paints. It checks localStorage first (for returning users who set an explicit preference), then falls back to the system preference. No flash. The same file gets two theme-color meta tags so the browser chrome matches:

<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#030712" media="(prefers-color-scheme: dark)" />

The color-scheme CSS property also gets set per-mode. This tells the browser to style native form controls (scrollbars, checkboxes, date pickers) in the appropriate scheme:

html {
  color-scheme: light;
}

html.dark {
  color-scheme: dark;
}

The Theme Store

Theme state lives in a Svelte writable store at src/lib/theme.ts. The type is a union of three values:

export type ThemeChoice = 'light' | 'dark' | 'system';

On first visit, the store initializes to 'system'. Once the user clicks the toggle, it becomes 'light' or 'dark' and gets written to localStorage. The toggleTheme() function resolves the current effective theme (which might be 'system' mapped to whatever the OS says), flips it, and applies the change in three places: the store value, localStorage, and the dark class on <html>.

export function toggleTheme() {
  if (!browser) return;

  theme.update((current) => {
    const effective = getEffectiveTheme(current);
    const next: ThemeChoice = effective === 'dark' ? 'light' : 'dark';
    localStorage.setItem('theme', next);
    applyTheme(next);
    return next;
  });
}

The store also listens for system preference changes via matchMedia. If the user is on 'system' and they toggle their OS to dark mode, the blog follows immediately without a page reload.

The Toggle Component

The ThemeToggle component is a button with two SVG icons: a sun (shown in dark mode, meaning "click for light") and a moon (shown in light mode, meaning "click for dark"). It uses Svelte 5's $state rune for the isDark flag, reading the DOM directly after each toggle:

<script lang="ts">
  import { toggleTheme, initTheme } from '$lib/theme';
  import { onMount } from 'svelte';

  let mounted = $state(false);
  let isDark = $state(false);

  onMount(() => {
    initTheme();
    mounted = true;
    isDark = document.documentElement.classList.contains('dark');
  });

  function handleClick() {
    toggleTheme();
    isDark = document.documentElement.classList.contains('dark');
  }
</script>

The {#if mounted} guard prevents server-side rendering of the button entirely. On the server, there is no localStorage and no DOM, so we render nothing and let the client hydrate it after mount. This avoids hydration mismatches between server and client state.

Painting Everything Dark

With the infrastructure in place, the remaining work was mechanical: go through every Svelte component and add dark: variants to every color class. The blog has 12 files that needed updating — layouts, pages, the PostCard component, admin dashboard, post forms, and the TipTap editor.

The pattern is repetitive but consistent. A heading that says text-gray-900 gets dark:text-gray-100. A border that says border-gray-200 gets dark:border-gray-800. Backgrounds flip from bg-white to dark:bg-gray-950 (page level) or dark:bg-gray-800 (card/surface level). Links go from text-blue-600 to dark:text-blue-400.

The most interesting case was the blog post content area. Articles are rendered as raw HTML from the API via {@html data.post.content}, which means Tailwind utility classes don't apply to the article body. Instead, the @tailwindcss/typography plugin handles styling through the prose class. Dark mode for prose content is a single class swap:

<div class="prose prose-gray dark:prose-invert max-w-none">
  {@html data.post.content}
</div>

The prose-invert class flips all the typography plugin's color calculations. Headings, paragraphs, links, code blocks, blockquotes — everything inverts with one class. This was easily the highest-value line of CSS in the entire implementation.

The admin side was the same process but with more surfaces: form inputs needed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100, status badges needed semi-transparent dark backgrounds like dark:bg-green-900/30 dark:text-green-400, and the TipTap editor toolbar needed active state overrides for its formatting buttons. Each toolbar button went from bg-gray-300 (active) to dark:bg-gray-600.

The Bug: The Toggle That Did Nothing

Everything built. Everything deployed. The blog loaded in dark mode on my dark-themed OS. The sun/moon icon appeared in the header. I clicked it.

Nothing happened.

The icon stayed the same. The page stayed dark. No errors in the console. The button was rendered, the click handler was attached, toggleTheme() was being called, localStorage was being updated, and the dark class on <html> was being toggled. But the page did not change.

The problem was not in the JavaScript. It was in the CSS.

Tailwind CSS v4 Changed the Default

In Tailwind CSS v3, you could configure class-based dark mode in tailwind.config.js:

// Tailwind v3
module.exports = {
  darkMode: 'class',
}

Tailwind CSS v4 removed tailwind.config.js entirely. Configuration now lives in CSS. And the default behavior of the dark: variant changed: it uses the prefers-color-scheme: dark media query by default, not a CSS class.

This meant every dark: class in the application was being activated by the browser's media query, not by the .dark class on <html>. The toggle was faithfully adding and removing the class, but Tailwind was not listening. The dark styles appeared when the OS was in dark mode and disappeared when it wasn't — regardless of what the toggle did.

The fix was a single line in app.css:

@custom-variant dark (&:where(.dark, .dark *));

This tells Tailwind v4 to generate dark: variants using the .dark class selector instead of the media query. With that one line, every dark: class in the application switched from being media-query-driven to being class-driven. The toggle started working immediately.

Why This Was Hard to Catch

This bug was invisible during development for a specific reason: I develop on a Mac with dark mode enabled. That means prefers-color-scheme: dark was always true. Tailwind's media-query-based dark: variants were always active. The page looked correctly dark. The flash-prevention script was adding the .dark class, which did nothing (but also broke nothing). The toggle was toggling a class that had no CSS effect, but since the media query was always matching, the dark styles stayed on regardless.

If I had tested with my OS in light mode, I would have seen it immediately: the site would have been stuck in light mode with a toggle button that did nothing. But I didn't, because the dark styles were coincidentally correct.

The lesson: when your dark mode works perfectly on first deploy with zero toggle testing, that is not evidence that it works. That is evidence that it is broken in a way that is invisible in your environment.

What Shipped

The final implementation touches every visual component on the site:

  • Flash prevention — inline script in app.html, zero-latency theme application
  • System preference detection — follows OS dark/light mode by default
  • Manual toggle — sun/moon icon in header, persisted in localStorage
  • Live system tracking — if set to system default, responds to OS preference changes in real time
  • Blog pages — layout, post list, post detail with prose-invert, tag pages, pagination
  • Admin pages — dashboard table, post forms, TipTap editor toolbar
  • Native controlscolor-scheme property ensures scrollbars and form controls match

Two new files (theme.ts and ThemeToggle.svelte), twelve modified files, one critical line of CSS configuration, and one humbling debugging session. Dark mode in SvelteKit with Tailwind v4 is not difficult — but the framework migration gotcha with the dark: variant default is the kind of thing that burns an hour if you don't know to look for it.