Pinned Image Sequence
Un vrai modèle glTF — ici une paire de lunettes de démonstration, 371 Ko, 13 396 triangles, verres iridescents — rendu par le composant model-viewer (chargé depuis un CDN) et épinglé en position: sticky dans une piste de 320 % : chaque événement scroll réécrit camera-orbit, 17° par cran de molette, 21 crans pour le tour, et la caméra rattrape sa cible en 230 ms. Le modèle est à remplacer par le vôtre.
Vous aurez accès à la démo interactive avec un compte gratuit.
Cet effet fait partie d'Effect.Labs — 811 effets vanilla, une partie gratuite, une partie premium. Scroll comprend 70 effets, dont 12 gratuits. Découvrir la catégorie →
3 exemples d'usage



Comment ça marche
La zone .pis-scene (width: 100%, aspect-ratio: 4/3, overflow: hidden, isolation: isolate, dégradé radial #1b2350 → #0a0e1f) contient son propre défilement : .pis-scroll en position: absolute; inset: 0; overflow-y: auto, barre masquée (scrollbar-width: none + ::-webkit-scrollbar { width: 0 }). Dedans, l'espaceur .pis-track fait 320 % de la hauteur, et .pis-stage, en position: sticky; top: 0, mesure 31.25% de l'espaceur — exactement la hauteur du défileur (0,3125 × 3,2 = 1) : la scène reste épinglée pendant toute la course, qui vaut 2,2 hauteurs de zone. Mesuré à 1 280 px de large : zone de 1 264 × 948 px, espaceur de 3 034 px, 2 086 px de course ; à 400 px : zone de 384 × 288, course de 634 px. Au centre, <model-viewer class="pis-mv"> — 240 × 240 px fixes, arrondi 16 px, fond dégradé, --poster-color: transparent — avec camera-orbit="0deg 75deg 105%", field-of-view="32deg", exposure="1.1", shadow-intensity="1", interaction-prompt="none", disable-zoom, disable-tap, reveal="auto" et un src qui pointe vers le modèle de démonstration servi par jsDelivr ; dessous, la légende .pis-cap (« angle 0° », 12 px, #aeb2c8) et, en haut, l'indication .pis-hint (« Scrollez pour faire tourner », pastille rgba(0,0,0,.4), z-index: 4). Légende, indication et viewer sont en pointer-events: none : la molette traverse tout et atteint le défileur.
La bibliothèque arrive par une balise module placée en tête du HTML : model-viewer 3.5.0 depuis Google Hosted Libraries (ajax.googleapis.com) — 935 194 octets, 254 Ko compressés sur le fil, three.js r163 embarqué. Un module ES est différé par nature : il ne bloque pas l'affichage, et l'élément <model-viewer>, déjà dans le DOM, est mis à niveau dès que sa définition arrive (mesuré : customElements.whenDefined résolu entre 170 et 810 ms). reveal="auto" lance alors la requête du modèle : SunglassesKhronos.glb, 371 188 octets (277 Ko compressés), servi par jsDelivr depuis le dépôt d'exemples Khronos et figé sur un commit — huit maillages, 13 396 triangles, une texture PNG et cinq matériaux PBR (branches, crochets, plaquettes, deux couches de verre en KHR_materials_transmission, volume, ior et iridescence), sous licence CC BY 4.0 (Eric Chadwick, Darmstadt Graphics Group). Mesuré : 1,6 à 2,3 s entre le chargement de la page et l'événement load du viewer, fichiers servis par les CDN. C'est un modèle de démonstration : remplacez-le par le vôtre. Ensuite update(), posé sur scroll (passive) et resize : p = scrollTop / (scrollHeight − clientHeight), a = Math.round(p × 360), puis mv.setAttribute('camera-orbit', a + 'deg 75deg 105%') — l'azimut suit le défilement, l'élévation (75°) et la distance (105 % du cadrage automatique) sont figées dans la chaîne. La légende reçoit a, l'indication passe à opacity: 0 dès p > 0.04 (84 px mesurés). Un événement, une écriture : 9 événements scroll ont produit 9 mutations de l'attribut. Un cran de molette de 100 px vaut 17° ; 21 crans font le tour à 1 264 px de large, 7 à 384 px.
Le lissage n'est pas dans le code : camera-orbit fixe une cible que la caméra de model-viewer rejoint selon interpolation-decay (50 ms par défaut, attribut absent). Mesuré après un cran : à 5° de la cible en 123 ms, 1° en 231 ms, 0,1° en 364 ms ; après un saut de 0 à 180° : 10° en 238 ms, 1° en 377 ms. Et la caméra prend le chemin le plus court : un saut de 0 à 190° passe par 357,9° (elle tourne à l'envers), 0 → 324° se fait en −36°. Invisible à la molette, visible quand on tire l'ascenseur d'un coup ou d'un geste rapide au trackpad. En fin de course (2 086 px), 360° est la vue de départ et le cran suivant fait défiler la page (chaînage mesuré : scrollY 100). Sous prefers-reduced-motion, le script sort avant de poser l'écouteur — mais n'ajoute jamais la classe pis-reduced que le CSS attend pour ramener l'espaceur à 100 % : la piste de 2 086 px reste, l'orbite reste à 0° et « Scrollez pour faire tourner » reste affiché (mesuré sur 8 crans). Restes d'une variante à cube CSS : .pis-cube et --pis-a dans le JS, perspective: 820px sur la scène et la règle .pis-scene.pis-reduced .pis-cube — aucun .pis-cube dans le HTML. L'indication porte un style="opacity: 1;" figé dans le HTML, que le script réécrit.
Accessibilité
prefers-reduced-motionprésent mais incomplet : le script lit la préférence et ne pose pas l'écouteurscroll— le modèle ne tourne plus —, mais il n'ajoute pas la classepis-reducedprévue par le CSS (.pis-track { height: 100% }). Mesuré avec la préférence active : 2 086 px de piste vide à faire défiler, indication « Scrollez pour faire tourner » àopacity: 1en permanence, orbite figée à 0°. Ajoutezscene.classList.add('pis-reduced')et masquez.pis-hintdans la sortie anticipée ; le modèle (1,3 Mo avec la bibliothèque, 530 Ko compressés) est chargé quand même.- Clavier : model-viewer expose dans son shadow DOM un
div.userInputavecrole="img",tabindex="0"et, pour nom, l'attributalt(« Lunettes de soleil 3D — vue 360° ») — un seul arrêt de tabulation, mesuré. Le focus étant dans.pis-scroll, les flèches font défiler la zone : ↓ = 40 px = 7°, soit 53 pressions pour le tour à 1 264 px de large. Écrivez unaltqui décrit l'objet, c'est le seul texte annoncé : la légende d'angle et l'indication sont enaria-hidden="true"et aucune zone live ne suit l'angle — acceptable pour un visuel décoratif, insuffisant si l'angle porte une information. - Contraste : légende
#aeb2c8à 12 px sur le dégradé de la zone, 7,1:1 sur#1b2350et 9,1:1 sur#0a0e1f; indication blanche sur la pastillergba(0,0,0,.4), 17,9:1. Les deux tombent sous 4,5:1 sur une page claire (2,1:1 et 2,85:1) : recolorez si vous retirez le dégradé. Le modèle n'a pas d'exigence de contraste, mais sonaltoui. - Piège à molette : la zone est un défileur imbriqué qui capture la molette pendant 21 crans à 1 264 px de large (7 à 384 px) avant de rendre la main à la page (chaînage mesuré au cran 22). Un lecteur qui descend la page s'y arrête sans l'avoir voulu ; un utilisateur au trackpad peut passer les 360° en un geste. Raccourcissez la course (
.pis-track { height: 200% },.pis-stage { height: 50% }) ou sortez la zone du flux de lecture principal. - Intégration : la rotation est déclenchée par l'utilisateur, jamais seule ; le viewer est en
pointer-events: none, donc pas de rotation à la souris ni au toucher — une seule commande, cohérente. Prévoyez l'échec : si le CDN de la bibliothèque ne répond pas, l'élément reste inconnu (carré dégradé de 240 px) ; si le modèle est injoignable, ou hébergé sur un domaine qui n'autorise pas le CORS, model-viewer émeterror(type: 'loadfailure', mesuré) et n'affiche rien — écoutez-le et affichez une image de repli.
Compatibilité navigateur
Requiert les modules ES (type="module"), les custom elements, position: sticky, aspect-ratio, inset et WebGL 2 (three.js r163 embarqué dans model-viewer 3.5.0) ; le modèle de démonstration utilise KHR_materials_transmission, volume, ior et iridescence, pris en charge par cette version. Le pilotage est en JavaScript classique (événement scroll + attribut) : il fonctionne dans Firefox, contrairement aux animations CSS en animation-timeline, que Firefox ne prend en charge dans aucune version. Deux ressources externes, servies avec Access-Control-Allow-Origin: * : la bibliothèque model-viewer (935 Ko, ajax.googleapis.com) et le modèle de démonstration (371 Ko, cdn.jsdelivr.net) — à remplacer par le vôtre.
Sans JavaScript, ni le module ni le script ne s'exécutent : la zone montre le carré dégradé de 240 px, « angle 0° » et l'indication, et 2,2 hauteurs de piste vide défilent quand même. Sans WebGL 2, model-viewer émet error et le carré reste vide. Si un CDN est bloqué (CSP sans ajax.googleapis.com ou cdn.jsdelivr.net, réseau filtré), même carré vide : hébergez alors les deux fichiers avec la page. Sous prefers-reduced-motion : modèle chargé, figé à 0°, indication affichée (voir accessibilité).
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<!-- bibliothèque model-viewer 3.5.0, chargée depuis le CDN Google -->
<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">Scrollez pour faire tourner</div>
<div class="pis-scroll">
<div class="pis-track">
<div class="pis-stage">
<!-- src : modèle de démonstration, à remplacer par le vôtre (CORS requis s'il est sur un autre domaine) -->
<model-viewer class="pis-mv" alt="Lunettes de soleil 3D — vue 360°" 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>
HTML + CSS + JS complet, prêt à coller — avec des centaines d'effets premium.
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
src du viewer (HTML — <model-viewer>) |
Lunettes de démonstration (SunglassesKhronos.glb, 371 Ko, jsDelivr) | N'importe quel glTF/GLB, hébergé avec la page (même origine : rien à configurer) ou sur un domaine qui envoie Access-Control-Allow-Origin — model-viewer le lit par fetch(), et sans cet en-tête le viewer émet error. Le poids commande l'attente : 1,6 à 2,3 s mesurées pour 371 Ko + 935 Ko de bibliothèque via les CDN. |
Course (CSS — .pis-track { height: 320% }) |
320 % (2,2 hauteurs) | Longueur de défilement pour un tour : 2 086 px à 1 264 px de large, 634 px à 384 px. 200% = tour en 10 crans, mais .pis-stage doit rester égal au défileur : passez-le de 31.25% à 50%, sinon l'épinglage décroche. |
Amplitude (JS — Math.round(p * 360)) |
360° | 180 = un demi-tour, 720 = deux tours. Au-delà de 180° dans un seul saut, la caméra part dans l'autre sens (chemin le plus court). |
Élévation et distance (HTML — camera-orbit="0deg 75deg 105%" ; JS — a + 'deg 75deg 105%') |
75° / 105 % | Deux endroits à changer ensemble : la chaîne du JS écrase l'attribut à chaque défilement. 90deg = vue de profil, 60deg = plus plongeante ; 120% éloigne la caméra. |
Lissage (HTML — interpolation-decay) |
absent = 50 ms | Retard de la caméra sur la cible : 1° en 231 ms par défaut ; 20 = 91 ms, 0 = collée au défilement (9 ms), 200 = 0,9 s de traîne (mesurés). |
Taille du viewer (CSS — .pis-mv { width: 240px; height: 240px }) |
240 × 240 px | Fixe quelle que soit la zone : 19 % de la largeur à 1 264 px, 83 % de la hauteur à 288 px. Passez en min(60%, 60vh) pour suivre le conteneur ; le 105% de camera-orbit recadre automatiquement. |
Rendu (HTML — exposure, shadow-intensity, field-of-view) |
1.1 / 1 / 32° | Luminosité, ombre portée au sol, focale. exposure="0.8" assombrit un modèle blanc ; field-of-view="20deg" aplatit la perspective. |
Seuil de l'indication (JS — p > 0.04) |
4 % (84 px) | L'indication disparaît après 4 % de course. Posée en left: 50%, elle ne dispose que de la moitié de la zone : mesurée sur deux lignes dès 324 px de large — ajoutez white-space: nowrap. |
| Chargement de la bibliothèque (HTML — balise module en tête du code) | avec la page (module différé, 935 Ko) | Le module ne bloque pas l'affichage, mais il part avec la page. Pour ne le télécharger qu'à l'approche de la zone, créez la balise depuis un IntersectionObserver (rootMargin: '300px') : l'élément déjà dans le DOM est mis à niveau à l'arrivée du module. Pour ne dépendre d'aucun CDN, npm i @google/model-viewer et pointez le src de la balise vers votre copie. |
FAQ
<model-viewer> et fait tourner sa caméra : la géométrie est réelle, lumière et ombre sont calculées à chaque angle. C'est ce qui le sépare de son voisin Image Sequence (fx-0583), qui commute 24 états CSS (dégradé, arrondi, taille) au fil d'un défilement de 920 px, sans image non plus — un simulacre de séquence. Ici on peut lire une charnière ou un filetage ; là-bas, non. Pour de vraies photos 360°, ce n'est pas le bon effet : model-viewer ne lit pas de série d'images.ajax.googleapis.com, 935 Ko, 254 Ko compressés) ; le src du viewer pointe vers un modèle de démonstration du dépôt d'exemples Khronos servi par jsDelivr (371 Ko, CC BY 4.0, URL figée sur un commit : elle ne bougera pas). Mesuré sur une page vierge : les deux répondent 200 avec Access-Control-Allow-Origin: *, et le viewer émet load entre 1,6 et 2,3 s. Pour votre modèle : hébergez le .glb à côté de la page (même origine, rien à configurer) ou sur un domaine qui envoie cet en-tête CORS — model-viewer le lit par fetch(), et sans l'en-tête la requête est refusée : le viewer émet error (loadfailure) et le carré reste vide. Pour la bibliothèque : npm i @google/model-viewer, copiez model-viewer.min.js chez vous et changez le src de la balise — plus aucune dépendance externe. Tant que vous gardez les CDN, une Content-Security-Policy doit autoriser ajax.googleapis.com en script-src et cdn.jsdelivr.net en connect-src.camera-orbit est interpolé par model-viewer avec interpolation-decay à 50 ms par défaut — mesuré, un cran de 17° est atteint à 1° près en 231 ms, un saut de 180° en 377 ms. Et l'interpolation choisit le chemin le plus court : demandez 190° depuis 0° et la caméra passe par 357,9°, en sens inverse. À la molette (17° par cran) on ne le voit jamais ; en tirant l'ascenseur ou d'un geste rapide au trackpad, si. interpolation-decay="0" colle la caméra au défilement (9 ms mesurés) ; pour interdire le retour arrière, cumulez l'angle sans le ramener sous 360 — model-viewer accepte les valeurs au-delà.