The Safari theme-sync quirk no AI could fix.
How a fixed-shell mobile portfolio stopped fighting iPhone Safari’s toolbar and learned to feed it instead, without giving up the swipe/snap deck UX.
Try it on your phone
If you have an iPhone handy, open this site in Safari, scroll to the top, and tap the theme toggle in the menu. The status area at the top and the toolbar at the bottom should change color along with the page. On most sites that just works. On this one, for a while, it didn’t.
The page would switch themes immediately. Safari’s chrome would lag a beat behind, sometimes a full beat, sometimes forever until the user pulled down to refresh. Same story at the bottom toolbar. It looked like a bug in the toggle, but the toggle was fine. Safari was looking at something the page wasn’t giving it.
I threw this at every coding agent I had access to over the course of a few weeks. Multiple frontier models, multiple sessions, every time with a fresh handoff doc summarizing what had already failed. None of them landed the fix. The actual workaround turned out to be small enough that I’m not sure it was anywhere in their training data.
This is a short post about what Safari is actually sampling on iPhone, why the obvious fixes (theme-color, painting the body) didn’t help in this layout, and the small workaround that did.
Why this site is weird
The mobile portfolio isn’t a normal scrolling page. It’s a fixed full-screen shell with an inner scroller that snaps between the about slide and each project card.
In CSS terms: the outer container is position: fixed; inset: 0; overflow: hidden; and the deck inside it is position: absolute; inset: 0; overflow-y: auto; with scroll-snap-type: y mandatory. The body never actually scrolls. The intersection observer that drives the right-rail page indicator runs against the inner deck, not the document.
I like that architecture for what this site does. Each card gets its own viewport-sized canvas, swipes feel deliberate, the rail dots are honest. But it isn’t how most sites are built, and as I learned, it isn’t how Safari assumes a page is built either.
The first thing you reach for
The standards-friendly answer is <meta name="theme-color">. You can ship a static one, or you can update it at runtime when the theme changes by patching the meta tag in place. I tried both. A static pair with media="(prefers-color-scheme: dark)" variants, then a small client component that watched next-themes and rewrote the tag.
Neither moved Safari reliably on this layout. The toolbar would sometimes update, sometimes not, and “sometimes not” is a worse experience than “consistently wrong,” because the user starts wondering what they did differently between attempts.
So I deleted the runtime sync. It was earning its rent in nobody’s apartment.
The next instinct, which broke desktop
The next thing you try is forcing background colors at the root. Paint html and body to match the theme. If Safari is sampling the document’s actual surface, that should give it something obvious to work with.
It does. It also instantly changes desktop, because html and body aren’t mobile-only. The portfolio’s desktop background is a layered radial-gradient composition that reads correctly because nothing is fighting it from below. Drop a flat fill behind it and the whole composition flattens.
Reverted. Whatever the mobile fix was, it had to live behind a media query.
The fix, in two layers
Two responsibilities. First, tell the standards stack what’s happening so Safari’s defaults play nice. Second, give it something to actually look at.
viewport-fit=cover
Let the document actually extend under Safari’s toolbars
<meta name="color-scheme">
Tell the standards stack which schemes the page supports, early
:root color-scheme + html.dark rules
Make the active scheme explicit at the document root, not just inside components
Top + bottom mobile edge strips
Two fixed, theme-driven surfaces behind the deck for Safari to sample
Layer one: the standards signals
The first three bullets are housekeeping. In app/layout.tsx:
export const viewport: Viewport = {
viewportFit: 'cover',
}
// in the document head:
<meta name="color-scheme" content="dark light" />And in app/globals.css:
:root { color-scheme: light dark; }
html.dark { color-scheme: dark; }
html:not(.dark) { color-scheme: light; }None of this is exotic. The point is that all three signals agree. Safari sees a viewport that extends under the toolbars, an early declaration of supported color schemes, and an explicit per-theme-class rule that resolves whichever one is active. That alone didn’t fix sync, but it stopped giving Safari conflicting hints about what to render.
Layer two: the edge strips
The piece that actually moved the toolbar was two fixed strips, one at each edge of the viewport, behind the rest of the mobile content. The markup lives in components/mobile-portfolio.tsx right next to the deck container:
<div className="mobile-portfolio">
<div className="m-browser-edge m-browser-edge-top" aria-hidden="true" />
<div className="m-browser-edge m-browser-edge-bottom" aria-hidden="true" />
{/* deck and content sit at z-index: 1 above */}
</div>The styling lives in app/mobile.css:
.m-browser-edge { position: fixed; left: 0; right: 0; pointer-events: none; }
.m-browser-edge-top {
top: 0; z-index: 0;
height: calc(env(safe-area-inset-top, 0px) + 64px);
background: var(--mobile-edge-top);
}
.m-browser-edge-bottom {
bottom: 0; z-index: 0;
height: calc(env(safe-area-inset-bottom, 0px) + 72px);
background: var(--mobile-edge-bottom);
}The two colors are CSS variables defined in :root and overridden under .dark:
:root {
--mobile-edge-top: #e7ecf5;
--mobile-edge-bottom: #f1e6df;
}
.dark {
--mobile-edge-top: #161b26;
--mobile-edge-bottom: #120f10;
}The strips sit at z-index: 0 beneath the deck (which sits at z-index: 1), so they don’t block taps and don’t draw over the cards. They aren’t there for the user. They’re there so Safari has a stable, theme-synced surface to read at the top and bottom of the viewport. About 30 lines total, and that’s the part that made theme switches sync without a refresh.
Hiding the seam
The first version of those strips looked terrible.
The top edge sat at one solid color. The about slide started at a different color underneath it. The seam between them read as a hard band across the screen. It synced Safari, sure, but it looked like I’d left an unfinished placeholder at the top of the page.
The fix was the inverse of the original instinct. Instead of trying to make Safari ignore the strip, I made the about slide blend into it. The first 188px of the about slide background is a linear gradient from the top-edge color down to the page background:
.m-about-bg {
background:
linear-gradient(180deg, var(--mobile-edge-top) 0%, var(--background) 188px),
/* the rest of the about-slide gradients sit below this */
radial-gradient(120% 90% at 50% 0%, hsl(var(--blue-strong) / 0.26), transparent 60%),
radial-gradient(90% 60% at 0% 100%, hsl(var(--peach-strong) / 0.24), transparent 60%);
}Safari samples one consistent color near the top of the viewport. The user reads it as “the top of the about screen fades into the toolbar,” which is the look I actually wanted in the first place. Same color, two consumers.
The bottom edge didn’t need the same treatment. The deck cards already extend to the bottom of the viewport with their own dark gradient, so the bottom strip just fills the safe-area bezel and that’s all it needs to do.
Sizing the dial
Two sliders matter, and they push in opposite directions.
If the strips are too short, Safari ignores them. I tried 8px and got nothing. 16px gave me partial behavior. 32px was intermittent. The current heights are 64px at the top and 72px at the bottom, plus the safe-area inset on each. That’s enough that Safari treats the strip as the relevant edge surface instead of treating it as decoration.
If they’re too tall (or just too opaque without the about-slide blend), you can see them. Even with the right color, a flat slab across the top of a swipe deck breaks the “this is one continuous canvas” feeling I want from the deck. The about-slide blend is what lets the strip be tall enough to influence Safari without showing up as its own element.
Once both numbers were dialed in, the page felt like it had a translucent toolbar treatment, even though the toolbar is just Safari rendering on top of a solid color. The user can’t tell.
Things I sank time into that didn’t help
- Runtime sync of
meta[name="theme-color"]. Did less than I hoped, and the React effect that ran on every theme change wasn’t free either. - Painting
htmlorbodydirectly. Worked for Safari, broke desktop. Mobile-only or nothing. - Tiny, near-invisible samplers. Looked clean. Did nothing for the toolbar.
- Large visible overlays at the top of the page. Worked. Looked broken.
- Refactoring the deck to use page scrolling. Safari behavior would have followed the rest of the web’s defaults, but the snap UX I wanted is an inner-scroller pattern. Reverted within an hour.
If this regresses
Note to future me. If iPhone Safari starts lagging on theme switches again, don’t start by adding theme-color back. Check these in order:
viewportFit: 'cover'is still set inapp/layout.tsx.<meta name="color-scheme" content="dark light">is still in the document head.:rootstill hascolor-scheme: light dark, and the explicithtml.dark/html:not(.dark)rules are still inglobals.css.- The two mobile-edge variables are still defined, and overridden per-theme.
- The
m-browser-edge-topandm-browser-edge-bottomdivs are still rendered insidemobile-portfolio.tsx, and still sized large enough to influence Safari. - The about slide’s top blend still uses the same
--mobile-edge-topcolor.
That’s the whole fix. No magic component, no listener, no animation frame. Markup, a couple of CSS variables, and a layout decision about who’s allowed to know what color goes near the edges of the screen.
The honest takeaway is that Safari on iPhone isn’t fully controlled by theme-color when your page isn’t the thing scrolling. Once I stopped trying to push the toolbar around with metadata and started feeding Safari the right pixels at the right z-index, it behaved.