Pinned Image Sequence
A real glTF model — here a demo pair of sunglasses, 371 KB, 13,396 triangles, iridescent lenses — rendered by the model-viewer component (loaded from a CDN) and pinned with position: sticky inside a 320% track: every scroll event rewrites camera-orbit, 17° per wheel notch, 21 notches for a full turn, and the camera catches up with its target in 230 ms. The model is meant to be replaced with your own.
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Scroll has 74 effects, including 12 free. Explore the category →
3 usage examples



How it works
The zone .pis-scene (width: 100%, aspect-ratio: 4/3, overflow: hidden, isolation: isolate, radial gradient #1b2350 → #0a0e1f) contains its own scrolling: .pis-scroll in position: absolute; inset: 0; overflow-y: auto, scrollbar hidden (scrollbar-width: none + ::-webkit-scrollbar { width: 0 }). Inside it, the spacer .pis-track is 320% of the height, and .pis-stage, in position: sticky; top: 0, is 31.25% of the spacer — exactly the scroller's height (0.3125 × 3.2 = 1): the stage stays pinned for the whole travel, which is 2.2 zone heights. Measured at 1,280 px wide: zone of 1,264 × 948 px, spacer of 3,034 px, 2,086 px of travel; at 400 px: zone of 384 × 288, travel of 634 px. In the middle, <model-viewer class="pis-mv"> — a fixed 240 × 240 px, 16 px radius, gradient background, --poster-color: transparent — with camera-orbit="0deg 75deg 105%", field-of-view="32deg", exposure="1.1", shadow-intensity="1", interaction-prompt="none", disable-zoom, disable-tap, reveal="auto" and a src pointing to the demo model served by jsDelivr; below it, the caption .pis-cap ("angle 0°", 12 px, #aeb2c8) and, at the top, the hint .pis-hint ("Scroll to rotate", rgba(0,0,0,.4) pill, z-index: 4). Caption, hint and viewer are all pointer-events: none: the wheel passes through everything and reaches the scroller.
The library comes in through a module tag placed at the top of the HTML: model-viewer 3.5.0 from Google Hosted Libraries (ajax.googleapis.com) — 935,194 bytes, 254 KB compressed over the wire, three.js r163 bundled. An ES module is deferred by nature: it does not block rendering, and the <model-viewer> element, already in the DOM, is upgraded as soon as its definition arrives (measured: customElements.whenDefined resolved between 170 and 810 ms). reveal="auto" then requests the model: SunglassesKhronos.glb, 371,188 bytes (277 KB compressed), served by jsDelivr from the Khronos sample-assets repository and pinned to a commit — eight meshes, 13,396 triangles, one PNG texture and five PBR materials (temples, ear hooks, nose pads, two lens layers using KHR_materials_transmission, volume, ior and iridescence), licensed CC BY 4.0 (Eric Chadwick, Darmstadt Graphics Group). Measured: 1.6 to 2.3 s between page load and the viewer's load event, files served by the CDNs. It is a demo model: replace it with yours. Then update(), bound to scroll (passive) and resize: p = scrollTop / (scrollHeight − clientHeight), a = Math.round(p × 360), then mv.setAttribute('camera-orbit', a + 'deg 75deg 105%') — the azimuth follows the scroll, the elevation (75°) and the distance (105% of the automatic framing) are frozen in the string. The caption receives a, the hint goes to opacity: 0 as soon as p > 0.04 (84 px measured). One event, one write: 9 scroll events produced 9 attribute mutations. A 100 px wheel notch is 17°; 21 notches make the turn at 1,264 px wide, 7 at 384 px.
The smoothing is not in the code: camera-orbit sets a target that model-viewer's camera reaches according to interpolation-decay (50 ms by default, attribute absent). Measured after one notch: within 5° of the target in 123 ms, 1° in 231 ms, 0.1° in 364 ms; after a jump from 0 to 180°: 10° in 238 ms, 1° in 377 ms. And the camera takes the shortest path: a jump from 0 to 190° goes through 357.9° (it turns backwards), 0 → 324° is done as −36°. Invisible with the wheel, visible when dragging the scrollbar in one go or flicking a trackpad. At the end of the travel (2,086 px), 360° is the starting view and the next notch scrolls the page (chaining measured: scrollY 100). Under prefers-reduced-motion, the script exits before binding the listener — but never adds the pis-reduced class the CSS expects to bring the spacer back to 100%: the 2,086 px track remains, the orbit stays at 0° and "Scroll to rotate" stays on screen (measured over 8 notches). Leftovers of a CSS-cube variant: .pis-cube and --pis-a in the JS, perspective: 820px on the stage and the rule .pis-scene.pis-reduced .pis-cube — no .pis-cube in the HTML. The hint carries a frozen style="opacity: 1;" in the HTML, which the script rewrites.
Accessibility
prefers-reduced-motionpresent but incomplete: the script reads the preference and does not bind thescrolllistener — the model no longer rotates — but it does not add thepis-reducedclass the CSS provides for (.pis-track { height: 100% }). Measured with the preference on: 2,086 px of empty track to scroll through, the "Scroll to rotate" hint atopacity: 1permanently, orbit frozen at 0°. Addscene.classList.add('pis-reduced')and hide.pis-hintin the early return; the model (1.3 MB with the library, 530 KB compressed) is loaded anyway.- Keyboard: model-viewer exposes in its shadow DOM a
div.userInputwithrole="img",tabindex="0"and thealtattribute as its name ("Lunettes de soleil 3D — vue 360°") — a single tab stop, measured. With focus inside.pis-scroll, the arrow keys scroll the zone: ↓ = 40 px = 7°, i.e. 53 presses for a full turn at 1,264 px wide. Write analtthat describes the object, it is the only text announced: the angle caption and the hint arearia-hidden="true"and no live region follows the angle — fine for a decorative visual, not enough if the angle carries information. - Contrast: caption
#aeb2c8at 12 px on the zone's gradient, 7.1:1 on#1b2350and 9.1:1 on#0a0e1f; white hint on thergba(0,0,0,.4)pill, 17.9:1. Both drop below 4.5:1 on a light page (2.1:1 and 2.85:1): recolour if you remove the gradient. The model has no contrast requirement, but itsaltdoes. - Wheel trap: the zone is a nested scroller that captures the wheel for 21 notches at 1,264 px wide (7 at 384 px) before handing back to the page (chaining measured at notch 22). A reader scrolling down the page stops there without meaning to; a trackpad user can go through the 360° in one gesture. Shorten the travel (
.pis-track { height: 200% },.pis-stage { height: 50% }) or take the zone out of the main reading flow. - Integration: the rotation is user-triggered, never autonomous; the viewer is
pointer-events: none, so no mouse or touch rotation — one single control, consistent. Plan for failure: if the library's CDN does not answer, the element stays unknown (a 240 px gradient square); if the model is unreachable, or hosted on a domain that does not allow CORS, model-viewer fireserror(type: 'loadfailure', measured) and shows nothing — listen for it and show a fallback image.
Browser compatibility
Requires ES modules (type="module"), custom elements, position: sticky, aspect-ratio, inset and WebGL 2 (three.js r163 bundled in model-viewer 3.5.0); the demo model uses KHR_materials_transmission, volume, ior and iridescence, supported by that version. The driving is plain JavaScript (scroll event + attribute): it works in Firefox, unlike CSS animation-timeline scroll-driven animations, which no Firefox version supports. Two external resources, both served with Access-Control-Allow-Origin: *: the model-viewer library (935 KB, ajax.googleapis.com) and the demo model (371 KB, cdn.jsdelivr.net) — to be replaced with your own.
Without JavaScript, neither the module nor the script runs: the zone shows the 240 px gradient square, "angle 0°" and the hint, and 2.2 heights of empty track still scroll. Without WebGL 2, model-viewer fires error and the square stays empty. If a CDN is blocked (a CSP without ajax.googleapis.com or cdn.jsdelivr.net, a filtered network), same empty square: host both files with the page instead. Under prefers-reduced-motion: model loaded, frozen at 0°, hint displayed (see accessibility).
The code
HTML structure to paste into your page (the CSS + JS come with your purchase):
<!-- model-viewer 3.5.0 library, loaded from the Google CDN -->
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js" integrity="sha384-Ftcjj/GNLxPvzNDftO/oryXB9aGxsGZY9JGqsXG0uUKgQDl9RfDgsx9NJ/4IVNPe" crossorigin="anonymous"></script>
<div class="pis-scene">
<div class="pis-hint" aria-hidden="true">Scroll to rotate</div>
<div class="pis-scroll">
<div class="pis-track">
<div class="pis-stage">
<!-- src: demo model, replace it with your own (CORS required if it lives on another domain) -->
<model-viewer class="pis-mv" alt="3D sunglasses — 360° view" camera-orbit="0deg 75deg 105%" field-of-view="32deg" interaction-prompt="none" disable-zoom disable-tap shadow-intensity="1" exposure="1.1" reveal="auto" src="https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets@34eb677041d54bf92b435f1bf2671b391d4a3892/Models/SunglassesKhronos/glTF-Binary/SunglassesKhronos.glb"></model-viewer>
<div class="pis-cap" aria-hidden="true">angle <b><span class="pis-deg">0</span>°</b></div>
</div>
</div>
</div>
</div>
One-time payment, no subscription — the code is yours forever
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Viewer src (HTML — <model-viewer>) |
Demo sunglasses (SunglassesKhronos.glb, 371 KB, jsDelivr) | Any glTF/GLB, hosted with the page (same origin: nothing to configure) or on a domain that sends Access-Control-Allow-Origin — model-viewer reads it through fetch(), and without that header the viewer fires error. Weight dictates the wait: 1.6 to 2.3 s measured for 371 KB + 935 KB of library through the CDNs. |
Travel (CSS — .pis-track { height: 320% }) |
320% (2.2 heights) | Scroll length for one turn: 2,086 px at 1,264 px wide, 634 px at 384 px. 200% = a turn in 10 notches, but .pis-stage must stay equal to the scroller: change it from 31.25% to 50%, otherwise the pinning breaks. |
Amplitude (JS — Math.round(p * 360)) |
360° | 180 = half a turn, 720 = two turns. Beyond 180° in a single jump, the camera goes the other way (shortest path). |
Elevation and distance (HTML — camera-orbit="0deg 75deg 105%"; JS — a + 'deg 75deg 105%') |
75° / 105% | Two places to change together: the JS string overwrites the attribute on every scroll. 90deg = side view, 60deg = steeper; 120% moves the camera away. |
Smoothing (HTML — interpolation-decay) |
absent = 50 ms | Camera lag behind the target: 1° in 231 ms by default; 20 = 91 ms, 0 = locked to the scroll (9 ms), 200 = 0.9 s of trailing (measured). |
Viewer size (CSS — .pis-mv { width: 240px; height: 240px }) |
240 × 240 px | Fixed whatever the zone: 19% of the width at 1,264 px, 83% of the height at 288 px. Use min(60%, 60vh) to follow the container; the 105% in camera-orbit reframes automatically. |
Rendering (HTML — exposure, shadow-intensity, field-of-view) |
1.1 / 1 / 32° | Brightness, ground shadow, focal length. exposure="0.8" darkens a white model; field-of-view="20deg" flattens the perspective. |
Hint threshold (JS — p > 0.04) |
4% (84 px) | The hint disappears after 4% of travel. Placed with left: 50%, the hint only gets half the zone: measured wrapping onto two lines from 324 px wide — add white-space: nowrap. |
| Library loading (HTML — module tag at the top of the code) | with the page (deferred module, 935 KB) | The module does not block rendering, but it ships with the page. To download it only when the zone approaches, create the tag from an IntersectionObserver (rootMargin: '300px'): the element already in the DOM is upgraded when the module arrives. To depend on no CDN at all, npm i @google/model-viewer and point the tag's src to your copy. |
FAQ
<model-viewer> and rotates its camera: the geometry is real, light and shadow are computed at every angle. That is what sets it apart from its neighbour Image Sequence (fx-0583), which switches 24 CSS states (gradient, radius, size) along a 920 px scroll, with no images either — a mock sequence. Here you can read a hinge or a thread; there, you cannot. For real 360° photos this is not the right effect: model-viewer does not read image series.ajax.googleapis.com, 935 KB, 254 KB compressed); the viewer's src points to a demo model from the Khronos sample-assets repository served by jsDelivr (371 KB, CC BY 4.0, URL pinned to a commit: it will not move). Measured on a blank page: both answer 200 with Access-Control-Allow-Origin: *, and the viewer fires load between 1.6 and 2.3 s. For your model: host the .glb next to the page (same origin, nothing to configure) or on a domain that sends that CORS header — model-viewer reads it through fetch(), and without the header the request is refused: the viewer fires error (loadfailure) and the square stays empty. For the library: npm i @google/model-viewer, copy model-viewer.min.js to your server and change the tag's src — no external dependency left. As long as you keep the CDNs, a Content-Security-Policy must allow ajax.googleapis.com in script-src and cdn.jsdelivr.net in connect-src.camera-orbit is interpolated by model-viewer with interpolation-decay at 50 ms by default — measured, a 17° notch is reached within 1° in 231 ms, a 180° jump in 377 ms. And the interpolation picks the shortest path: ask for 190° from 0° and the camera goes through 357.9°, the other way round. With the wheel (17° per notch) you never see it; dragging the scrollbar or flicking a trackpad, you do. interpolation-decay="0" locks the camera to the scroll (9 ms measured); to forbid the backward turn, accumulate the angle without wrapping it under 360 — model-viewer accepts values beyond.