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-primarymaps tooklch(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)
- When a user chooses a theme, the app removes
lightanddarkfromdocument.documentElement.classList, then adds the selected theme name and mode - The CSS variables change immediately and the browser repaints with the new colours
localStorage.setItem('theme', themeName)andlocalStorage.setItem('mode', 'light'/'dark')save the preference- The 569-line font system selects Google Fonts or accepts
.ttfand.otfuploads, creates a blob URL, injects@font-face, and sets the--font-familyCSS 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:
// 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