Field Notes

Theming

Runtime Theme Engine: 11 Light and Dark OKLCh Themes Without Flash

MemeWin has 11 colour themes, each with light and dark variants, for 22 CSS rule sets. It uses Tailwind v4 and the OKLCh colour space, switches without a reload, and lets users choose a Google Font or upload a `.ttf` or `.otf`. This note covers how the first paint stays free of a theme flash.

Topic
Theming
Year
Read
5 min

The requirement

OKLCh COLOR SPACE (why not HSL/RGB)

  • The space is perceptually uniform, so changing lightness keeps hue and saturation visually consistent
  • oklch(60% 0.15 250) represents 60% lightness, 15% chroma, and a hue of 250 degrees
  • For dark mode, adjusting lightness while retaining hue and chroma avoids muddy variants
  • Tailwind v4 supports this directly: bg-primary maps to oklch(var(--primary))

The 22 CSS rule sets take up 2,365 lines in globals.css.

Each theme defines about 35 CSS custom properties:

--primary, --primary-foreground, --secondary, --accent, --muted, --border, --ring, --background, --foreground, --destructive, --success, --warning, and --info, plus surface, overlay, and elevation tokens, all in OKLCh.

RUNTIME HOT-SWAP (theme-toggle.tsx, 883 lines)

  1. When a user chooses a theme, the app removes light and dark from document.documentElement.classList, then adds the selected theme name and mode
  2. The CSS variables change immediately and the browser repaints with the new colours
  3. localStorage.setItem('theme', themeName) and localStorage.setItem('mode', 'light'/'dark') save the preference
  4. The 569-line font system selects Google Fonts or accepts .ttf and .otf uploads, creates a blob URL, injects @font-face, and sets the --font-family CSS variable

ZERO-FLASH SOLUTION (Tailwind v4 JIT problem)

  • Tailwind v4 compiles on demand, and the first implementation briefly flashed unstyled content during a theme switch
  • The fix is to place the theme class in the root layout before hydration:
Decision cut / tsx
// app/layout.tsx
export default function RootLayout({ children }) {
  const theme = getInitialTheme(); // reads localStorage/cookie synchronously
  return (
    <html className={theme}>
      <body>{children}</body>
    </html>
  );
}
  • The server-rendered HTML already has the correct theme class, so the first paint does not need a client-side swap

RESULT

Continue conversation

Frontend problem worth thinking through?

Bring the context, including the hard parts. I am happy to talk them through.