Skip to content
All posts
Engineering/February 14, 2026/Omar Majed

The Holy Trinity of Theming: Dark Mode Without Flickering, Radial Animations, and a 40-Theme Color Picker

Your dark mode flickers on page load. Your theme toggle is a sad checkbox. Your color scheme picker reloads the entire page. Let us fix all three, and let us make it look absurdly good while we are at it.

Hands holding a colorful fan of swatches on a gray backdrop, representing theme and color scheme selection

Let me paint a picture. You have spent three weeks building a gorgeous landing page. The colors are perfect. The typography sings. You deploy it. You send the link to your designer. They open it. And for exactly 0.3 seconds, the entire page flashes white before settling into dark mode.

"What was that flash?" they Slack you. You close your laptop. You open it again. You pretend you did not see the message. You know exactly what the flash was. It was your hopes and dreams buffering.

This, friends, is The Flicker. The unstyled milliseconds between server-rendered HTML and your JavaScript finally deciding what theme the user wants. It haunts frontend developers the way goto statements haunt computer science professors. And like goto, everyone says they have solved it, but half the solutions on Stack Overflow just move the problem somewhere else.

Today we are going to fix The Flicker, build a radial dark-mode animation so slick it should be illegal, and top it off with a 40-theme color scheme picker that does not make your users wait for a page reload. Buckle up. We are going deep into CSS territory, and we are bringing snacks.

The Stack (Or: Standing on the Shoulders of Very Stylish Giants)

Before we dive in, let me lay out the tools. We are running Next.js with the App Router — server components, server-side cookies, the whole shebang. Our component library is Shadcn UI, which gives us a beautiful CSS-variable-based theming system out of the box. Every Shadcn component — buttons, dialogs, cards, inputs — reads colors from CSS custom properties like --primary, --background, and --accent. Change the variables, change the entire UI. It is the kind of architecture that makes you wonder why anyone does theming any other way.

And where do our 40+ themes come from? tweakcn.com — an open-source visual theme editor for Shadcn that exports complete OKLCH color palettes, font stacks, border radii, and shadow definitions. Each theme ships as a standalone CSS file that redefines every Shadcn variable. We did not hand-craft a single palette. We downloaded them, dropped them in public/colorSchemes/, and wired them up. The tweakcn team did the hard design work. We just connected the plumbing. (Shoutout to them. Seriously. They saved us weeks.)

One critical gotcha if you are using tweakcn themes: they export dark mode selectors as .dark. If you are using next-themes or a data-attribute strategy (like we are), you must rewrite those to html[data-theme="dark"]. Otherwise your dark mode will not work and you will spend two hours debugging before realizing the selector is wrong. Ask me how I know.

With Tailwind v4 tying it all together via @theme inline, these CSS variables become first-class Tailwind utilities. bg-primary, text-foreground, border-accent — all reactive to whichever theme is loaded. No JavaScript color manipulation. No runtime recalculation. Just CSS doing what CSS was born to do.

Part 1: Why Your Dark Mode Flickers (And Why It Is Not Your Fault)

The classic dark mode implementation goes something like this: store the user's preference in localStorage, read it on mount with useEffect, and toggle a class on the document. Sounds reasonable. Is catastrophically wrong.

Here is why. Server-Side Rendering happens on the server (I know, shocking). The server does not have localStorage. So it renders the default theme — usually light. The HTML arrives at the browser, painted in pristine white. Then React hydrates. useEffect fires. JavaScript reads the stored preference. "Oh, they wanted dark mode!" it gasps, scrambling to apply the class. For a brief, horrible moment, your user sees a white flash brighter than a thousand suns. The Flicker has struck again.

The fix is deceptively simple: do not let JavaScript handle it. Let a cookie handle it.

The Cookie Strategy

Cookies, unlike localStorage, travel with every HTTP request. Your server can read them. This means the server knows the theme before it renders a single byte of HTML. No flash. No guessing. No existential dread.

In our implementation, the dark mode toggle writes a cookie with a simple document.cookie call:

unknown node

The server reads this cookie and sets data-theme on the HTML element during SSR. By the time the browser paints the first pixel, the correct theme is already baked into the markup. The Flicker never had a chance.

And here is the kicker: we also set document.documentElement.dataset.theme on the client side immediately inside a synchronous flushSync call — not in a useEffect, not in a callback, not in a setTimeout. Synchronously. Before React has time to even think about re-rendering. The theme change is instant because we do not give the browser a single frame to show the wrong state.

The Tailwind v4 Dark Variant

Tailwind v4 lets you define a custom dark variant. Instead of relying on the system prefers-color-scheme media query (which you cannot control from JavaScript), we define our own:

unknown node

Now every dark: utility in Tailwind responds to our cookie-driven data attribute instead of the OS setting. Full control. No flicker. The crowd goes wild.

Part 2: The Radial Dark Mode Animation (AKA "The Circle of Life")

Okay, so your dark mode does not flicker anymore. Great. But now your toggle just... snaps. Light. Dark. Light. Dark. Like a light switch in a haunted house. Functional? Yes. Inspiring? About as inspiring as a default Windows screensaver.

What if, instead, a circle expanded from the toggle button, sweeping the new theme across the screen like an ink drop in water? What if it looked like something out of a Pixar transition? What if your users actually enjoyed toggling dark mode?

Enter document.startViewTransition — the CSS View Transitions API. It is the browser's way of saying "I will take a screenshot of what the page looks like now, let you change things, and then I will animate between the two states." It is absurdly powerful, and we are going to abuse it.

The Implementation

Here is the core of our AnimatedDarkModeToggle component. Read it slowly. Savor it. This is the good stuff.

unknown node

Let me walk you through the three steps because each one is doing something clever.

Step 1: flushSync Inside startViewTransition

The View Transitions API works like this: the browser captures the "before" state as a snapshot, you make your DOM changes inside the callback, and then it captures the "after" state. It animates between them using CSS pseudo-elements.

But here is the trap. React batches state updates. If you call setIsDark normally, React might not apply the change immediately. The browser's "after" snapshot could capture the old state. Your animation would transition from dark to... dark. Very zen. Not useful.

That is why we use flushSync from react-dom. It forces React to synchronously apply the state update and flush the DOM changes right then and there. No batching. No deferring. The browser captures the correct "after" state, and the animation works perfectly.

Step 2: The Geometry (Math.hypot Is Your Friend)

We need to know where to start the circle and how big it needs to grow. The circle starts at the center of the toggle button — we get that from getBoundingClientRect(). The maximum radius needs to reach the farthest corner of the viewport. Math.hypot gives us the hypotenuse — the diagonal distance from the button to the farthest edge. This ensures the circle covers the entire screen no matter where the button is positioned.

Fun fact: if your toggle button is in the top-right corner (which it often is), the circle has to travel all the way to the bottom-left. That is a long trip. The Math.hypot calculation handles it elegantly. Geometry class finally paid off. Tell your math teacher.

Step 3: Animating the Pseudo-Element

The magic is in the pseudoElement option. We are not animating any real DOM element. We are animating ::view-transition-new(root) — a browser-generated pseudo-element that holds the "after" snapshot of the page. We clip it to a circle that starts at zero radius and expands to cover the full viewport.

The visual result: the new theme "bleeds" outward from the button in a perfect circle. The old theme is still visible behind it, shrinking as the circle grows. It looks expensive. It costs nothing. It is pure CSS wizardry with a little JavaScript orchestration.

One Critical CSS Rule

For this to work, you need to disable the default view transition animations. Otherwise the browser applies its own fade, and things get weird:

unknown node

This tells the browser: "Thank you for the pseudo-elements. I will take it from here." Without this rule, you get a fight between your clip-path animation and the browser's default crossfade. Nobody wins that fight. Especially not the user.

Part 3: A 40-Theme Color Scheme Picker (Without the Reload of Shame)

Dark mode is a toggle. Two states. Easy. But what if you want to offer 40 complete color schemes — each with their own light and dark variants, custom fonts, unique shadows, and their own personality? Now you are in a different league. Now you are building a theme engine.

The naive approach: dynamically swap CSS variables at runtime with JavaScript. You can write a monstrous function that sets 30+ CSS variables on the document root. It works. It also turns every theme switch into a JavaScript operation, makes SSR useless (the server does not know the variables), and re-introduces The Flicker on page load. We fought too hard to let The Flicker back in.

The Architecture: Static CSS Files + Server Cookies

Our approach leans into what Next.js does best: server rendering. Here is the full architecture.

Step 1: Each tweakcn theme lives as a standalone CSS file in public/colorSchemes/. We have 43 of them. Each file redefines the complete set of Shadcn CSS variables under :root for light mode and html[data-theme="dark"] for dark mode. Here is what a theme file looks like (abbreviated):

unknown node

Notice the @theme inline block at the bottom. That is Tailwind v4's way of saying "these CSS variables are your design tokens." It bridges the gap between raw CSS custom properties and Tailwind utility classes. When you write bg-primary, Tailwind resolves it to var(--primary) which resolves to the OKLCH value from whichever theme file is loaded. The chain is elegant: tweakcn theme -> CSS variables -> Tailwind utilities -> your components. Zero runtime cost.

Also notice: each theme defines its own --font-sans, --font-serif, and --font-mono. The cyberpunk theme uses Outfit. The notebook theme uses Architects Daughter. The graphite theme uses Montserrat. Switching themes does not just change colors — it changes the entire typographic personality. This is where tweakcn themes go from "nice" to "this feels like a completely different product."

Step 2: The Next.js Layout (Where the Magic Connects)

This is the part that makes it all work. Our root layout is a server component. It reads cookies, determines the active theme, and injects the correct <link> tag — all before a single byte of HTML reaches the browser:

unknown node

Read that <link> tag carefully. It is a single line of code and it is doing everything. The server reads the colorScheme cookie, interpolates the theme name into the href, and the browser loads exactly one CSS file. If the cookie says cyberpunk, the browser loads /colorSchemes/cyberpunk.css. If it says graphite, it loads /colorSchemes/graphite.css. First paint is correct. No flash. No intermediate state. The Flicker never even gets invited to the party.

And look at the data-theme attribute on the <html> tag — it reads the payload-theme cookie and sets it server-side. Both cookies, both themes, both correct on first render. This is the double-cookie strategy in action: one cookie for light/dark, one for the color scheme. Two cookies, zero flicker.

Step 3: Per-Theme Font Loading

Here is a detail that most theme systems skip entirely: fonts. Each tweakcn theme specifies its own font stack. If you load all 43 themes' fonts upfront, your users download megabytes of typefaces they will never see. Instead, we maintain a mapping of which theme needs which Google Fonts:

unknown node

The layout calls getGoogleFontsUrl(colorSchemeName) and injects the result as a <link> tag. If you are on the graphite theme, you download Montserrat, Inter, and Fira Code. If you are on cyberpunk, you download Outfit and Fira Code. Only the fonts you need. The cafeteria is open, but you only take what is on your plate.

The Reload Is Intentional (And That Is Okay)

"Wait," you say. "The user clicks a theme and the page reloads?" Yes. Here is the nuance.

Switching a color scheme is not like toggling dark mode. Dark mode changes a data-theme attribute — every CSS variable already has both light and dark values loaded in the current theme file. The switch is instant because both states are already in the CSS.

A color scheme swap replaces every CSS variable — background, foreground, primary, secondary, accent, muted, card, border, fonts, shadows, radii — the entire design system. Plus it needs to load different Google Fonts. The cleanest way to do this is to swap which CSS file the <link> tag points to. And that requires a reload.

But the reload does not flicker. The cookie is already set before window.location.reload() fires. The server reads it on the next request and serves the correct stylesheet from the first byte. The user clicks a swatch, sees a brief navigation, and lands on a page that is already correct. No intermediate state. No The Flicker. The Flicker is not even allowed in the building.

The Preview Grid (40 Themes, Zero Extra Stylesheets)

How do you preview 40 themes inside a modal without loading 40 stylesheets? Simple: you do not use the actual CSS variables. Instead, you store a lightweight preview object — just three OKLCH colors per mode (background, primary, accent):

unknown node

Each theme card renders six tiny circles — three for light, three for dark. The circles use inline style={{ backgroundColor: color }} — no dynamic CSS needed. Forty themes, 240 circles, zero stylesheet bloat. Your bundle size thanks you. Your Lighthouse score thanks you. Your users, who just want to pick a pretty color, definitely thank you.

Reading the Active Scheme (Without useState Gymnastics)

Here is a pattern I see developers get wrong. They try to read the active color scheme from a React context, a global store, or a URL parameter. All of these introduce unnecessary complexity. The cookie is the source of truth. Just read the cookie:

unknown node

The typeof document === "undefined" guard handles SSR gracefully — if we are on the server, we fall back to the default. On the client, we parse the cookie. Done. No context providers. No prop drilling. No state management library that costs more bytes than the feature itself.

Part 4: How It All Fits in the Header

In our header component, the dark mode toggle and color scheme picker sit side by side in the actions bar. The toggle fires the radial animation and writes the theme cookie. The picker opens a dialog with the 40-theme grid and writes the scheme cookie. Both cookies are read by the server on next render.

The architecture is symmetrical:

  • Dark mode: cookie (payload-theme) -> data-theme attribute -> @custom-variant dark -> every dark: utility responds. No reload needed.
  • Color scheme: cookie (colorScheme) -> server reads it -> correct CSS file imported -> every CSS variable is correct from first paint. Reload needed, but flicker-free.

Two cookies. Zero flicker. Forty themes. One radial animation that makes your designer weep with joy. This is the setup. This is the way.

Bonus: The Hydration Dance

One more thing. When the page first loads, we need to show the correct auth state — is the user signed in or not? But the server does not have the full session data. It only has a hasSession boolean passed from the layout.

Our header handles this with a simple hydration pattern:

unknown node

Before hydration, we trust the server's hasSession prop. After hydration, we trust the client's authClient.useSession(). The transition is seamless because both should agree (a logged-in user on the server is still logged in on the client). No flash of "Sign In" button for authenticated users. No flash of user avatar for anonymous visitors.

The Flicker does not discriminate. It attacks themes. It attacks auth state. It attacks anything that differs between server and client. The defense is always the same: make the server and client agree on the initial render, and defer the client-only truth until after hydration.

The Checklist

If your theming setup does not check all of these boxes, you have work to do:

  • Dark mode preference stored in a cookie, not just localStorage. The server must know the theme before rendering.
  • Custom Tailwind dark variant using data-theme attribute, not prefers-color-scheme.
  • Dark mode toggle uses View Transitions API with flushSync for instant, synchronous DOM updates.
  • Radial clip-path animation on ::view-transition-new(root) for that premium feel.
  • Default view transition animations disabled (animation: none on the pseudo-elements).
  • Color scheme stored in a separate cookie. Server imports the correct CSS file at render time.
  • Theme preview uses lightweight inline color swatches, not dynamically loaded stylesheets.
  • Hydration-safe auth state: SSR prop for initial render, client session after hydration.

The Flicker is dead. The radial animation is alive. Your users have 40 themes to play with. And you — you beautiful, detail-obsessed frontend developer — did not take a single shortcut to get here.

Now go toggle your dark mode. Watch that circle expand. You earned it.

ReactTailwindDark ModeTheming