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 fromnext-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 />
insideThemeProvider
: This is the most critical part. By placing<ThemeUpdater />
insideThemeProvider
, 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 likeuseEffect
anduseState
.useTheme()
Hook: This hook connects to theThemeProvider
we set up inlayout.tsx
and retrieves the current theme. We useresolvedTheme
, 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.- The code inside
useEffect
runs after the component renders. - The key is the dependency array at the end:
[resolvedTheme]
. This tells React to re-run the effect every time theresolvedTheme
value changes.
- The code inside
- The Process:
- When I switch the theme (e.g., from light to dark), the
ThemeProvider
updates its state. - The
useTheme()
hook inThemeUpdater
receives the newresolvedTheme
value ("dark"). - Because
resolvedTheme
has changed, theuseEffect
hook is triggered again. - The code inside the effect runs, finds the
<meta name="theme-color">
tag in the document's<head>
, and sets itscontent
to thedarkColor
.
- When I switch the theme (e.g., from light to dark), the
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.