webdevpuneet.com

Scroll-Scrubbed GLB Walk Cycle — Free GSAP ScrollTrigger + Three.js Skeletal Animation

Scroll-Scrubbed GLB Walk Cycle · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<section class="gfw-intro"><h1>Scroll to scrub a real skeletal animation, frame by frame</h1><p>This fox's embedded walk-cycle clip is driven directly by scroll position — drag to orbit the camera independently, any time.</p></section>
<section class="gfw-pin" id="gfwPin">
  <canvas id="gfwCanvas"></canvas>
  <div class="gfw-hud">
    <div class="gfw-bar"><div class="gfw-bar-fill" id="gfwBarFill"></div></div>
    <div class="gfw-label" id="gfwLabel">Frame 0%</div>
  </div>
  <div class="gfw-hint">Drag to orbit · plain scroll always scrolls the page · Ctrl/Cmd + scroll to zoom</div>
  <div class="gfw-zoom">
    <span class="gfw-zoom-label">+</span>
    <input type="range" id="gfwZoom" class="gfw-zoom-slider" min="0" max="100" step="1" />
    <span class="gfw-zoom-label">&minus;</span>
  </div>
</section>
<section class="gfw-outro"><p>End of the walk cycle — scroll back up to rewind it.</p></section>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Real skeletal AnimationClip
Loads and scrubs a genuine artist-authored walk-cycle clip embedded in the .glb.
Frame-accurate scroll scrubbing
mixer.setTime() maps scroll progress directly onto the clip's own duration.
Fully reversible playback
Scrolling up plays the exact same animation backward, with no special-casing.
Zero conflict with camera dragging
Object-space animation and camera-space orbiting never touch the same property.
Automatic clip selection
Picks the walk clip by name with a safe fallback to the first available clip.
Live progress HUD
A fill bar and frame-percentage label driven straight off scroll progress.
Honest load-failure fallback
A logged error swaps in a placeholder mesh instead of a silent void.
Slider + Ctrl/Cmd-scroll zoom
Zoom is an explicit, opt-in gesture, never a hijacked plain scroll wheel.

About this UI Snippet

Scroll-Scrubbed GLB Walk Cycle — Real Skeletal Animation Driven by Scroll

Screenshot of the Scroll-Scrubbed GLB Walk Cycle snippet rendered live

Most "scroll-controls-a-3D-model" demos rotate a static mesh. This one goes a step further: it loads a real glTF model with an embedded, artist-authored skeletal walk-cycle animation, and scrubs that actual animation clip — bones and all — directly from scroll position, frame by frame, forward and backward, while a visitor can still freely orbit the camera by dragging at any moment.

A real AnimationClip, not a fake one

The loaded Fox sample asset ships three baked animation clips (Survey, Walk, Run) as part of the .glb file itself. The snippet picks whichever clip's name matches /walk/i, falling back to the first available clip if the asset's naming ever changes, and builds a THREE.AnimationMixer around it with mixer.clipAction(clip).play(). The action is deliberately left unpaused: Three.js's internal pose evaluation skips a paused action even when it's driven by setTime(), not just its own per-frame advance — and since the render loop below never calls mixer.update(delta), there's no automatic playback to guard against pausing in the first place.

mixer.setTime() is the whole trick

A normal Three.js animation loop calls mixer.update(delta) every frame, advancing playback at real-world speed. This snippet never does that. Instead, ScrollTrigger's onUpdate calls mixer.setTime(progress * clipDuration) — setting the mixer to an exact point inside the clip's own duration, computed straight from scroll progress. Scroll down and the fox walks forward, one bone-pose at a time; scroll back up and the exact same walk cycle plays in reverse, because setTime doesn't care about direction or elapsed real time, only the value you give it.

Object-space animation, camera-space orbiting — no coordination needed

Scroll only ever touches the mixer's internal clip time. OrbitControls only ever touches the camera's position. Neither system reads or writes anything the other owns, so — exactly like the turntable rotation version of this idea — there's no flag, no pause-on-drag handler, no handoff logic required at all. Drag the canvas mid-scroll and the walk cycle keeps scrubbing exactly as scroll dictates, from whatever new angle you chose.

A named, honest fallback if the model fails

If GLTFLoader can't fetch the .glb (offline, CDN outage, blocked request), the real error is logged to the console and a simple placeholder cone stands in, so the scene never silently renders empty. The HUD progress bar and frame-percentage label still work regardless, since they read directly off scroll progress rather than off the model.

Zoom is opt-in, not a hijacked scroll wheel

OrbitControls' built-in wheel-zoom is deliberately turned off (controls.enableZoom = false) — left on, it calls preventDefault() on every wheel event over the canvas, silently swallowing the page scroll this whole demo depends on. Zoom is reimplemented as two explicit, opt-in gestures instead: a vertical slider next to the canvas, and Ctrl/Cmd + scroll wheel (the same convention embedded Google Maps and most map widgets use). Both call the same setZoomDistance() helper, which clamps to controls.minDistance/maxDistance and keeps the slider in sync no matter which input triggered the change.

Customizing it

Swap in any .glb with a baked AnimationClip — the /walk/i clip-name matcher and the mixer.setTime scrubbing logic work unchanged for any skeletal or morph-target animation. Pair it with scroll GLB camera flythrough for the checkpoint-touring variant of this same GLB-plus-scroll family, or scroll-scrubbed GLB turntable for the simplest version.

Step by step

How to Use

  1. 1
    Add all five CDN scriptsthree.min.js, GLTFLoader.js, OrbitControls.js, gsap, and ScrollTrigger.
  2. 2
    Paste HTML, CSS, and JSThe fox loads standing at frame 0 of its walk cycle.
  3. 3
    Scroll into the pinned sectionThe walk cycle plays forward, frame by frame, tied to scroll position.
  4. 4
    Scroll back upThe exact same clip scrubs backward, since setTime is direction-agnostic.
  5. 5
    Drag on the canvas at any pointThe camera orbits freely and independently — the walk cycle keeps scrubbing normally.
  6. 6
    Use the slider or Ctrl/Cmd + scroll to zoomPlain scroll always advances the page; zoom is a separate, opt-in gesture.
  7. 7
    Swap in your own animated .glbUpdate MODEL_URL and adjust the clip-name matcher if needed.

Real-world uses

Common Use Cases

Character showcase pages
Let visitors scrub a real skeletal walk cycle by scrolling, with bones animating from an artist-authored clip rather than a rotating static mesh.
Animation portfolios
Demonstrate a baked skeletal animation, with mixer.setTime() mapping scroll progress onto exact frames and playback reversing exactly when scrolling up.
Mascot storytelling
Tie a brand character's movement to the narrative, with OrbitControls still free to move the camera because animation and orbit work in different spaces.
AnimationMixer teaching
Study a complete example of loading a glTF and scrubbing its AnimationClip, compared with the camera approach in scroll GLB helmet camera flythrough.
Scroll narratives with 3D
Use as a centrepiece between narrative sections, followed by a scroll reveal grid of supporting content below it.
Related: Scroll Direction Theme Shift
See the Scroll Direction Theme Shift for a related scroll pattern worth pairing with this one.
Related: Scroll-Triggered GLB Model Carousel
See the Scroll-Triggered GLB Model Carousel for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Rotating a model only ever changes one transform property (rotation.y). Scrubbing a real AnimationClip means setting the exact playback time of a baked, multi-bone skeletal animation via mixer.setTime() — the fox's legs, tail, and body all pose correctly for that exact instant in the walk cycle, forward or backward, because the animation data itself (not a simple formula) defines the pose at every point in time.

Because here scroll only ever writes to the AnimationMixer's clip time, and OrbitControls only ever writes to the camera's position — two completely separate objects, so there's nothing for the two systems to fight over. The camera-flythrough snippet needs explicit pause-on-drag logic specifically because scroll and OrbitControls both want to control the same camera.position property there. Zoom follows the same discipline: OrbitControls' own wheel-zoom is turned off so a plain scroll always advances the page, and zooming is reimplemented as an explicit slider plus Ctrl/Cmd + scroll.

update(delta) advances animation playback at real-world speed, independent of scroll — useful for a normal looping animation, but not for scrubbing. setTime(t) jumps the mixer directly to an exact point in the clip, which is what lets scroll progress map onto animation progress one-to-one, including playing the clip backward when scrolling up.

The code checks gltf.animations.length before building a mixer at all, so a model with no animations simply renders statically with no errors. If animations exist but none match /walk/i, it falls back to gltf.animations[0] — the first available clip — rather than failing, so the demo keeps working even if the asset's clip names ever change.

Set up the renderer, scene, GLTFLoader call, AnimationMixer, and ScrollTrigger inside a mount effect, keeping the mixer, action, and clipDuration in refs so the onUpdate callback can reach current values. Call controls.dispose(), renderer.dispose(), and the ScrollTrigger instance's .kill() in the cleanup function; the mixer itself needs no explicit disposal beyond letting it be garbage-collected.