Scroll-Triggered GLB Model Carousel — Free GSAP ScrollTrigger + Three.js Gallery Snippet

Scroll-Triggered GLB Model Carousel · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Three real glTF binary models
Loaded via THREE.GLTFLoader into a genuine multi-model gallery, not one model recolored.
Scroll-scrubbed turntable rotation
The whole gallery group's rotation is a direct, reversible function of scroll progress.
Every model stays loaded
No show/hide toggling or reloading when scrolling back to a model already seen.
Independent per-model fallbacks
Each model's own load failure swaps in its own placeholder, in its own slot only.
Live label and dot indicator
A real model name and a synced dot row track the current scroll-selected model.
Simultaneous manual orbit
OrbitControls dragging works at all times, with zero conflict handling.
Auto-fit model scale
Every model is scaled to the same target height via bounding-box measurement.
Slider + Ctrl/Cmd-scroll zoom
Zoom is an explicit, opt-in gesture, never a hijacked plain scroll wheel.

About this UI Snippet

Scroll-Triggered GLB Model Carousel — A Real Multi-Model Gallery on One Turntable

Screenshot of the Scroll-Triggered GLB Model Carousel snippet rendered live

Showing off more than one 3D model on scroll usually means either loading them one at a time (with an awkward swap moment) or building a full multi-scene setup. This snippet does neither: it loads three genuinely different .glb models onto three slots of a single rotating turntable Group, and lets scroll rotate that whole group — bringing each model into the camera's fixed viewing position in turn, purely by rotation.

One turntable, three slots, not three toggled visibilities

GALLERY is loaded once into three child Groups (slots), each positioned 120 degrees apart around a shared turntable group's center. Every model stays loaded and present in the scene at all times — there's no show/hide toggle, no re-parenting, and no re-triggering a load when scrolling back to a model you've already seen. Only turntable.rotation.y ever changes.

Scroll rotates the turntable, never an individual model

ScrollTrigger's onUpdate maps the 0-1 scroll progress across (TOTAL_SLOTS - 1) steps and sets turntable.rotation.y directly from that value — a pure, reversible function of scroll position, exactly like the single-model scroll-scrubbed GLB turntable snippet, just applied to which model faces the camera instead of to one model's own spin.

Camera-space orbit, object-space carousel — still zero conflict

OrbitControls only ever repositions the camera around a fixed target point. The scroll handler only ever sets the turntable group's own rotation. Because those are two different transforms on two different objects, dragging to orbit whichever model currently faces the camera and scrolling to bring the next model into view both work simultaneously with no coordination code, exactly the same conflict-free pattern this whole snippet family relies on.

Independent, honest per-model fallbacks

Each of the three GLTFLoader.load() calls has its own error callback that names which gallery entry failed and swaps in a color-matched placeholder sphere into that specific slot — so one broken model URL only ever affects its own position on the turntable, never the other two.

A live label and dot indicator, not just a number

Alongside the numeric scroll progress, the current step is rounded to the nearest whole model index and used to update both a text label (the model's actual name) and a row of dot indicators — the same UI pattern real image/product carousels use, applied here to a genuinely 3D, scroll-scrubbed gallery.

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, so a plain scroll always advances the page.

Customizing it

Add a fourth entry to GALLERY (the slot math adapts automatically), change SLOT_RADIUS for a tighter or wider turntable, or pair this with GLB model comparison viewer for a non-scroll-driven way to look at two models at once.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why rotating one shared turntable group (rather than toggling each model's visibility) is what lets all three models stay loaded and avoids any reload flicker when scrolling back to a model already seen, and why dragging to orbit and scroll-driven turntable rotation never need explicit conflict-resolution code. It's also useful for extending the demo — ask it to add a fourth or fifth model with the gallery spacing adjusting automatically, ease the turntable rotation with a non-linear curve instead of a linear scroll mapping, or add per-model caption text that cross-fades in sync with the label and dot indicator. Use the conversation to build real intuition for scroll-scrubbed multi-object scenes before applying the same turntable-gallery pattern to your own set of glTF models.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "scroll-triggered multi-model 3D carousel" in plain HTML, CSS, and JavaScript using Three.js (core, GLTFLoader, and OrbitControls, all loaded from a CDN with no bundler) plus GSAP with its ScrollTrigger plugin.

Requirements:
- A pinned full-viewport Three.js scene (GSAP ScrollTrigger pin: true) with an intro section before it and an outro section after, studio-lit with at least a key and fill light plus a simple ground/grounding element.
- Define a gallery of at least three real .glb models as plain data (name, URL, a fallback color), each pointed at a genuine, freely-licensed, CDN-hosted glTF binary URL (e.g. different Khronos glTF-Sample-Assets models) — do not substitute primitive geometry for any of them.
- Create one shared parent "turntable" Group, and inside it, one child Group per gallery entry ("slot"), positioned evenly around a circle so each model sits in its own fixed position relative to the turntable's center. Load each gallery entry's model into its own slot using THREE.GLTFLoader, and after each one loads, measure its bounding box and scale it to the same fixed target height (rather than a hardcoded scale number) so every model in the gallery compares fairly at a consistent visual size.
- Using ScrollTrigger's scrub option, rotate only the shared parent turntable group's own rotation directly from scroll progress — mapped so that each full model-to-model transition corresponds to an even fraction of the total scroll range — never toggle any individual model's visibility, reparent anything, or reload a model when scrolling back to one already seen. The rotation must be a pure, reversible function of scroll position.
- Display a live text label showing the name of whichever model is currently closest to facing the camera, and a row of dot indicators that highlights the currently-active model, both derived from the same scroll progress value used for the turntable rotation.
- Set up OrbitControls on the camera with damping enabled so a visitor can click-and-drag the canvas at any time to orbit around whichever model currently faces the camera — this must work continuously and simultaneously with the scroll-driven turntable rotation, with zero explicit conflict-resolution code, since OrbitControls only ever moves the camera while scroll only ever rotates the turntable group.
- Turn off OrbitControls' own wheel-zoom and instead implement zoom as an explicit opt-in gesture: a vertical range-input slider next to the canvas, plus Ctrl/Cmd + scroll wheel — a plain scroll must do nothing and pass through to the page normally.
- Handle each model's own GLTFLoader error callback independently by logging which gallery entry failed and substituting a simple placeholder mesh into that specific slot only, so a single broken model URL never affects the other slots on the turntable.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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 Duck loads facing the camera; the other two models wait on the turntable.
  3. 3
    Scroll into the pinned sectionThe turntable rotates smoothly, bringing each model into view in turn.
  4. 4
    Watch the label and dotsBoth update live to show which model currently faces the camera.
  5. 5
    Drag on the canvas at any pointOrbit freely around whichever model is currently on stage.
  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
    Add another modelPush a new { name, url, color } entry into GALLERY — the slot layout adapts automatically.

Real-world uses

Common Use Cases

Multi-product scroll showcases
Walk a visitor through several real 3D products in one continuous scroll gallery.
Portfolio and case-study reels
Present multiple 3D assets or projects as one scroll-driven turntable gallery.
glTF/ScrollTrigger teaching demos
A complete, real example of scroll-scrubbing which object is currently in view.
Museum/collectible rotating exhibits
Bring several artifact models into view in turn as part of a scroll narrative.
Alongside the single-model turntable
Compare against scroll-scrubbed GLB turntable's single-model version.
Alongside the comparison viewer
Pair with GLB model comparison viewer for a non-scroll side-by-side look.

Got questions?

Frequently Asked Questions

All three models are loaded once into their own child Group, each positioned 120 degrees apart around a shared parent turntable Group's center. Scroll only ever rotates that parent turntable group's own rotation.y property — every model stays loaded, present, and untouched in the scene at all times, so there's no show/hide toggle, re-parenting, or re-triggered load involved in bringing a different model into the camera's fixed viewing position.

They act on two different transforms. OrbitControls repositions the camera around a fixed target point — camera-space. The scroll handler sets the turntable group's own rotation.y directly — object-space, and specifically the whole gallery's rotation, never any individual model's own local transform. Because a visitor's drag and the scroll handler never write to the same property, both work simultaneously with zero conflict-resolution code, the same pattern used throughout this snippet family.

Each of the three GLTFLoader.load() calls has its own independent error callback that logs specifically which gallery entry failed and adds a color-matched placeholder sphere into that model's own slot only — the other two models continue to load and rotate into view completely normally, so a single broken URL never breaks the whole carousel.

Push a new { name, url, color } object into the GALLERY array. The slot positions are computed automatically by dividing a full circle by GALLERY.length, and the ScrollTrigger's scroll-distance and rotation-per-step math both read from GALLERY.length as well, so adding an entry automatically adjusts the turntable spacing and how far you need to scroll to see every model.

Set up the renderer, scene, GLTFLoader calls, OrbitControls, and the turntable/slots group hierarchy inside a mount effect, keeping GALLERY and the turntable group 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 to release the WebGL context and remove the pin/scroll listeners on unmount.