Scroll Velocity Feedback
Dix cartes dans une boîte qui défile : à chaque image, le script mesure le déplacement de scrollTop, le lisse à 20 % et l'écrit dans deux variables CSS — skewY jusqu'à 4° et scaleY jusqu'à +12 % à 30 px par image. Mesuré : une rafale plafonnée n'affiche que 1,5° à 120 Hz, la boîte de 420 px garde 284 px de course quelle que soit la largeur, et la boucle ne s'arrête jamais.
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
Anatomie : .svf-scene (height: 420px plafonnée par max-height: 100vh, overflow: hidden, isolation: isolate, fond #0c1022, texte blanc en sans-serif, arrondi 14 px) contient .svf-scroll, un conteneur absolu sur inset: 0 à overflow-y: auto, 20 px de marge interne et barre masquée (scrollbar-width: none doublé de ::-webkit-scrollbar { width: 0 }). Dedans, .svf-grid aligne dix .svf-card de 120 px sur deux colonnes (écart 16 px), quatre dégradés par nth-child(4n+1…4n), libellé « 01 » à « 10 » en blanc 15 px gras. Chaque carte porte transform: skewY(calc(var(--svf-v, 0) * 4deg)) scaleY(calc(1 + var(--svf-a, 0) * .12)), transition: transform .08s linear et will-change: transform. Le contenu fait 5 × 120 + 4 × 16 + 40 = 704 px pour une boîte de 420 px : 284 px de course quelle que soit la largeur (mesuré : scrollHeight − clientHeight = 284 à 1 280 px comme à 390 px). La hauteur est fixe, et non un ratio de la largeur, précisément pour cela : une boîte qui grandirait avec l'écran finirait par contenir tout son contenu et n'aurait plus rien à faire défiler. Le compteur .svf-meter (aria-hidden, 11 px, coin bas droit) affiche « vélocité » et .svf-val. Les dix cartes livrées portent en ligne style="--svf-v: 0.000; --svf-a: 0.000;" : un état de repos figé à l'extraction, sans effet puisque le script réécrit ces variables à chaque image.
La boucle : pour chaque .svf-scene, une fonction loop() en requestAnimationFrame lit scrollTop, calcule raw = cur − last (le déplacement depuis l'image précédente, en pixels) puis v += (raw − v) × 0.2 — un lissage exponentiel qui comble 20 % de l'écart par image. clamped = clamp(v, −30, 30) / 30 ramène la vitesse entre −1 et 1 ; la boucle refait scene.querySelectorAll('.svf-card') et écrit sur chacune --svf-v (signé, trois décimales) et --svf-a (valeur absolue), puis Math.round(|v|) dans le compteur, non plafonné. En descendant, --svf-v est positif : skewY fait plonger le bord droit de chaque carte ; en remontant, il remonte. Tout se compte en images, pas en millisecondes : après l'arrêt, v garde 33 % de sa valeur cinq images plus tard, 11 % après dix, moins de 0,5 % après vingt-quatre — 0,2 s à 120 Hz, 0,4 s à 60 Hz. Mesuré à 122 Hz : un cran de molette de 100 px appliqué en une image donne v = 20, --svf-a 0,667, compteur « 20 », retour à zéro en 180 ms ; un saut à bout de course (284 px en une image) donne v = 57 (compteur), plafonné à 1,000 pendant trois images.
Ce qui s'affiche est plus discret que la formule. La transition de 80 ms court après une cible qui perd 20 % par image : sur un geste bref, le transform calculé n'atteint jamais le nominal. Mesuré sur la première carte, rafale plafonnée (--svf-a = 1) : 1,47° et scaleY 1,044 à 122 Hz, 2,77° et 1,083 à 60 Hz émulé — 37 % puis 69 % des 4° / +12 % annoncés ; cran de 100 px : 0,74° / 1,022 à 122 Hz, 1,11° / 1,033 à 60 Hz ; retour visuel au net 350 ms après le geste. Seul un défilement soutenu approche la valeur nominale : 16 px par image maintenus donnent --svf-a 0,53, soit 2,1° — 1,31° mesurés après treize images. Deux conséquences : l'amplitude dépend de la fréquence de l'écran (plus forte à 60 Hz qu'à 120), et la boucle ne s'arrête jamais — mesuré au repos, sans aucun défilement : 2 400 setProperty et 120 querySelectorAll par seconde pour réécrire « 0.000 » sur dix cartes ; sous mouvement réduit, zéro écriture mais toujours 120 appels requestAnimationFrame par seconde.
Accessibilité
prefers-reduced-motionest présent et le repli est complet :matchMediaest lu une fois au chargement et, sousreduce, la boucle sort avant toute écriture — mesuré : 0setProperty,transformidentité après 300 px de molette, compteur figé sur « 0 », défilement natif intact (WCAG 2.3.3, animation déclenchée par l'interaction : respecté). Trois limites : un changement de préférence en cours de page n'est pas suivi (pas d'écouteurchange) ; la classesvf-reducedque le CSS prévoit (transform: none !important) n'est jamais posée par le script, c'est un interrupteur manuel ; le compteur reste affiché à 0 — masquez-le sous@media (prefers-reduced-motion: reduce).- Barre de défilement masquée et boîte sans
tabindex:scrollbar-width: noneet::-webkit-scrollbar { width: 0 }retirent la seule poignée visible, et rien n'indique que la zone défile. Mesuré dans Chromium : Tab atteint.svf-scroll(conteneurs défilants focalisables au clavier depuis Chrome 130), Flèche bas fait 40 px par appui (vde pointe 8,--svf-a0,27) ; Firefox et Safari ne s'y arrêtent pas. Poseztabindex="0",role="region"et unaria-labelsur.svf-scroll, avec un:focus-visibleenoutline-offset: -3px(l'overflow: hiddende la scène coupe un contour extérieur). Aucunoverscroll-behavior: à bout de course, la molette passe à la page (mesuré : 284 px consommés par la boîte, le cran suivant fait défiler la page de 300 px). - Le texte se déforme avec la carte :
skewYcisaille etscaleYétire tout ce que la carte contient, chiffres compris, pendant le geste. Réservez l'effet à des conteneurs (lignes, vignettes) et gardez-le hors des paragraphes qu'on lit en défilant. Contraste des libellés vendus (blanc 15 px gras, seuil 4,5:1) : de 1,25:1 sur l'extrémité menthe du premier dégradé à 3,1:1 au mieux — aucun ne passe ; ce sont des marqueurs de démonstration, remplacez-les par votre contenu ou passez le texte en#0c1022(6,8:1 à 9,7:1 au milieu des quatre dégradés). Le compteur tient : 9,5:1 pour le libellé et 15,8:1 pour la valeur sur son fond noir à 45 %. - Lecteurs d'écran et tactile : le compteur est
aria-hidden="true"— indispensable, il changerait jusqu'à 120 fois par seconde — et le script n'écrit que des propriétés personnalisées en ligne, l'arbre d'accessibilité n'est jamais touché. Les cartes sont desdivsans rôle : dans une vraie liste, gardez vosul/liou vos liens et posez les classes dessus. Le libellé « vélocité » est codé en dur, en français, dans le HTML vendu. Au doigt, l'effet suit le glissé et l'inertie puisqu'il litscrollTopet non des événements : mesuré en émulation tactile, glissé de 160 px en 130 ms →--svf-a0,22, retour à zéro à la fin du geste.
Compatibilité navigateur
Code ES5 (var, function, IIFE), requestAnimationFrame, matchMedia, NodeList.forEach (Chrome 51, Firefox 50, Safari 10), variables CSS lues dans deux calc() d'un transform (Chrome 49, Firefox 31, Safari 9.1), will-change, isolation, inset et place-items. Le plancher réel est le raccourci inset (Chrome 87, Firefox 66, Safari 14.1), qui étire .svf-scroll sur toute la boîte ; la hauteur de .svf-scene est une simple height plafonnée par max-height: 100vh, comprise partout. scrollbar-width (Firefox 64, Chrome 121, Safari 18.2) est doublé de ::-webkit-scrollbar { width: 0 }. La vitesse est calculée par le script à partir de scrollTop, pas par animation-timeline : l'effet fonctionne dans Firefox, qui ne prend en charge les animations pilotées par le scroll en CSS natif dans aucune version — et aucune propriété CSS ne sait de toute façon exposer une vitesse, seulement une position. Zéro dépendance : aucune bibliothèque, aucune image, aucun fichier à héberger — les trois blocs collés dans une page vierge suffisent.
Sans JavaScript, la boîte défile normalement, les cartes restent droites (les variables gardent leur 0.000 en ligne) et le compteur affiche « 0 » — mesuré : transform identité après 300 px de molette. Sans le raccourci inset (navigateurs d'avant 2021), .svf-scroll n'est plus étiré sur la boîte : remplacez-le par top: 0; right: 0; bottom: 0; left: 0. Sans variables CSS, calc(var(--svf-v, 0) * 4deg) est invalide et toute la déclaration transform est ignorée : cartes droites, aucune erreur.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="svf-scene">
<div class="svf-meter" aria-hidden="true">vélocité <b><span class="svf-val">0</span></b></div>
<div class="svf-scroll">
<div class="svf-grid">
<div class="svf-card">01</div>
<div class="svf-card">02</div>
<div class="svf-card">03</div>
<div class="svf-card">04</div>
<div class="svf-card">05</div>
<div class="svf-card">06</div>
<div class="svf-card">07</div>
<div class="svf-card">08</div>
<div class="svf-card">09</div>
<div class="svf-card">10</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 |
|---|---|---|
Angle maximal (CSS — skewY(calc(var(--svf-v, 0) * 4deg))) |
4° | Inclinaison atteinte à 30 px par image ; le signe suit le sens du défilement. Sur un geste bref, le pic visible reste bien en dessous (1,5° mesuré à 120 Hz, 2,8° à 60 Hz) : 6 à 8° pour des tuiles étroites, 2° pour des lignes de texte. |
Étirement maximal (CSS — scaleY(calc(1 + var(--svf-a, 0) * .12))) |
+12 % | Allongement vertical, toujours positif (valeur absolue). Les cartes s'étirent depuis leur centre et mordent de 7 px sur leurs voisines à pleine vitesse ; 0 pour ne garder que l'inclinaison. |
Plafond de vitesse (JS — Math.max(-30, Math.min(30, v)) / 30) |
30 px par image | Vitesse à laquelle l'effet sature. Un cran de molette de 100 px appliqué d'un coup donne 20 ; un glissé tactile mesuré, 7. 15 sature plus tôt et exagère les gestes doux ; 60 réserve la pleine amplitude aux grands coups. |
Lissage (JS — v += (raw - v) * 0.2) |
0,2 par image | Fraction de l'écart comblée à chaque image : montée à 89 % en dix images, retour sous 0,5 % en vingt-quatre. 0.1 = inertie plus lourde (quarante-huit images), 0.5 = réaction sèche. Compté en images : deux fois plus long en millisecondes sur un écran 60 Hz. |
Transition (CSS — transition: transform .08s linear) |
80 ms | Second lissage, en millisecondes celui-là, qui rabote le pic visible d'un geste bref (1,47° au lieu de 4° mesurés à 120 Hz). none pour que la carte suive la variable image par image — le choix des trois exemples ; .15s pour un rendu plus mou. |
Contenu et hauteur de la boîte (HTML — 10 .svf-card de 120 px ; CSS — height: 420px; max-height: 100vh) |
704 px de contenu, boîte de 420 px | La course est la différence des deux : 284 px, quelle que soit la largeur. Mettez votre liste réelle (visez au moins deux hauteurs de boîte pour que la vitesse ait le temps de monter) ou changez la hauteur, en gardant la boîte plus basse que son contenu : à égalité, plus rien ne défile, donc plus rien ne bouge. Le plafond 100vh l'empêche seulement de dépasser l'écran. |
Compteur (HTML — .svf-meter / .svf-val) |
« vélocité » + px par image, aria-hidden |
Affiche Math.round(|v|) sans plafond (57 mesuré pour un saut à bout de course, 284 px en une image). Supprimez le bloc entier : le script teste if (val). Le libellé est en français dans le HTML. |
Sélection des cartes (JS — scene.querySelectorAll('.svf-card') dans la boucle) |
à chaque image | Recherche les cartes 120 fois par seconde et écrit vingt variables. Sortez la requête de la boucle, ou écrivez les deux variables une seule fois sur .svf-grid — les cartes en héritent — pour diviser les écritures par dix. |
FAQ
scrollHeight − clientHeight : 704 px de cartes moins la hauteur de la boîte. Avec une hauteur proportionnelle à la largeur (aspect-ratio: 16/10, par exemple), la boîte grandit avec l'écran et finit par contenir tout son contenu — mesuré avec ce ratio : 214 px de course à 784 px de large, 89 à 1 000, zéro dès 1 127 px, et la molette passe directement à la page. Sans défilement, raw vaut toujours 0, v reste à 0, les cartes ne bougent pas et le compteur non plus. La hauteur fixe garantit 284 px de course partout (mesuré à 1 280 px comme à 390 px) ; max-height: 100vh l'empêche seulement de dépasser l'écran. Si vous changez la hauteur ou le contenu, vérifiez dans la console que s.scrollHeight − s.clientHeight reste franchement positif — visez au moins deux hauteurs de boîte pour que la vitesse ait le temps de monter.scrollTop / (scrollHeight − clientHeight), de 0 à 1 — dans un écouteur scroll passif, et la traduisent en remplissage ou en entrées d'éléments : arrêtez-vous à mi-course, la scène reste à mi-course. Celui-ci calcule une dérivée : la différence de scrollTop entre deux images, lissée, sans aucun écouteur ; arrêtez-vous n'importe où, tout revient à zéro en vingt-quatre images. C'est la seule information du lot qui décrit le geste et non l'endroit, et aucune animation pilotée par le scroll en CSS (animation-timeline) ne peut la produire : une timeline expose une position, jamais une vitesse. Le prix : une boucle requestAnimationFrame permanente là où les deux autres ne travaillent que pendant l'événement. Les trois s'empilent d'ailleurs sans conflit sur une même boîte — variables et classes différentes — si vous voulez une progression et un feedback de vitesse.loop() redemande une image avant même de tester quoi que ce soit. Mesuré au repos, boîte immobile : 120 appels requestAnimationFrame, 120 querySelectorAll et 2 400 setProperty par seconde pour réécrire 0.000 sur dix cartes — et encore 120 appels par seconde sous mouvement réduit, où la boucle ne fait rien. Sur une page qui reste ouverte, c'est un recalcul de style à chaque image pour rien. Correctif en six lignes : gardez un drapeau running ; dans loop(), si raw === 0 et |v| < 0.01, écrivez une dernière fois 0, mettez running = false et ne redemandez pas d'image ; sur scroller.addEventListener('scroll', …, { passive: true }), si !running, remettez last = scroller.scrollTop, running = true et relancez loop(). Sous mouvement réduit, ne lancez simplement pas la boucle : if (reduceMotion) return; avant l'appel. Pour une boîte hors écran, un IntersectionObserver peut couper de la même façon.