On a scroll-scrubbed hero, iOS painted a solid black panel where the video should have been. Desktop Chrome was fine. Playwright on macOS WebKit was fine. The bug only appeared on a real iPhone. There were actually two separate problems: one in how iOS handles video readiness, and one in how trigger points were computed for reveal animations. Neither showed up in CI.
iOS Holds a Paused Video at readyState 1
The scroll-scrub works by setting currentTime on a video element as the user scrolls. That only works if the browser has decoded frame data. On desktop, browsers fetch enough of the video to reach readyState 4 (HAVE_ENOUGH_DATA) even when the video is paused. iOS does not. A paused video on iOS sits at readyState 1 (HAVE_METADATA): the browser knows the duration and dimensions, but it has not loaded any frame data at all.
Every currentTime assignment queued against a readyState 1 video is accepted without error and then silently goes nowhere. The seeking event fires, the seeked event never does, and the canvas stays black.
The probe script (scripts/ios-hero-probe.mjs) served the build to the iPhone simulator and stepped through eleven scroll positions, asserting a decoded frame at each one. Before the fix: all eleven steps showed readyState 1, seeking: true, luminance 0. After the fix: readyState 4, no pending seeks, luminance between 43 and 72 across the steps. The full vitest suite passed at 65 tests.
The Fix: Prime the Decoder with play() then pause()
The only way to get iOS to load frame data is to actually start playback, even briefly. The fix is:
- Muted and playsinline are required. Without both attributes, iOS rejects the
play()call in a non-gesture context. - Call
play()inside theloadedmetadatahandler. That is the earliest point where the call is safe. - Immediately call
pause()once the play promise resolves. The user never sees or hears anything. - Remove the poster only after the promise resolves. This is the part that was wrong before: the poster was being removed on
loadedmetadata, before any frame existed, so the black canvas was exposed while the priming was still pending.
One thing that made no difference: whether the video was served via a blob URL or a direct URL. The readyState behaviour was identical either way.
Low Power Mode Is a Hard Limit
When Low Power Mode is active, iOS can refuse the play() call entirely. The promise rejects. There is nothing a web page can do to override that. The implementation handles it by leaving the poster in place on rejection rather than revealing a black frame. The scroll-scrub will not function in that state, but the page still looks intentional rather than broken.
This is worth being clear about: the play-then-pause trick does not fix Low Power Mode. It fixes the normal iOS case where the browser simply has not fetched frame data yet.
The Second Bug: Stale ScrollTrigger Points
Separately, the reveal animations for panels below the hero were firing at the wrong scroll positions. Elements would appear too early or not at all, and the behaviour looked intermittent. The pattern was: visitors who scrolled up and then back down would see correct reveals; visitors who scrolled straight down from the top would not.
The cause is straightforward. ScrollTrigger computes trigger offsets at initialisation time. If content below the hero is rendered or injected after that calculation, the page becomes taller than ScrollTrigger's model. Every trigger point is now wrong by however much the page grew. The accidental fix of scrolling up and down triggered a ScrollTrigger refresh, which is why it appeared to self-correct.
Switching to IntersectionObserver
The site moved reveal gating to IntersectionObserver. The reasons it works better here:
- No precomputed offsets. The observer fires relative to the viewport at the moment of intersection, so there is nothing to go stale.
- Immediate resolution for already-visible elements. Anything in the viewport when the observer is attached is reported as intersected straight away. Elements already scrolled past are shown outright rather than waiting for a scroll event.
- No dependency on page height being stable at init. Content can grow below the hero without affecting any reveal calculation.
The tradeoff is that IntersectionObserver gives less control over the exact scroll position that triggers a reveal. For a simple show-on-enter pattern that is fine. For something that needs precise scrubbing tied to scroll progress, you would still need ScrollTrigger, but you would also need to call ScrollTrigger.refresh() after any content change.
What the Probe Script Actually Checked
The scripts/ios-hero-probe.mjs approach is worth noting. Rather than relying on manual QA on a device, the script:
- Serves the production build locally
- Drives the iPhone simulator through eleven scroll steps
- Asserts
readyState, theseekingflag, and the luminance of a sampled frame at each step
Luminance 0 means a black frame. Luminance in the 43 to 72 range means real content is visible. That gave a reproducible, automatable signal for a bug that was otherwise invisible in CI because Playwright on macOS WebKit does not replicate iOS's video loading behaviour.
If you are working on scroll-driven video and wondering why your tests pass but real phones fail, this is likely the gap. See also the notes on the smoke transition for related thinking about what desktop WebKit does and does not predict about mobile Safari.
What I'd do differently
- Run the device probe earlier. Both bugs existed from the first implementation. A probe script on day one would have caught the readyState issue before it reached a real user.
- Treat page-height stability as an explicit assumption. If a component's trigger points depend on the page being a certain height, that assumption should be documented and tested, not discovered when it breaks.
- Default to IntersectionObserver for simple reveals. ScrollTrigger is powerful but it carries the cost of needing accurate page geometry at init. For show-on-enter patterns, IntersectionObserver is simpler and more robust.
- Add a luminance assertion to the standard test suite. The probe script was written reactively. It should have been part of the suite from the start.




