Sep 15, 2025

720 words

4 m

15 reads

Matching theme color to browser ui elements

My site's theme switching worked flawlessly—colors changed instantly, icons flipped, the entire UI responded smoothly. But there was one detail that kept bothering me.

On my Safari and Arc browser's top bar color wasn't adapting realtime to the background color of my website. I would switch to dark mode, and that small colored bar at the top of mobile browsers would remain light until they refreshed the page.

Such a minor detail. But it felt fundamentally wrong.


1. The Problem with Static Metadata

Next.js loves static generation for good reasons—it's fast, SEO-friendly, and reliable. But sometimes static approaches break the very user experience you're trying to create.

I had this configuration in my layout.tsx:

export const viewport: Viewport = {
  themeColor: [
    { media: "(html:has(.light)", color: "#f0f4ff" },
    { media: "html:has(.dark)", color: "#0a0f1f" },
  ],
};

This looks elegant. CSS media queries automatically detecting theme preferences. It should work perfectly.

It doesn't.

The fundamental issue is timing. This viewport configuration gets baked in at build time. It's static metadata that can't respond to JavaScript-driven theme changes from next-themes. The browser evaluates the media query, but by the time your theme provider adds the .dark class to the HTML, the viewport color is already determined.

You're stuck with whatever the browser initially detected, regardless of user interaction.


2. The Real Solution

1. The Foundation in layout.tsx

In my src/app/layout.tsx file, the application is wrapped with the <ThemeProvider>.

// src/app/layout.tsx
// ...
<ThemeProvider
  defaultTheme="system"
  attribute="class"
  enableSystem
  disableTransitionOnChange
>
  <ThemeUpdater />
  {children}
</ThemeProvider>
// ...
  • ThemeProvider: This component from next-themes is the brain of the theme system. It keeps track of the current theme ("light", "dark", or "system") and makes this information available to any child component that asks for it.
  • <ThemeUpdater /> inside ThemeProvider: This is the most critical part. By placing <ThemeUpdater /> inside ThemeProvider, we ensure it has access to the theme context.

2. The Logic in ThemeUpdater.tsx

In the src/components/ThemeUpdater.tsx component

// src/components/ThemeUpdater.tsx
"use client";

import { useTheme } from "next-themes";
import { useEffect } from "react";

// ... color constants ...

export function ThemeUpdater() {
  const { resolvedTheme } = useTheme();

  useEffect(() => {
    // ... logic to find or create the meta tag ...
    metaThemeColor.setAttribute(
      "content",
      resolvedTheme === "dark" ? darkColor : lightColor,
    );
  }, [resolvedTheme]);

  return null;
}
  • "use client": This directive marks the component as a Client Component, allowing it to run in the browser and use React hooks like useEffect and useState.
  • useTheme() Hook: This hook connects to the ThemeProvider we set up in layout.tsx and retrieves the current theme. We use resolvedTheme, which gives us the actual active theme ("light" or "dark"), even if the user's preference is "system".
  • useEffect Hook: This is where the magic happens.
    1. The code inside useEffect runs after the component renders.
    2. The key is the dependency array at the end: [resolvedTheme]. This tells React to re-run the effect every time the resolvedTheme value changes.
  • The Process:
    1. When I switch the theme (e.g., from light to dark), the ThemeProvider updates its state.
    2. The useTheme() hook in ThemeUpdater receives the new resolvedTheme value ("dark").
    3. Because resolvedTheme has changed, the useEffect hook is triggered again.
    4. The code inside the effect runs, finds the <meta name="theme-color"> tag in the document's <head>, and sets its content to the darkColor.

So, in short: Yes, it works every time I switch themes. The ThemeUpdater is constantly listening for changes from the ThemeProvider and immediately updates the browser's viewport color to match.


3. Details Matter to me

You might question whether anyone actually notices a small viewport color discrepancy. But here's the truth about user experience: consistency often represents quality.

When users interact with your theme switcher and see instant, seamless transitions everywhere except one small detail, it breaks the illusion of polish. They may not consciously identify the issue, but they sense something is incomplete.

The difference between "this feels smooth" and "something's slightly off" often comes down to these micro-interactions.

We all should care about getting these details right. Not because the technical challenge is complex—this solution is actually quite simple—but because it demonstrates genuine attention to the complete user experience.

MORE