An Apple product page rests on no magic library. Open the JavaScript bundles shipped in production and you find three distinct techniques, stackable, all reproducible. What follows comes from inspecting apple.com directly, not from second hand articles.
What is actually in the code
iPhone 17 Pro: a 3D engine with its own shaders
The main.built.js bundle weighs 680 KB and contains a ProductViewerWebGL singleton with its own scene loader and MobX reactions. The most telling part is the hand written GLSL:
uniform float focalPoint;
uniform float near;
uniform float far;
layout(location = 0) out vec4 gColor;
layout(location = 1) out vec4 gDepth;
Two render targets, one for color, one for depth, plus a focal point: Apple runs post processed depth of field on the phone model. The colorway picker is not an image carousel, it calls onColorChange on the scene and swaps the material live. Rotation is wired to pointermove. This is the most ambitious of the three.
AirPods Pro: video scrubbing, over adaptive streaming
The heaviest bundle of the three (881 KB) and the most teachable page. The DOM says it out loud: .video-scrub-container, .sticky-image, .scroll-item-driver, .scroll-copy. The JS holds a scrubKeyframe() method mapping scroll position onto a sequenceKeyframes array.
The detail most tutorials miss: Apple no longer uses an image sequence. The code runs this.isSafari ? this._requestVideo() : this._requestVideoStream() and loads hls.js from /ac/libs/hls.js/. Safari plays the video natively, every other browser goes through adaptive HLS, and the browser only downloads the segments the user actually scrolls through. A 300 frame JPEG sequence does the exact opposite.
MacBook Pro: the in house keyframe engine
The least spectacular page, and by far the most instructive. Apple’s internal animation engine is visible here: a small keyframe language with its own expression parser supporting clamp, lerp, smoothstep, round, cos, sin, atan.
anim.createScrollGroup().addKeyframe(el, {
start: "a0t - 100vh",
end: "lerp(0.756, a0t, a0b - 100vh)"
})
// and above all, driven from CSS:
{ start: "css(--hz-hardware-kf-start, a0)",
end: "css(--hz-hardware-kf-end, a0)" }
a0t is the top of anchor 0, a0b its bottom, offsets are expressed in vh. The direct consequence: animation start and end points are tuned in CSS, so responsive breakpoints live in the stylesheet rather than in JavaScript.
Two more things show up. A spring system (spring.setTarget("scale", ...), snapValue, a bounce preset) instead of fixed easing curves. And an explicit will-change lifecycle, with _addWillChange and _removeWillChange fired over the range { start: "t - 150vh", end: "b + 50vh" }. That is exactly what separates a smooth page from one that spins up the fan.
The build order, A to Z
Step 1: the track
Every animated section is a tall container holding a sticky child. The container height is the scroll duration.
<section class="scene">
<div class="scene-sticky">
<video class="scene-video" muted playsinline preload="auto"></video>
<p class="scene-copy">The copy that goes with it</p>
</div>
</section>
.scene { height: 400vh; }
.scene-sticky {
position: sticky;
top: 0;
height: 100vh;
overflow: clip;
}
Then a single value, normalized between 0 and 1, that every other layer reads. Nothing else.
function progress(section) {
const r = section.getBoundingClientRect()
const total = r.height - window.innerHeight
return Math.min(1, Math.max(0, -r.top / total))
}
Step 2: video scrubbing
One video tag, never a play() call. The only thing that moves is currentTime.
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = '/media/scene.m3u8' // Safari: native
} else {
const hls = new Hls({ maxBufferLength: 8 })
hls.loadSource('/media/scene.m3u8')
hls.attachMedia(video)
}
Encoding matters as much as the code. A short GOP makes seeking land on the right frame instead of the last keyframe.
ffmpeg -i source.mov -c:v libx264 -crf 20
-g 10 -keyint_min 10 -sc_threshold 0 -an out.mp4
Step 3: smoothing
The raw scroll value must never land straight in a style. It goes through a lerp or a spring, and only the smoothed value gets written.
let target = 0, current = 0, raf = null
function onScroll() {
target = progress(section) * video.duration
if (!raf) raf = requestAnimationFrame(tick)
}
function tick() {
current += (target - current) * 0.12
video.currentTime = current
if (Math.abs(target - current) < 0.001) {
current = target
raf = null // idle page: zero frames
} else {
raf = requestAnimationFrame(tick)
}
}
Spring for anything that should feel physical (a scale, a panel opening). Lerp for anything that must land exactly on a value (currentTime, an opacity ramp).
Step 4: keyframes driven by CSS
This is the idea worth stealing from the MacBook Pro page. Each animation’s bounds live in CSS, so responsive tuning never touches JavaScript.
.scene { --copy-start: .15; --copy-end: .40; }
@media (max-width: 734px) {
.scene { --copy-start: .05; --copy-end: .55; }
}
const css = (el, n) => parseFloat(getComputedStyle(el).getPropertyValue(n))
const sub = (p, a, b) => Math.min(1, Math.max(0, (p - a) / (b - a)))
const smoothstep = t => t * t * (3 - 2 * t)
const t = smoothstep(
sub(p, css(scene, '--copy-start'), css(scene, '--copy-end'))
)
copy.style.opacity = t
copy.style.transform = `translate3d(0, ${(1 - t) * 24}px, 0)`
Four pure helpers are enough: clamp, lerp, smoothstep, sub. No bespoke math per section.
Step 5: real time 3D, if the budget allows
This is the iPhone 17 Pro tier. One .glb model, a material swap for colorways, rotation wired to the same progress value, plus a small pointer parallax. The 3D bundle loads dynamically, only as the section approaches the viewport. Below that budget, a scrubbed video gives 80% of the result for 10% of the cost.
Step 6: performance, where it is won or lost
Reproduce Apple’s will-change lifecycle, with the same margins:
const io = new IntersectionObserver(
([e]) => el.classList.toggle('will-change', e.isIntersecting),
{ rootMargin: '150% 0px 50% 0px' } // t - 150vh / b + 50vh
)
io.observe(section)
The rest is three rules: animate transform and opacity only, one requestAnimationFrame loop for the whole page, never write to the DOM inside a scroll listener. If the motion layer ships more than roughly 300 KB of JavaScript, remove a layer.
Step 7: accessibility
All three Apple pages read prefers-reduced-motion at boot, store it in a single model and expose it as a class on the page. Copy that behaviour:
@media (prefers-reduced-motion: reduce) {
.scene { height: auto; }
.scene-sticky { position: static; height: auto; }
.scene-video { display: none; }
.scene-poster { display: block; }
}
Non negotiable rule: copy stays real DOM text, never baked into the video. Otherwise the page tells nothing once motion is disabled, and search visibility goes with it.
The checklist before shipping
- One
requestAnimationFrameloop for the whole site, stopping when nothing moves. - HLS video, short GOP, never an image sequence.
- Animation bounds as CSS variables, not JavaScript constants.
will-changeadded and removed dynamically, never set permanently.- Only
transformandopacityanimated. - Inline hero poster so LCP does not wait.
- A
prefers-reduced-motionversion that still tells the whole story. - Tested on a real mid range phone, not on the MacBook simulator.
For the generic scroll layer underneath all this (inertia, reveals, pinning), see Building genuinely high-end scroll animations.