webdevpuneet.com

Scroll-Scrubbed GLB Motion Trail — Free GSAP ScrollTrigger + Three.js Skeletal Animation

Scroll-Scrubbed GLB Motion Trail · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<section class="gmt-intro"><h1>Scroll to scrub a real skeletal animation with a live motion trail</h1><p>Two translucent "ghost" poses trail behind the current scroll position, sampled straight from the same real glTF animation clip.</p></section>
<section class="gmt-pin" id="gmtPin">
  <canvas id="gmtCanvas"></canvas>
  <div class="gmt-hud">
    <div class="gmt-bar"><div class="gmt-bar-fill" id="gmtBarFill"></div></div>
    <div class="gmt-label" id="gmtLabel">Frame 0%</div>
  </div>
  <div class="gmt-hint">Drag to orbit · plain scroll always scrolls the page · Ctrl/Cmd + scroll to zoom</div>
  <div class="gmt-zoom">
    <span class="gmt-zoom-label">+</span>
    <input type="range" id="gmtZoom" class="gmt-zoom-slider" min="0" max="100" step="1" />
    <span class="gmt-zoom-label">&minus;</span>
  </div>
</section>
<section class="gmt-outro"><p>End of the animation clip &mdash; scroll back up to rewind the trail.</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 baked animation clip from a real glTF character rig.
Three independently-posed rig clones
SkeletonUtils.clone() gives each rig its own real, independently-scrubbable skeleton.
Offset-lag ghost trail
Two translucent ghosts sample the same clip a fraction of a second behind the live rig.
Frame-accurate scroll scrubbing
Every rig's mixer.setTime() maps scroll progress directly onto the clip's own duration.
Zero conflict with camera dragging
Object-space animation and camera-space orbiting never touch the same property.
Non-depth-writing ghost materials
Translucent ghosts avoid z-fighting artifacts against the live rig.
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 Motion Trail — Three Independent Rigs, One Shared Clip

Screenshot of the Scroll-Scrubbed GLB Motion Trail snippet rendered live

A single scrubbed skeletal pose reads clearly, but it loses all sense of *motion* — you can't tell which way a limb was moving a moment ago. This snippet fixes that by cloning the same rigged model three times, giving each clone its own independent AnimationMixer, and scrubbing all three from the same scroll position at slightly staggered offsets — producing a real, readable motion trail built entirely from genuine baked animation data, not a fake shader-based afterimage effect.

Why SkeletonUtils.clone(), not Object3D.clone()

A skinned mesh's pose depends on its skeleton — a hierarchy of bone objects the mesh's vertices are weighted against. Three.js's plain Object3D.clone() performs a shallow clone that does *not* correctly rebuild an independent skeleton and re-bind the cloned mesh to it; all clones would end up sharing (and fighting over) the same bones. THREE.SkeletonUtils.clone() — loaded here as a fourth Three.js addon script — exists specifically to solve this, producing a real, independently-posable rig from a single source scene.

Three mixers, one shared clip

Each of the three cloned rigs gets its own THREE.AnimationMixer and its own clipAction() built from the exact same loaded AnimationClip — the animation *data* is shared (there's only one clip, decoded once), but each mixer's playback time is entirely independent, which is what lets the trailing ghosts sit at a different point in the clip than the live rig.

mixer.setTime(), offset per rig

setClipTime(progress) calls rig.mixer.setTime() for all three rigs on every scroll update, but subtracts each rig's own lag value (a small fraction of the clip's duration) from the current scroll progress before converting to clip time. The live rig has lag = 0; the two ghosts trail 4.5% and 9% of the clip behind it — close enough to read as a genuine, connected motion trail rather than three disconnected poses.

Translucent, non-depth-writing ghost materials

Each ghost rig's mesh materials are cloned (never shared with the live rig or each other), tinted toward purple, and set to a low opacity with depthWrite = false — the last part matters because a fully opaque, depth-writing ghost mesh directly overlapping the live mesh would produce ugly z-fighting artifacts; disabling depth writes on the translucent ghosts lets them blend cleanly behind and around the live pose instead.

The unpaused-action rule still applies, per rig

Every rig's action.play() call is left unpaused for the same reason as the single-rig walk-cycle snippet: pausing an AnimationAction also blocks Three.js's internal pose evaluation inside setTime(), not just its own per-frame advance. Since the render loop never calls any rig's mixer.update(delta), nothing auto-advances any of the three rigs on its own — scroll's setTime() calls remain the only thing driving all three.

A different asset than the Fox turntable snippet, deliberately

This snippet loads Khronos' Cesium Man sample asset rather than the Fox used in scroll-scrubbed GLB walk cycle — a different real skeletal rig and clip, specifically to demonstrate the technique generalizes rather than being tied to one specific sample file.

Zoom is opt-in, not a hijacked scroll wheel

OrbitControls' built-in wheel-zoom is turned off, with zoom reimplemented as a slider plus Ctrl/Cmd + scroll, exactly like every other snippet in this family, so a plain scroll always advances the page.

Customizing it

Adjust RIG_COUNT and GHOST_LAG for a longer or tighter trail, swap MODEL_URL for any other animated .glb, or pair this with scroll GLB camera flythrough for a longer scroll narrative around a real animated character.

Step by step

How to Use

  1. 1
    Add all six CDN scriptsthree.min.js, GLTFLoader.js, OrbitControls.js, SkeletonUtils.js, gsap, and ScrollTrigger.
  2. 2
    Paste HTML, CSS, and JSThe Cesium Man rig loads at frame 0, with two faint ghost poses layered behind it.
  3. 3
    Scroll into the pinned sectionAll three rigs scrub forward together, the ghosts trailing slightly behind.
  4. 4
    Scroll back upThe whole trail scrubs backward exactly, since setTime is direction-agnostic.
  5. 5
    Drag on the canvas at any pointThe camera orbits freely and independently — the trail 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 — SkeletonUtils.clone() works on any skinned rig.

Real-world uses

Common Use Cases

Sports and biomechanics visuals
Show a real captured movement with a trailing motion path, using two translucent ghost rigs that sample the same clip slightly earlier.
Game animation portfolios
Demonstrate skeletal rigging skills in a game animation portfolio, with SkeletonUtils.clone() giving each of three rigs its own independent skeleton.
Character motion teaching
Teach how a pose relates to what came before, since a single scrubbed pose loses all sense of the direction a limb was moving.
Character storytelling
Tie a character's movement to scroll narrative, with every rig's mixer.setTime() mapped directly from scroll progress.
Single-rig comparison
Compare against the scroll GLB walk cycle scrub, then place either as a centrepiece before a scroll reveal grid.

Got questions?

Frequently Asked Questions

A skinned mesh's pose is driven by its skeleton — a hierarchy of bone Object3D instances the mesh's vertices are weighted against. Three.js's built-in Object3D.clone() only performs a shallow copy that does not correctly rebuild an independent skeleton or re-bind the cloned mesh's skin to it, so multiple clones made that way would all end up sharing (and visually fighting over) the exact same bones. SkeletonUtils.clone(), loaded as its own addon script, exists specifically to produce a real, independently-posable rig from one source scene.

Each rig stores a small lag value — a fixed fraction of the clip's total duration. Every scroll update calls mixer.setTime() on all three rigs using the same current scroll progress, but subtracts that rig's own lag before converting to clip time first. Because all three lag values are fixed fractions of the same clip, the trail's spacing stays visually consistent throughout the entire scrub, whether scrolling forward or backward.

The ghost rigs occupy nearly the same 3D space as the live rig, just slightly offset in pose. If a translucent ghost mesh still wrote to the depth buffer as if it were opaque, it would incorrectly occlude parts of the live mesh behind it, producing visible z-fighting and flickering artifacts. Disabling depth writes on the ghost materials lets their transparency blend correctly without interfering with what should render in front of or behind them.

Deliberately, to demonstrate the multi-rig scrubbing and SkeletonUtils cloning technique generalizes to any skinned glTF character rig, not just one specific sample asset already used elsewhere in this snippet family. Cesium Man ships its own baked AnimationClip with clean, readable full-body motion, which is what a motion trail needs to look convincing.

Set up the renderer, scene, GLTFLoader call, OrbitControls, and the rigs array (with each rig's mixer, action, and lag) inside a mount effect, keeping them in refs so the ScrollTrigger onUpdate callback can reach current values. Call controls.dispose(), renderer.dispose(), and the ScrollTrigger instance's .kill() in the cleanup function; the mixers themselves need no explicit disposal beyond letting them be garbage-collected.