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.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 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 (CSS + JS available with a premium account):
<!-- 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>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
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.