KOMPOSE.
All resources
WebsiteClaude

Building genuinely high-end scroll animations

The problem it solves

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.

The prompt to copy

Optimized withClaude
# Scroll motion layer: build spec
# Portable: save it as CLAUDE.md (Claude Code) or AGENTS.md (Codex), or paste
# it straight into a chat with Claude or ChatGPT.

You are implementing the scroll motion layer of a high-end marketing site.
Build it as ONE shared system, never as per-component animations.

Stack: GSAP + ScrollTrigger + Lenis. If the project is a React application,
use Framer Motion (useScroll / useTransform / useSpring) instead. Never both:
two animation loops always end up drifting apart.

1. INERTIA
   - Lenis with duration 1.1, smoothWheel: true, smoothTouch: false.
   - ONE clock only: lenis.on('scroll', ScrollTrigger.update), then
     gsap.ticker.add((t) => lenis.raf(t * 1000)) and gsap.ticker.lagSmoothing(0).
   - Never open a second requestAnimationFrame loop.
   - Add data-lenis-prevent to every internally scrollable panel: modals,
     menus, dropdowns, search panels, code blocks.
   - Remove any CSS scroll-behavior: smooth, it fights with Lenis on anchors.

2. REVEALS
   - One ScrollTrigger.batch pass over [data-anim="fade"] and [data-anim="line"].
   - start 'top 85%', once: true, opacity 0 to 1, y 12-24px, duration 0.6-0.9s,
     ease 'expo.out', stagger 0.04-0.08.
   - Headings and section intros only. Never every paragraph.
   - Call document.fonts.ready.then(() => ScrollTrigger.refresh()).

3. PINNING
   - position: sticky when an element only needs to stick.
   - ScrollTrigger pin + scrub: 0.5 when scroll progress drives an animation.
   - invalidateOnRefresh: true, and compute `end` as a function, not a constant.
   - Disable or drastically shorten every pin below 768px.

4. SCRUB
   - A paused , with currentTime = self.progress * video.duration,
     scrub 0.3.
   - Remind me to re-encode the source with `ffmpeg -g 1`, otherwise seeking
     stutters and the effect is unusable.

HARD CONSTRAINTS
   - Animate transform and opacity only. Never top, left, width, height, margin.
   - No getBoundingClientRect() inside a per-frame callback.
   - Wrap everything in gsap.matchMedia(). The (prefers-reduced-motion: reduce)
     branch must destroy Lenis and render the final state instantly. It must
     never leave elements sitting at opacity 0.
   - will-change only on the handful of elements actually being transformed.

Deliver: a single motion.ts file, the list of data attributes to add to the
markup, and an explicit list of what you deliberately chose not to animate.

A great scroll is four layers, not a library

The starting mistake is believing you install “smooth scroll” and the site turns premium. Studio-grade scrolling is four independent mechanics, stacked in this order:

  • Inertia: the scroll itself is smoothed, the page trails the wheel slightly.
  • Reveal: elements appear as they enter the viewport.
  • Pinning: a section holds still while its content advances.
  • Scrubbing: an animation, a video or a scene is driven frame by frame by scroll position.

Sites that impress use all four. Sites that make people nauseous use three of them at random, badly tuned, on the same page.

Three sites, three schools

Before writing code, look at what works. These three were inspected directly, not described from memory.

https://landonorris.com, https://jobs.netflix.com, https://www.rockstargames.com

landonorris.com: Webflow, plus an attribute-driven layer

A Webflow site (data-wf-site attributes, assets on the Webflow CDN) with a real engineering layer grafted on top. Lenis is injected through a plain embed carrying its stylesheet (html.lenis, .lenis-smooth [data-lenis-prevent]). More interesting: around twenty Rive <canvas> elements driven purely by HTML attributes such as data-rive-scrolltrigger-target, data-rive-scrolltrigger-start, data-rive-scrolltrigger-end, data-rive-fit and data-rive-input.

That is the pattern worth stealing: no animation is written in the markup. One script scans the DOM, reads the attributes and wires ScrollTrigger. A designer can add an animation from Webflow without touching a line of JavaScript.

jobs.netflix.com: restraint that scales

Next.js App Router with a LenisProvider component wrapping the app. No 3D, no spectacle: a careers site that has to stay usable. The detail that betrays serious work: the search panel carries data-lenis-prevent="true". That panel has its own internal scroll, so it is excluded from global smoothing. Without the attribute, wheeling inside the panel would move the page behind it. That is what separates a clean integration from a copy-paste of the docs.

rockstargames.com: declarative scroll in React

The served HTML is 3.5KB. The whole site is assembled from dynamically loaded micro-frontends (fifteen independent modules, versioned separately, served from a CDN). Among the declared shared dependencies: React, React Router and framer-motion 12.42.2. No GSAP, no Lenis. Their scroll animations live in React components, through useScroll and useTransform. A completely different approach from the other two, and a perfectly valid one: when the site is a React application maintained by several teams, a declarative animation inside the component ages better than a global script scanning the DOM.

The rule: marketing site or Webflow, use GSAP plus Lenis. React application, use Framer Motion. Mixing both in one project means two competing animation loops and guaranteed judder.

Layer 1: inertia

The most visible layer, and the fastest to get wrong. The principle: the browser stops scrolling natively, you intercept the event and interpolate position every frame.

import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

const lenis = new Lenis({
  duration: 1.1,          // 0.8 to 1.2. Beyond that the site feels sluggish.
  smoothWheel: true,
  smoothTouch: false,     // leave native scrolling on touch
})

// One clock for the entire site: GSAP's.
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)

Those five wiring lines are the heart of the subject. Run Lenis in its own requestAnimationFrame loop and GSAP in another, and the two drift by a frame, which makes every pinned section shake. One clock, always.

The four recurring mistakes:

  • Too long a duration. Past 1.2 the site does not feel smooth, it feels unresponsive. Perceived quality comes from responsiveness, not from the trail.
  • Stacking it with CSS scroll-behavior: smooth. The two fight and anchors become unpredictable. Remove the CSS.
  • Forgetting internally scrollable panels. Modals, menus, dropdowns: each one gets data-lenis-prevent, exactly like the Netflix careers search panel.
  • Forcing inertia on mobile. Native touch scrolling is already perfectly locked to the finger. Smoothing it introduces a lag that reads instantly as a bug.

Layer 2: reveals

One file, zero animation inside components. Declare intent in HTML and let the script do the rest:

// One pass, every marked element on the site
ScrollTrigger.batch('[data-anim="fade"]', {
  start: 'top 85%',
  once: true,
  onEnter: (els) => gsap.to(els, {
    opacity: 1,
    y: 0,
    duration: 0.8,
    ease: 'expo.out',
    stagger: 0.06,
  }),
})

The values that work, after a lot of trial: a 12 to 24 pixel offset (more than that becomes obvious), a 0.6 to 0.9 second duration, an expo.out or power3.out ease, a 0.04 to 0.08 second stagger inside a group, and a top 85% start so the animation is already under way by the time the eye lands on it.

once: true is not a detail. An animation that replays every time you scroll back up turns reading into a fairground ride. Reveal once, and only animate headings and section intros, never every paragraph.

One last thing: recalculate positions once fonts have loaded, or every trigger is measured against a layout that no longer exists.

document.fonts.ready.then(() => ScrollTrigger.refresh())

Layer 3: pinning

A section that holds still while its content advances. This is what makes a site feel like a film rather than a page. Two routes: CSS position: sticky, free and unbreakable, or ScrollTrigger’s pin, more powerful but it rewrites the DOM.

Simple rule: if you only need something to stick, use sticky. If you need scroll progress bound to an animation, use pin with scrub. The classic case, a horizontal section:

const panels = gsap.utils.toArray('.panel')

gsap.to(panels, {
  xPercent: -100 * (panels.length - 1),
  ease: 'none',
  scrollTrigger: {
    trigger: '.horizontal',
    pin: true,
    scrub: 0.5,               // the lag: this is what smooths it
    end: () => '+=' + window.innerWidth * panels.length,
    invalidateOnRefresh: true,
  },
})

Use scrub: 0.5 rather than scrub: true: the animation catches up with scroll position half a second late, which absorbs wheel jerkiness. It is a one-value setting that changes the entire feel.

On mobile, disable pinning or shorten it drastically. A horizontal section pinned across 400% of viewport height, on a phone, mostly reads as a frozen page.

Layer 4: scrubbing

The layer that genuinely impresses: a video or an animation whose every frame maps to a scroll position. Three techniques, ordered by return on effort.

Video scrubbing

A paused <video> whose currentTime you drive. Far lighter than a 300-PNG sequence, and it is what the big product pages use today.

const video = document.querySelector('[data-scrub-video]')
video.pause()

ScrollTrigger.create({
  trigger: '.sequence',
  start: 'top top',
  end: '+=200%',
  pin: true,
  scrub: 0.3,
  onUpdate: (self) => {
    if (video.duration) video.currentTime = self.progress * video.duration
  },
})

The trap is the encoding. A normal video only carries a keyframe every couple of seconds, so seeking stutters because the decoder has to rebuild each frame. Re-encode with a keyframe everywhere:

ffmpeg -i source.mp4 -an -vf "scale=1280:-2" -c:v libx264 -crf 26 -g 1 
  -movflags +faststart scrub.mp4

-g 1 forces every frame to be a keyframe. The file grows by roughly 40%, and the scrub becomes perfectly fluid. Without it the effect is unusable.

Rive

For vector motion nothing else comes close: a few dozen kilobytes, crisp at any resolution, and a state machine you drive from code. That is the choice made on the Lando Norris site, with scroll progress fed straight into the animation inputs. A designer works in the Rive editor, the developer only wires it up.

A 3D scene

Three.js driven by scroll is the tier above, and another budget entirely. Only worth considering when the 3D object is the product itself.

The React variant: declarative scroll

If the project is a React application, there is no need to scan the DOM. Scroll progress becomes a reactive value:

const ref = useRef(null)
const { scrollYProgress } = useScroll({
  target: ref,
  offset: ['start end', 'end start'],
})
const y = useTransform(scrollYProgress, [0, 1], ['0%', '-25%'])
const smooth = useSpring(y, { stiffness: 120, damping: 20, mass: 0.4 })

return <motion.div ref={ref} style={{ y: smooth }} />

The useSpring wrapped around useTransform plays the role of GSAP’s scrub: it damps the motion instead of pinning it frame-perfect to the wheel. Without it the result feels twitchy and cheap.

Performance rules

A scroll animation gets 16 milliseconds per frame. What blows the budget:

  • Animate transform and opacity only. Animating top, left, width, height or margin forces a layout recalculation every frame. It is the number one cause of judder.
  • Use will-change: transform sparingly, never across dozens of elements: every promoted layer eats video memory.
  • Never read the DOM inside a loop. One getBoundingClientRect() per frame cancels the entire benefit. ScrollTrigger caches positions, let it.
  • One global observer rather than one per component.
  • Test on a real mid-range phone, not on your laptop. That is where opinions are formed.

Accessibility, the part everyone skips

For a share of your visitors, motion triggers genuine symptoms. The system announces it, you only have to listen:

const mm = gsap.matchMedia()

mm.add('(prefers-reduced-motion: no-preference)', () => {
  // every animation lives in here
})

mm.add('(prefers-reduced-motion: reduce)', () => {
  lenis.destroy()
  gsap.set('[data-anim]', { opacity: 1, y: 0, clearProps: 'transform' })
})

The key reflex: the reduced version renders the final state immediately. It does not disable the animation and leave elements invisible, which is one of the most common and most damaging bugs out there, a site that is entirely blank for the user. Check too that keyboard navigation still works inside pinned sections, and that anchors land correctly after a reload.

What CSS already does on its own

For simple reveals, JavaScript is no longer mandatory:

@keyframes reveal {
  from { opacity: 0; translate: 0 24px; }
  to   { opacity: 1; translate: 0 0; }
}

.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 35%;
}

Compositor-driven, no JavaScript, no loop cost. Support is still partial, so treat it as progressive enhancement with an @supports check and a visible final state by default. On a site where scroll is not the main event, this is often all you need, and it saves loading 50KB of library for three fades.

Wiring the prompt into Claude Code or Codex

The prompt at the top of this page is not meant to be pasted once and forgotten. It is a constraints file: it lives at the root of the project and the agent re-reads it every session. Only the filename changes from one agent to the next.

  • Claude Code: CLAUDE.md at the repository root.
  • Codex: AGENTS.md, character for character the same content. That is the convention it looks for.
  • Both on one project: keep AGENTS.md as the single source and reduce CLAUDE.md to one import line, @AGENTS.md. One version to maintain instead of two that drift apart within a week.
  • Plain chat, no agent (Claude or ChatGPT in the browser): paste the prompt, then send the file for the section you are working on. Without repository context, ask for one block of code at a time.

Two habits that hold whatever the agent. First: one layer at a time, with a review between each, never all four at once. Second: make it describe in three bullets what it is about to write before it writes a single line. Correcting a description costs ten seconds, correcting three hundred lines costs thirty minutes.

One limitation worth knowing: with no screenshot tool wired in, neither of them can see its own render. The numeric values in this guide (an 85% start, 0.8 seconds, expo.out, scrub: 0.5) exist precisely for that reason. They stand in for the eye until you put a visual loop in place.

The pre-launch checklist

  • One animation loop, GSAP’s, with Lenis wired into it.
  • data-lenis-prevent on every internally scrollable area.
  • ScrollTrigger.refresh() after fonts and images have loaded.
  • Reveals set to once: true, limited to headings and section intros.
  • Pinning disabled or shortened below 768 pixels.
  • Scrub videos re-encoded with -g 1.
  • The prefers-reduced-motion branch actually tested, with the OS setting on.
  • Tested on a mid-range phone, not just in the simulator.
  • Anchors, back navigation and scroll restoration verified.

Scroll is the only interaction 100% of your visitors will use. It is also the only one they notice when it is bad. Three days spent tuning it show up more than a month spent on a feature nobody opens.

Related resources

All resources
WebsiteClaude

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

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.

›_You are building an Apple-style product page: a long scroll where the visuals are driven b
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