Rotation on Scroll
Un cadran de 120 px à 12 graduations, collé à 50 px du haut d'une zone de 300 px qui défile sur 900 px : son aiguille (::before) tourne de 720° sur une timeline nommée — scroll-timeline: --rotationScroll y sur la zone, animation-timeline: --rotationScroll sur l'aiguille. Mesuré 0,8° par pixel, un tour tous les 450 px, retour au tick de départ en bas de course. Zéro JavaScript ; Firefox : aiguille immobile, cadran et textes visibles.
Mis à jour le
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
Le HTML vendu empile un cadre .demo-preview (flex centré, min-height: 300px, position: relative, fond #0a0a0f, texte blanc, sans-serif), la zone .rotation-container (100% × 300 px, overflow: hidden auto, scroll-timeline: --rotationScroll y), un espaceur .rotation-spacer vide de 400% (1 200 px, padding-top: 20px) et le bloc .rotation-sticky (position: sticky; top: 50px, colonne centrée, gap: 12px) qui porte le cadran .rotation-dial — 120 × 120 px, bord de 3 px rgba(99,102,241,.3) — et l'étiquette .rotation-label « SCROLL TO ROTATE » (12 px, graisse 600, blanc à 50 %). Dans le cadran, douze .dial-tick de 2 × 8 px posés à top: 4px et tournés de 0 à 330° par pas de 30° (:nth-child(1) à (12)) autour de transform-origin: 50% 56px ; les 1er, 4e, 7e et 10e portent .dial-major (12 px, blanc à 60 %) et marquent les quarts de tour. L'aiguille est le pseudo-élément ::before : 3 px de large, 45 % de haut (51 px), dégradé transparent → #6366f1, ancré bottom: 50%; left: 50%, transform-origin: center bottom ; ::after pose le pivot, un disque de 10 px. Sous la zone, .slr-scroll-hint « Scrollez pour tourner » (11,2 px, blanc à 40 %, absolu à 8 px du bas du cadre) rebondit de 4 px toutes les 2 s. Course mesurée : 1 200 − 300 = 900 px. Aucun JavaScript, aucune variable du site, aucune erreur console.
Le mouvement tient en deux déclarations. scroll-timeline: --rotationScroll y sur la zone crée une timeline nommée qui vaut 0 en haut et 1 en bas de sa course ; animation-timeline: --rotationScroll sur ::before y attache l'animation dialRotate (translateX(-50%) rotate(0deg) → rotate(720deg), linear, animation-range: normal = toute la course, fill-mode: both, animation-duration: auto). Le navigateur remplace l'horloge par la position de défilement : l'angle vaut 720° × scrollTop ÷ 900, sans écouteur ni calcul. Mesuré dans Chromium par crans de molette de 150 px : 120°, 240°, 360°, 480°, 600°, 720° — 0,8° par pixel, un tour tous les 450 px, une graduation tous les 37,5 px — progress relu à 0,1667 par cran ; mêmes valeurs dans WebKit 26.5. Flèche bas (40 px) donne 32°, PageDown (302 px) 241,6°, Fin 720°. Remonter fait tourner l'aiguille à l'envers, sans inertie ni lissage. Le nom de la timeline se résout en remontant les ancêtres du pseudo-élément : deux copies du code collées dans la même page suivent chacune leur zone (mesuré : 0° et 180° avec la seconde à 225 px), et une zone peut piloter plusieurs cadrans.
Le cadran, lui, ne se déplace pas : top: 50px pousse le bloc collant à 50 px du haut de la zone dès le repos (sa place naturelle, 20 px, est sous le seuil) et l'y maintient sur les 900 px — mesuré : bloc à 50 px à toutes les positions, 146 px de haut, aiguille et étiquette toujours dans le cadre. C'est ce qui le sépare de Rotate on Scroll (fx-0561) et Rotate On Scroll (fx-0549), où l'objet qui tourne est dans le flux et traverse la boîte. La zone était vendue en height: 100% : sous un parent sans hauteur fixe — une page vierge, l'aperçu du site — elle prenait la hauteur de son contenu (166 px), l'espaceur en pour cent s'écrasait et il n'y avait rien à faire défiler (course mesurée 0, aiguille figée). Elle fait maintenant 300 px en dur ; changez cette valeur, l'espaceur suit.
Accessibilité
- prefers-reduced-motion absent du code : mesuré sous émulation, l'aiguille suit toujours le défilement (progression 0,5 à 450 px) et l'indice continue de rebondir (animation mesurée
running). La rotation est acceptable telle quelle : elle ne bouge que par le geste du lecteur (WCAG 2.3.3 vise le mouvement autonome). Le rebond, lui, est continu : ajoutez@media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }. - Clavier : mesuré, Chromium et Firefox donnent le focus à la zone par Tab sans
tabindex(activeElement=.rotation-container) ; flèche bas avance de 40 px (32°) dans Chromium, 51 px dans Firefox ; PageDown de 302 px (241,6°), Fin va au bout. WebKit 26.5 ne la focalise pas (focus resté surbody, flèches sans effet) : ajouteztabindex="0", unaria-labelet un style:focus-visible. Au doigt, le geste doit commencer dans la zone ;overscroll-behavior: containévite d'entraîner la page en bout de course. - Contraste sur
#0a0a0f: l'étiquette (blanc à 50 %, 12 px) vaut 5,3:1 ; l'indice (blanc à 40 %, 11,2 px) 3,8:1, sous le seuil de 4,5:1 — passez-le à 60 % ou posezaria-hidden="true", il est décoratif. Les ticks à 30 % valent 2,6:1, sous le 3:1 des éléments graphiques (les majeurs à 60 %, 7,3:1) ; l'aiguille et le pivot#6366f14,4:1 ; le bord du cadran 1,4:1, décoratif. Si la position de l'aiguille doit être lue, montez les ticks à 50 %. - Lecteurs d'écran et repli : le cadran est fait de
divvides, rien n'est lu et l'angle n'est pas exposé — si l'aiguille porte une information (un réglage, une position dans le texte), écrivez-la en texte à côté, l'effet n'a pas de script pour le faire, et masquez le dessin avecaria-hidden="true". « SCROLL TO ROTATE » et « Scrollez pour tourner » sont deux textes en dur, un en anglais, un en français, lus tels quels quelle que soit la langue de la page. Sans animation pilotée par le défilement (Firefox), le cadran est un dessin fixe et la zone défile ; sans CSS, une pile dedivvides et deux lignes de texte.
Compatibilité navigateur
CSS seul : scroll-timeline (timeline nommée), animation-timeline, animation-range, position: sticky, overflow: hidden auto, un dégradé et deux @keyframes. Zéro dépendance, zéro JavaScript. Les animations pilotées par le défilement existent dans Chrome et Edge 115+, Safari et iOS 26+ (mesuré dans WebKit 26.5 : 120° par cran de 150 px, valeurs identiques à Chromium). Firefox : aucune version. Mesuré dans Firefox 153 : CSS.supports('animation-timeline: scroll()') répond faux, la déclaration et animation-duration: auto sont ignorées (durée calculée 0s), dialRotate tourne sur l'horloge du document et se termine aussitôt (playState: finished, progression 1, fill-mode: both) : l'aiguille prend son image-clé finale, rotate(720deg) — deux tours exacts, donc visuellement identique au repos — et n'en bouge plus. La zone défile normalement (course 900 px), le cadran reste collé à 50 px, graduations, étiquette et indice sont affichés ; aucune erreur. Un visiteur Firefox voit un cadran fixe dont l'aiguille pointe le tick du haut, sous une invitation à défiler qu'il ne peut pas vérifier.
Le code vendu n'a pas de repli @supports et n'en a pas besoin pour rester lisible : l'image-clé finale est l'état de départ, rien n'est caché ni déplacé. Dans Firefox, Chrome avant 115 et Safari avant 26, le cadran est un dessin fixe et la zone une boîte qui défile. Si l'aiguille porte une information, donnez-la autrement à ces navigateurs, ou retirez l'invitation qui promet une rotation : @supports not (animation-timeline: scroll()) { .rotation-label, .slr-scroll-hint { display: none } }. Pour une rotation réelle partout, il faut un écouteur scroll qui écrit le transform, comme Zoom on Scroll (fx-0580) le fait pour son échelle. Là où overflow n'est pas honoré (certains clients mail), l'espaceur s'étire à 1 200 px et le cadran reste posé en haut, immobile.
Le code
Structure HTML à coller dans votre page (le CSS + JS sont inclus avec Premium) :
<div class="demo-preview">
<div class="rotation-container">
<div class="rotation-spacer">
<div class="rotation-sticky">
<div class="rotation-dial">
<div class="dial-ticks">
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
</div>
</div>
<span class="rotation-label">…</span>
</div>
</div>
</div>
<span class="slr-scroll-hint">…</span>
</div>
Le code complet HTML + CSS + JS de Rotation on Scroll, prêt à coller — et les 811 effets. Ou 299 € une fois, accès à vie.
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Angle total (CSS — @keyframes dialRotate, to { rotate(720deg) }) |
720° (deux tours, 0,8° par pixel sur 900 px) | L'aiguille retombe sur le tick de départ en bas de course. 360deg pour un tour par lecture, 330deg pour aller du premier au douzième tick sans revenir, 270deg pour une jauge à quart mort. Gardez le translateX(-50%) dans les deux images-clés : il centre l'aiguille sur le pivot. |
Hauteur de la zone et course (CSS — .rotation-container { height: 300px }, .rotation-spacer { height: 400% }) |
300 px / 1 200 px, course 900 px | Course = espaceur − zone ; angle par pixel = angle total ÷ course. 200% donnent 300 px de course et 2,4° par pixel ; 800%, 2 100 px et 0,34°. Une hauteur en pour cent sur la zone ne marche que sous un parent de hauteur fixe — c'est ce qui la rendait inerte collée seule. |
Point d'accroche (CSS — .rotation-sticky { top: 50px }, .rotation-spacer { padding-top: 20px }) |
50 px (bloc collant de 146 px) | Le bloc est poussé à 50 px dès le repos parce que sa place naturelle (20 px) est sous le seuil ; top: 20px le laisse en place et ne le colle qu'ensuite. Le bloc doit tenir dans la zone : 50 + 146 = 196 sur 300. |
Graduations et taille du cadran (HTML + CSS — douze .dial-tick, :nth-child(n) de 30° en 30°, transform-origin: 50% 56px, .rotation-dial 120 px) |
12 ticks, 4 majeurs à 0°, 90°, 180°, 270°, cadran de 120 px | Pour 8 ticks, huit div et des :nth-child tous les 45°. L'origine à 56 px vise un point 3 px sous le centre (4 + 56 = 60, centre de la boîte intérieure à 57 ; mesuré : tick du haut à 47 px du centre, tick du bas à 53) — 50% 53px corrige. L'aiguille suit la taille du cadran en pour cent, le pivot et les ticks non : pour 160 px, origine 50% 73px et bloc collant de 186 px, donc top ≤ 114 px. |
Couleurs (CSS — #6366f1 dans le dégradé de ::before, ::after et le bord ; blanc à .3 et .6 des ticks) |
indigo, ticks à 30 et 60 % | L'aiguille est un dégradé transparent → #6366f1 : sa base s'efface sous le pivot, sa pointe est pleine. Ticks à .3 = 2,6:1 sur #0a0a0f, .5 passe le 3:1. Sur fond clair, changez aussi le fond du cadre .demo-preview et le blanc des textes. |
Cible du défilement (CSS — scroll-timeline: --rotationScroll y) |
la zone de 300 px | Portez la déclaration sur html pour lier l'aiguille au défilement de la page (mesuré sur une page de 3 000 px : 180° à 600 px, 720° en bas), ou remplacez animation-timeline par scroll(root). Le cadran doit rester un descendant de l'élément qui porte le nom, en sticky ou fixed pour rester visible. |
Étiquette et indice (HTML — .rotation-label « SCROLL TO ROTATE », .slr-scroll-hint « Scrollez pour tourner ») |
anglais et français en dur, rebond de 4 px toutes les 2 s | Deux textes en dur dans deux langues : remplacez-les ou supprimez les span. L'indice s'accroche au cadre .demo-preview (position: relative) : sans ce cadre, il se cale sur le premier écran de la page, plus sur la zone ; hintBounce part avec lui. |
FAQ
.rotation-spacer par votre texte en gardant .rotation-sticky en premier : il reste collé pendant que le texte passe dessous. Donnez-lui un fond et un z-index, sinon les paragraphes suivants se peignent par-dessus le cadran ; top: 0 si le cadran doit affleurer. La course devient hauteur du contenu − 300 px et les 720° s'y répartissent d'eux-mêmes : 1 500 px de texte donnent 1 200 px de course, 0,6° par pixel. Deux limites : le cadran doit rester à l'intérieur de .rotation-container, car --rotationScroll se résout en remontant ses ancêtres — un cadran posé à côté de la zone ne tourne pas (timeline-scope sur un ancêtre commun lève cette limite, Chrome 116+ et Safari 26) ; et la zone garde une hauteur fixe, à remplacer par une valeur en vh ou par un parent qui en a une. Les trois scènes de cette page sont construites ainsi (courses mesurées 357, 375 et 327 px).animation-timeline: scroll() — la timeline anonyme du défileur le plus proche. fx-0561 fait pareil avec un carré de 120 px violet-cyan et deux anneaux en pointillés animés eux aussi (−360° pour l'un, +720° pour l'autre). Dans les deux, l'objet est dans le flux : il monte avec le contenu, traverse la boîte et sort par le haut, et rien ne dit où l'on en est. fx-0571 déclare une timeline nommée (scroll-timeline: --rotationScroll y) et colle un instrument : le cadran reste à 50 px du haut pendant toute la course, ses douze graduations donnent une lecture (un tick tous les 37,5 px), et seul le pseudo-élément tourne. Le nom permet aussi de choisir le défileur — une zone parente précise, ou la page via html — là où scroll() prend toujours le plus proche. Prenez fx-0549 ou fx-0561 pour un objet décoratif qui passe, fx-0571 quand la rotation doit lire une position (un réglage, une progression, un cap) et rester sous les yeux.rotate(360deg) (un tick = 1/12 de la course) ou rotate(330deg) (l'aiguille va du premier au douzième tick et s'y arrête). La position se lit ensuite sur les graduations : avec 720° sur 900 px, un tick vaut 37,5 px et un quart de tour 112,5 px ; une flèche bas de 40 px déplace l'aiguille de 32°, un PageDown de 302 px de 241,6° (mesuré). Rien n'est exposé aux lecteurs d'écran : si la position compte, écrivez-la aussi en texte.