ScrollTrigger patterns that survive Safari
Every ScrollTrigger animation that's failed in production has failed the same way: looked great in Chrome on a fast Mac, broke on iOS Safari at the worst possible moment. The platform is forgiving in dev and unforgiving in launch.
This is the three patterns I've shipped across four production sites that survived iOS Safari, mid-range Android, and reduced-motion preferences. And the fourth pattern I cut from the rotation because the cost-of-keeping-it-working got higher than the design win it delivered.
pattern 01 — pin + scrub at the right z-index
The pinning pattern: a section gets pinned at the top of the viewport, an internal animation runs as the scroll position passes through. Used for hero reveals, section transitions, big-typography moments.
The trap: pinSpacing: true (the default) inserts a real element into the document flow to hold the pinned section's space. On Safari, this interacts badly with z-index when the pinned section has transparent backgrounds and stacked content underneath. Z-index becomes non-monotonic. The pinned section sometimes renders above content it should be below.
The fix that holds: when the pinned section has transparent areas, use pinSpacing: false plus an explicit spacer element you control. You manage the height calculation yourself. More verbose; predictable.
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: "#hero",
start: "top top",
end: "+=120%", // pin holds for 120% of viewport height
pin: "#hero-content",
pinSpacing: false, // critical for transparent stacks
scrub: 0.8, // smooth interpolation, slight lag
});
// Explicit spacer — under my control, predictable on Safari
const spacer = document.createElement("div");
spacer.style.height = "120vh";
heroEl.parentElement?.insertBefore(spacer, heroEl.nextSibling);The cost: more code. The win: predictable behavior on Safari. Trade-off worth it for any hero where the pinned section overlaps other content.
pattern 02 — lenis + native momentum handoff
The pattern: smooth scroll provided by Lenis, native momentum scroll on iOS at the document edges. The challenge: making them cohabit without one cancelling the other.
The trap: Lenis intercepts wheel and touch events. On iOS, if Lenis runs the show end-to-end, the rubber-band momentum at top/bottom of page gets killed. The page feels heavy and unfamiliar.
The fix: configure Lenis with syncTouch: false and let iOS handle touch events natively at the document boundaries. Lenis manages mid-scroll easing on desktop wheel events; iOS keeps its native momentum at the edges. Both feel correct.
const lenis = new Lenis({
duration: 1.15,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
lerp: 0.1,
wheelMultiplier: 1,
touchMultiplier: 1.5,
syncTouch: false, // hands touch back to native iOS
autoRaf: true,
});The cost: slightly less consistent feel across desktop and mobile — they're not identical. The win: neither platform feels broken. Predictable degradation.
pattern 03 — chained reveals that respect reduced-motion
The pattern: a sequence of elements that fade or rise into view as the user scrolls — text, images, components — chained together.
The trap: ignoring prefers-reduced-motion. The animation looks beautiful for the majority; for the ~10% of users who've enabled reduced-motion at the OS level, the animation is actively unpleasant — it triggers vestibular symptoms or just feels broken.
The fix: check window.matchMedia("(prefers-reduced-motion: reduce)") at animation setup. If reduced motion is requested, skip the entrance animations entirely. The elements just appear in their final state. No transition.
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) {
// Reveal everything in final state, no animation
gsap.set(targets, { opacity: 1, y: 0 });
return;
}
// Otherwise the staggered reveal
gsap.from(targets, {
opacity: 0,
y: 32,
duration: 0.8,
stagger: 0.1,
scrollTrigger: { trigger: container.current, start: "top 80%" },
});The cost: writing the no-animation branch. The win: accessibility compliance and a meaningful improvement for users who needed it. Reduced motion is not an edge case — 5-15% of users have it enabled depending on the platform demographic. Worth supporting properly.
the pattern i cut — parallax-everything
The pattern I removed from my toolkit: applying parallax to every visual element on the page. The idea was layers of depth, foreground and background moving at different rates. Beautiful in motion-design portfolios, brutal in production.
Why I cut it:
- iOS Safari with parallax-everywhere produces janky frame drops on the 80th-percentile device
- Reduced-motion users see flat layout with weird empty spaces where parallax was supposed to fill
- Maintenance cost is high — every layout change requires re-tuning parallax offsets
The replacement: parallax used sparingly, on at most 1-2 distinctive elements per page. The hero portrait moves slightly slower than scroll; the rest of the page scrolls at native rate. Same general effect with 10% of the bug surface.
the meta-rule
The unifying rule I extract from production motion work:
Animations that pay their own cost survive. Animations that don't, eventually get cut.
The cost is iOS Safari edge cases, reduced-motion handling, maintenance under layout changes, performance on the 80th-percentile device. The pay is the feeling the animation produces in the moment a user lands on the page.
The three patterns above pay their cost. Pinning earns its complexity because the moment it creates — a hero or section that holds the eye — can't be produced any other way at comparable polish. Lenis earns its weight because the smooth-scroll feeling is the foundational tone of the site. Reduced-motion-aware reveals earn their branching because accessibility isn't optional.
The pattern I cut didn't pay its cost. Parallax-everywhere produced a minor aesthetic improvement in dev, against a major tax in production. Bad trade.
The discipline is being honest about that trade-off. Animations are seductive in dev. They have to earn their keep in prod.
