I told my friends I'd finally pushed my portfolio live. One of them pulled it up on his phone right there on the bench.
The header loaded. The grid behind the hero loaded. Then nothing. A big empty screen where the rest of my site was supposed to be.
I got roasted. On a roadside bench, for a site I had announced moments earlier. It was funny. It was significantly funnier for them.
The phone was an iPhone 13 Pro Max on the latest iOS, and I'm putting that up front because it kills the excuse I would otherwise have reached for. This was not a five-year-old budget phone dying on 2G. It was a flagship, on mobile data, and my site did not render.
First instinct: Safari. Blaming Safari is free and usually at least partly correct.
So he opened it in Chrome. Same shit.
That was the clue, and I completely missed it. Every browser on iOS is Safari wearing a different hat. Same WebKit engine underneath, whatever the icon says. "Try it in Chrome" isn't a second test on an iPhone, it's the first test again.
Then another friend opened it on his Samsung. Perfect. Smooth, exactly like my laptop.
Every iPhone broken. Every Android fine. My machine fine. Cool. Love that for me.
This was the genuinely miserable part.
I threw it at BrowserStack. BrowserStack told me the site was fine. Every iOS version I tried, fine, fine, fine.
Then I borrowed my sister's phone. Broken. My cousins' phones. Broken. Every real iPhone I could physically get my hands on failed the same way, while the one service whose entire job is reproducing this stuff kept insisting I was imagining it.
I still don't have a clean explanation for that gap. My best guess is that a fresh remote device on a fast connection never sits in the broken state long enough for you to catch it. Whatever the reason, the lesson stuck: when every real phone you can find disagrees with your testing service, believe the phones.
So I did it the stupid way. Deploy, check on my sister's phone, change something, deploy again. For about a week.
The next day I downloaded one of those apps that gives you a console on your phone. I genuinely don't remember which one, it was whatever came up first.
Then it took about ten seconds.
The content was there. All of it. Every paragraph sitting in the DOM at opacity: 0, waiting for JavaScript to turn up and give it permission to exist.
Nothing had failed to load. Nothing had crashed. My site had rendered perfectly and then hidden itself from everybody.
I wrote both of these on purpose, thinking I was being clever.
The first was the scroll reveal wrapped around every section:
<motion.div
initial="hidden"
whileInView="show"
variants={{ hidden: { opacity: 0, y: 24 }, show: { opacity: 1, y: 0 } }}
>
{children}
</motion.div>initial="hidden" is the killer. It bakes opacity: 0 straight into the server-rendered markup. The element doesn't come back until the bundle has downloaded, parsed, hydrated and hooked up an IntersectionObserver, and every single link in that chain is something that can be slow.
The second one was dumber. My hero entrance was a GSAP timeline, and to stop everything flashing in its final position before GSAP grabbed it, I'd written the obvious guard:
.hero-reveal { opacity: 0; }GSAP cleared that on load. So if GSAP didn't run, nothing cleared it, and the hero stayed invisible. Permanently. There's no route back from that state. I had written a line of CSS whose only job was to hide my own content, and then trusted a third-party library to undo it.
Which explains exactly what my friend saw on that bench. The header and the hero grid were plain HTML and plain CSS, so they painted. Everything else in the page was sitting behind a script that hadn't arrived yet.
I'd built a site where the decorative background was more reliable than the writing.
Honestly, none of this is iOS-specific, and that's the part worth taking away. Any browser anywhere will show you a blank page if the content is gated behind a script that hasn't run. The only variable is how long the gap lasts.
On my laptop that gap was short enough that I never once saw it while building the entire site. On that iPhone it was long enough to look like a broken website, and occasionally long enough that people gave up and started roasting me instead.
My first attempt was prefers-reduced-motion: reduce, wired up through JavaScript. Reasonable idea: if someone doesn't want motion, skip the animation and show everything.
It didn't help at all. The JavaScript still had to load before anything could decide anything, so you got the same blank wait, and then the entire page appeared at once in a single ugly slab.
I hadn't fixed the problem. I'd moved it. Same gate, different key.
The hero entrance is a staggered fade and rise. There was never a reason for that to be JavaScript. CSS has done this natively for years, so I deleted the timeline, deleted the guard, and wrote it properly:
@keyframes hero-fade-up {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: no-preference) {
.hero-badge,
.hero-sub,
.hero-cta,
.hero-terminal {
animation: hero-fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-badge { animation-delay: 50ms; }
.hero-terminal { animation-delay: 400ms; }
.hero-sub { animation-delay: 500ms; }
}Two things in there matter more than they look.
both as the fill mode. Without it you can't stagger with delays, because each element sits at its natural opacity until its delay runs out and then snaps into place. both tells the browser to apply the from state during the delay and hold the to state afterwards. The useful side effect is that the hidden state now lives inside the animation instead of in a rule of its own. Take the animation away and the element is just visible.
Then the no-preference wrapper, which is the direct fix for my failed attempt. Instead of animating by default and switching it off under reduce, the animation only exists inside no-preference. The plain stylesheet now describes a page that's already finished and static, and motion is the thing being added on top. Reduced motion stopped being something JavaScript had to decide and became the default I opt out of.
Visually it's the same entrance. Same easing, same delays. It just happens at first paint now, before the browser has even asked for the JavaScript.
These actually depend on scroll position, so I couldn't just delete the JS. What I could change was which direction the logic ran. Instead of rendering hidden and letting JavaScript release it, render visible and let JavaScript arm it.
export function useInViewReveal(once = true) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once, margin: '-80px' });
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
const animate: 'show' | 'hidden' = !hydrated || inView ? 'show' : 'hidden';
return { ref, animate };
}The hydrated flag is the whole trick. On the server it's false, so animate is 'show', so the markup that ships is visible. Pair it with initial={false} and Framer stops writing an initial state of its own:
<motion.div ref={ref} initial={false} animate={animate} variants={variants}>
{children}
</motion.div>There's now no moment where JavaScript hasn't run and the content is hidden, because "JavaScript hasn't run" and "visible" are the same state.
Once it hydrates, anything already on screen stays put, so nothing flashes and nothing re-animates a paragraph you're halfway through reading. Anything off screen drops to hidden and reveals on scroll like before. On a decent connection you cannot tell that any of this changed.
One thing I got wrong on the first pass: I'd made the staggering container a motion element too, with its own hidden variant. Doesn't matter how careful the children are if the parent can still hide them. RevealGroup is a plain div now that hands each child an incremental delay and has no opinion about visibility.
requestAnimationFrame loop during hydration helps exactly the phones that need help.position: sticky and became a server component. One less scroll library, and under reduced motion it collapses to a normal vertical stack for free.overflow-x: clip on body went back to hidden. clip is the better property and I'd rather use it, but Safari didn't ship it until 16, and an overflow value an older iPhone doesn't understand is not a bug you find quickly."Modern CSS" and "CSS that works on the phone your friend is holding" are not the same list.
None of it is hard to find once you know to look. It's just completely invisible from where I was standing.
The check that would have saved me a week: turn JavaScript off and reload. DevTools, command palette, Disable JavaScript. If the page still reads top to bottom you're fine. If it's blank, congratulations, you have my bug. Four seconds.
Then throttle properly, and watch the first two seconds rather than the finished state. The finished state always looks great. That's exactly why nobody catches this.
Read the SSR HTML instead of the DOM, too. The inspector shows you the page after hydration, which is precisely the version that's lying to you. curl the URL and grep it for opacity:0. Anything sitting on real content is a problem waiting for a worse connection.
And test on a real phone. Not a simulator, not a remote device farm that will cheerfully tell you everything is fine. A real one, in your hand, on cellular.
I'm not going to pretend this ended with a perfect Lighthouse score and a lesson learned. It's still not as fast as I want. The entrance is all hand-written CSS now with no motion library involved at all, and I'm still going back and forth on whether to do reduced motion through a proper hook instead of by hand.
But it renders. Immediately, on every phone I can get hold of, whether or not the JavaScript ever turns up. That's a low bar, and I had somehow built a site that couldn't clear it.
The thing had been live and broken for about a week before that bench. I have no idea who else opened it in that window. Probably nobody. I've decided to believe it was nobody.