Putting CSS’s more recent scrolling animation capabilities to the test to recreate a complex animation of the Apple Vision Pro headset from Apple’s website.
Scroll-driven CSS is cute until Safari turns up and pretends your timeline property is fan fiction.
I haven’t tried this Vision Pro one, but I’d be staring straight at the fallback when animation-timeline: view() isn’t there—half-frozen “premium” hero animations look broken, not fancy. Default to a static end state, then gate the scroll stuff behind @supports so unsupported browsers just get a normal page.
The “half-frozen hero” thing is real, and it gets worse once you remember prefers-reduced-motion exists. Even on browsers that support scroll timelines, you should ship a no-motion version by default and only opt into the scroll animation when motion is allowed; otherwise you’re building a page that’s broken-by-accessibility on purpose. Something like:
.hero { transform: translateZ(0); } /* static */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.hero { animation: vp 1s linear both; animation-timeline: view(); }
}
}
I found a related kirupa. com article that can help you go deeper into this topic:
Scroll-linked stuff can look slick, but it falls apart fast once you hit prefers-reduced-motion and the “half-frozen hero” state on unsupported browsers. I’d ship a static hero that looks finished, then only opt into the scroll timeline when motion is explicitly allowed and the feature exists.
One more footgun: even when animation-timeline works, you can make scrolling feel awful by animating paint/layout-heavy properties (filters, big blurred backdrops, chunky shadows). Keep it boring—transform/opacity—and sanity-check in DevTools Performance that you’re not repainting the whole hero on every scroll tick. And yeah, I’m not 100% sure why, but I’ve seen macOS “Reduce motion” and browser settings disagree, so the static baseline saves you from weird mismatches.
.hero {
transform: translate3d(0,0,0);
opacity: 1;
}
/* only animate when motion is allowed + the browser supports scroll timelines */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.hero {
will-change: transform, opacity;
animation: vp 1s linear both;
animation-timeline: view();
}
}
}
@keyframes vp {
from { transform: translateY(24px); opacity: .85; }
to { transform: translateY(0); opacity: 1; }
}
Even with “safe” properties like transform, a giant sticky hero can still feel kind of heavy because you’re basically asking the compositor to babysit a huge texture every scroll tick.
I’ve had some luck boxing it in so the expensive pixels don’t leak out—contain: paint plus overflow: clip/hidden on the hero (or an inner wrapper) made a big “poster” section smoother for me. I’m not 100% sure it’s consistent across browsers, but it’s one of the few tweaks that felt real without changing the design.
“giant poster” is exactly the vibe — it’s the layer size more than “is transform safe.” I’ve gotten the best win by putting the sticky stuff in an inner wrapper that’s overflow clipped + contain: paint, then keeping blur/drop-shadow off that wrapper (push the fancy effects onto a smaller child) because those effects feel like they turn the whole texture into a scroll tax.
I’ve done the will-change / translateZ(0) nudge too, but I’m never totally sure when it’s a real improvement vs Chrome just allocating a bigger layer and calling it “optimized.”
Blur/drop-shadow on a big sticky layer will absolutely turn into a repaint party — it’s like you’ve asked Chrome to drag a wet poster down the page. Keeping the clipped/contained wrapper “boring” and moving the fancy bits onto a smaller child is the only approach I’ve seen stay smooth.
will-change is still a bit of a dark art to me; half the time it feels like “congrats, you now have a larger layer” and the bill shows up later as VRAM + jank on laptops.
Yeah, this matches what I’ve seen too: big sticky + blur/drop-shadow is basically “please repaint me every frame.” The only way I’ve kept stuff like this tolerable is exactly what you said—keep the sticky container plain (no filters, no shadows), then put the filtered/shadowed bits on a smaller absolutely-positioned child so the expensive pixels don’t cover the whole viewport.
On will-change, fwiw I’m still not 100% sure when it’s a net win either; it can help, but it’s easy to accidentally promote a giant layer and then everything gets worse on integrated GPUs.