KOMPOSE.
All resources
WebsiteClaude

Build an Apple-style product page from A to Z (the code teardown)

The problem it solves

Apple product pages look out of reach, yet they rest on five stackable layers that are all reproducible. This resource opens the production JavaScript bundles of iPhone 17 Pro, AirPods Pro and MacBook Pro, shows what each one actually does (hand written GLSL shaders, HLS video scrubbing, a CSS driven keyframe engine), then gives the build order and the full prompt.

The prompt to copy

Optimized withClaude
You are building an Apple-style product page: a long scroll where the visuals are driven by scroll position rather than by autoplay, and where all motion belongs to one shared system instead of per-component animations.

Stack: Next.js (App Router) + TypeScript, or plain HTML/CSS/JS if no framework is wanted. GSAP + ScrollTrigger for orchestration, hls.js for the scrubbed video, react-three-fiber + three.js only if layer 5 is requested. Never stack two animation libraries.

Build it in this order and do not skip a layer.

LAYER 1: THE TRACK
- Every animated section is a tall container (300vh to 500vh) holding one child with position: sticky; top: 0; height: 100vh.
- Expose a single normalized progress value per section: 0 when the sticky child locks, 1 when it unlocks, computed from getBoundingClientRect. Every other layer reads that number and nothing else.
- One requestAnimationFrame loop for the whole page, never one per component. Never write to the DOM inside a scroll listener.

LAYER 2: SCRUBBED VIDEO
- Use a single  and map progress to video.currentTime. Do not use a PNG or JPEG image sequence: heavier, slower to start, worse on mobile.
- Serve the clip as HLS. On Safari set video.src to the .m3u8 directly (native support), everywhere else attach hls.js. The browser then downloads only the segments the user actually scrolls through.
- Encode with a very short GOP so seeking lands on the right frame:
  ffmpeg -i source.mov -c:v libx264 -crf 20 -g 10 -keyint_min 10 -sc_threshold 0 -an out.mp4
  then segment to HLS with 2 second parts.
- Never call video.play(). The only thing that moves the video is currentTime.

LAYER 3: SMOOTHING
- Never assign a raw scroll value to a style. Pass it through a lerp (current += (target - current) * 0.1) or a spring, and write only the smoothed value.
- Spring for anything that should feel physical (scale, a panel opening, card weight). Lerp for anything that must land exactly on a value (video currentTime, opacity ramps).
- Snap to target when the delta drops below 0.001 and cancel the frame loop, so an idle page costs nothing.

LAYER 4: KEYFRAMES DRIVEN BY CSS
- Do not hardcode animation start and end points in JS. Read them from CSS custom properties on the section, so responsive breakpoints stay in the stylesheet:
  .scene { --copy-start: .15; --copy-end: .40 }
- In JS: sub(progress, start, end) clamped to 0..1, then smoothstep (t*t*(3-2*t)) before applying.
- Compose only from a small set of pure helpers: clamp, lerp, smoothstep, round. No bespoke math per section.

LAYER 5: REAL TIME 3D (only if asked)
- react-three-fiber, one .glb model, a material swap for colorways, camera or object rotation driven by the same progress value, plus pointermove for a small parallax.
- Load the 3D bundle dynamically, and only when the section is within one viewport of the fold.

PERFORMANCE RULES (non negotiable)
- Add will-change through a class only while the section sits within 150vh before and 50vh after the viewport, and remove it outside. A permanent will-change on a long page destroys performance.
- Animate transform and opacity only. Never top, left, width or height.
- decoding="async" everywhere, lazy media below the fold, an inline hero poster so LCP does not wait for the video.
- Budget: if the motion layer ships more than roughly 300 KB of JavaScript, cut a layer.

ACCESSIBILITY
- Read prefers-reduced-motion once at boot, store it in a single flag, expose it as a class on .
- When reduced: sections collapse to normal height, sticky becomes static, the video is replaced by its end frame or poster, copy is simply visible. The page must still tell the whole story.
- Keep copy as real DOM text at all times, never baked into the video.

DELIVERABLE
Build it for: [describe your product, the 3 to 5 moments the scroll should tell, and where the assets come from].
Return the section markup, the CSS including the custom properties per breakpoint, and the single shared animation module.

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.

https://www.apple.com/iphone-17-pro/, https://www.apple.com/airpods-pro/, https://www.apple.com/macbook-pro/

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 requestAnimationFrame loop 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-change added and removed dynamically, never set permanently.
  • Only transform and opacity animated.
  • Inline hero poster so LCP does not wait.
  • A prefers-reduced-motion version 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.

Related resources

All resources
WebsiteClaude

Building genuinely high-end scroll animations

Smooth scroll dropped in with three lines gives you a sluggish site that judders on pinned sections and turns annoying on mobile. Studio-grade scrolling is four distinct layers, each tuned separately: inertia, reveal, pinning, scrubbing. This guide covers all four, with the code, the values that actually work, and a teardown of three sites that get them right.

›_# Scroll motion layer: build spec # Portable: save it as CLAUDE.md (Claude Code) or AGENTS
Open the sheet
WebsiteClaude

Building a 10k website with AI: the full pipeline

A client shows you igloo.inc and asks for a quote. These sites look impossible to ship solo, yet two of the three run on a very reproducible stack. This guide gives the real teardown of all three, what a 10k budget actually buys, and the full production pipeline with Claude and the external tools around it.

›_# CLAUDE.md - Project constitution for a studio-grade marketing site You are the lead crea
Open the sheet
WebsiteClaude

How a site like likova.space is built (and the prompt to recreate it)

Architecture/real-estate marketing sites like likova.space look like a major production but are built on a straightforward stack: no framework, raw Three.js with GLTFLoader for a real 3D model, hand-rolled scroll motion. This breaks down exactly what's running and gives a ready-to-use prompt to rebuild the pattern with your own project.

›_You are building a scroll-narrative marketing site for a physical product (real estate, ar
Open the sheet
Take action

Want us to build the site?

These resources set the tone. We can deliver the full site, fast and built to convert.

Book a callAbout us