Elastic Cursor
Un disque de 24 px collé au pointeur, étiré par un ressort amorti selon la vitesse — jusqu'à scaleX 2,2, orienté par atan2 — et une traîne de 16 px qui le suit à 15 % par image ; à l'arrêt, le disque passe par un creux à 0,91 et se stabilise en 130 ms. La zone masque le pointeur natif (cursor: none).
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. Curseurs comprend 26 effets, dont 1 gratuits. Découvrir la catégorie →
3 exemples d'usage



Comment ça marche
La zone .elastic-zone (100% × 300px, dégradé radial #1a1a3e → #0f0f25, position: relative, cursor: none) contient deux enfants absolus, ronds, en pointer-events: none et transform: translate(−50%, −50%) : .elastic-cursor-el, le disque de 24 px en dégradé #a78bfa → #6366f1 (z-index: 2), et .elastic-cursor-trail, la traîne de 16 px en rgba(139,92,246,.3) (z-index: 1). Trois écouteurs sur la zone : mouseenter lève un drapeau et lance la boucle requestAnimationFrame ; mouseleave le baisse — la boucle s'arrête à l'image suivante — et met les deux éléments à opacity: 0 ; mousemove enregistre la position du pointeur relative à la zone et remet opacity: 1.
À chaque image, la boucle fait quatre choses. La traîne rattrape le pointeur par interpolation : l += .15 × (r − l), soit 15 % de l'écart restant par image — 95 % du chemin en une vingtaine d'images, environ 0,3 s (mesuré : recollée à moins de 1 px en 284 ms). Le disque, lui, est placé exactement sous le pointeur. La vitesse est le déplacement du pointeur depuis l'image précédente, en px par image ; elle fixe une cible d'étirement g = 1 + min(.04 × vitesse, 1.2) : 1 à l'arrêt, 2,2 dès 30 px par image. L'angle q = atan2(dy, dx) n'est mis à jour que si la vitesse dépasse 0,5 px par image : à l'arrêt, le disque garde sa dernière direction, et c'est le long de cet axe qu'il rebondit.
Le ressort — ajouté le 5 septembre 2026 pour tenir la promesse du nom — relie l'étirement courant v à sa cible : w += (g − v) × .2 (raideur), w *= .6 (amortissement), v += w. Le disque reçoit rotate(q) scaleX(v) scaleY(1 / (.5 + .5 × v)) : à v = 2,2, la hauteur tombe à 0,625, le disque devient une goutte allongée dans le sens du mouvement. Mesuré dans Chromium sur un balayage horizontal rapide : scaleX 2,28 au plus fort (le ressort dépasse la cible), scaleY 0,61, rotation 0° ; balayage vertical, rotation 90°.
À l'arrêt, la cible retombe à 1 et le ressort la traverse : mesuré, scaleX 2,08 → 1,62 → 1,22 → 1,00 à 58 ms, creux à 0,91 à 83 ms, 0,95 → 0,98 → 1,00 à 133 ms, puis stable à ±0,02 — le rebond promis, en un peu plus d'un dixième de seconde. Les deux disques sont à opacity: 0 dans le CSS livré : avant le premier survol, rien n'est visible — sans cet état initial, ils apparaissaient dans le coin haut-gauche de la zone, à −4/−4 px et 0/0 px, opacité 1 (mesuré). Une limite du code : bien que will-change: transform soit déclaré, left/top sont réécrits à chaque image, ce qui déclenche une mise en page — la seule transform aurait suffi.
Accessibilité
- prefers-reduced-motion absent du code : étirement, rebond et traîne jouent quelle que soit la préférence. Ne posez pas les écouteurs si
matchMedia('(prefers-reduced-motion: reduce)').matches— et, dans ce cas, retirez aussicursor: nonede la zone, sinon l'utilisateur perd son pointeur sans rien à la place. cursor: nonesupprime le pointeur natif dans toute la zone : le disque est l'unique repère de position, et il n'existe que pendant le survol, après le premiermousemove. Sans JavaScript, ou avant son exécution, la zone est un trou sans pointeur — n'appliquezcursor: noneque sous une classe posée par le script (.js .elastic-zone). Les éléments interactifs placés dans la zone restent cliquables (les disques sont enpointer-events: none) mais sont visés avec un disque de 24 px, pas une flèche.- Contraste : le disque en
#a78bfa → #6366f1mesure 6,1:1 et 4,2:1 sur le dégradé de la zone, au-dessus du seuil de 3:1 d'un indicateur graphique — il reste repérable. La traînergba(139,92,246,.3)est à 1,5:1, purement décorative. Le texte d'invite fourni,rgba(255,255,255,.15), est à 1,6:1 : remontez-le à.5(5:1) s'il doit être lu. - Tactile : aucun
mousemove, donc pas de disque, etcursor: nonen'y change rien. Mais certains navigateurs mobiles émettent unmouseenterau toucher sans jamais émettre demouseleave: la bouclerequestAnimationFramedémarre et ne s'arrête plus. Conditionnez l'initialisation àmatchMedia('(hover: hover) and (pointer: fine)'). Lecteurs d'écran : deuxdivvides, muets ; posezaria-hidden="true"sur la zone. - Intégration : la zone doit rester en
position: relativeavec une hauteur explicite (300pxdans le code vendu ; un pourcentage sans parent dimensionné rend 0 px), et contenir directement les deuxdiv— le script les cherche parquerySelectordans la zone et ne fait rien s'il en manque un. La remise à zéro à la sortie est faite (opacité 0, boucle arrêtée, pointeur natif rendu hors de la zone) ; l'état initial est couvert par l'opacity: 0des deux éléments dans le CSS.
Compatibilité navigateur
Code ES5 (var, function) : il requiert requestAnimationFrame, getBoundingClientRect, les transformations 2D et linear-gradient. cursor: none est reconnu partout depuis 2012. Zéro dépendance.
Sans JavaScript, la zone masque le pointeur natif (cursor: none) et les deux disques restent invisibles (opacity: 0) : l'utilisateur n'a plus de curseur dans la zone. Réservez cursor: none à une classe ajoutée par le script. Sans requestAnimationFrame (IE 9), la boucle ne démarre pas et le même trou apparaît.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="elastic-zone" data-elastic-cursor>
<div class="elastic-cursor-el"></div>
<div class="elastic-cursor-trail"></div>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;color:rgba(255,255,255,0.15);font-size:0.8rem;">Déplacez le curseur pour voir l'élasticité</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 |
|---|---|---|
Suivi de la traîne (JS — l += .15 × (r − l)) |
.15 par image | Fraction de l'écart rattrapée à chaque image. .05 = traîne lointaine et lente (95 % du chemin en 1 s) ; .4 = presque collée au disque. Vaut pour x et y. |
Sensibilité à la vitesse (JS — .04 × vitesse, plafond 1.2) |
+0,04 par px/image, max +1,2 (scaleX 2,2) | Le coefficient fixe à quelle vitesse la goutte s'allonge (.08 : deux fois plus tôt), le plafond l'étirement maximal. min(.02 × v, .6) donne un disque qui se déforme à peine. |
Ressort (JS — w += (g − v) × .2 ; w *= .6) |
raideur .2, amortissement .6 | Raideur = vitesse de convergence vers la cible ; amortissement = fraction de la vitesse conservée à chaque image. .3 / .5 : rebond plus profond et plus court ; .1 / .8 : oscillations lentes et visibles ; .2 / 0 : plus de rebond du tout. |
Seuil de rotation (JS — t > .5) |
.5 px/image | En dessous, l'angle n'est pas mis à jour : le disque garde l'axe de son dernier mouvement et rebondit le long de cet axe. Montez à 2 pour ignorer les micro-tremblements de la main. |
Tailles (CSS — .elastic-cursor-el 24 px, .elastic-cursor-trail 16 px) |
24 px / 16 px | Diamètres au repos ; l'étirement les multiplie (24 px devient 53 × 15 px à scaleX 2,2). Le translate(−50%, −50%) garde le centre sous le pointeur quelle que soit la taille. |
Couleurs (CSS — dégradé #a78bfa → #6366f1, traîne rgba(139,92,246,.3)) |
violet → indigo, traîne violette à 30 % | Le disque doit rester à 3:1 au moins sur votre fond, c'est le seul pointeur. Une traîne plus opaque (.6, 2,3:1) devient un second point visible plutôt qu'une ombre. |
cursor: none (CSS — .elastic-zone) |
pointeur natif masqué | Indispensable pour que le disque remplace la flèche, dangereux sans JavaScript. Déplacez-le sous une classe posée par le script (.js .elastic-zone { cursor: none }) et n'en héritez pas sur les liens si vous voulez garder la flèche sur eux. |
FAQ
.elastic-cursor-el et .elastic-cursor-trail posent opacity: 0. Positionnés en absolu sans left/top, les deux div se trouvent bien à l'origine de la zone, décalés d'un demi-diamètre vers l'extérieur par le translate(−50%, −50%) (le disque à −4/−4 px, la traîne à 0/0 px), mais invisibles. Le script écrit opacity = '1' en ligne au premier mousemove — l'écriture en ligne l'emporte sur la règle — et '0' au mouseleave. Si vous retirez ces deux déclarations, les disques apparaissent dans le coin dès le chargement, à opacité 1 (mesuré).v est tiré vers sa cible g (1 à l'arrêt, jusqu'à 2,2 en vitesse) : w += (g − v) × .2 ajoute à la « vitesse d'étirement » un cinquième de l'écart, w *= .6 en dissipe 40 %, v += w applique le tout. Quand la main s'arrête, v descend de 2,2 vers 1, dépasse (mesuré : creux à 0,91 à 83 ms) et revient (1,00 à 133 ms, stable ensuite). La raideur .2 règle la vitesse du retour, l'amortissement .6 la profondeur du creux : .3 / .5 pour un rebond plus sec, .1 / .8 pour des oscillations lentes, amortissement 0 pour supprimer le rebond.cursor: none posé en CSS sur la zone. Sans JavaScript : la règle s'applique, aucun disque ne suit. Avant le premier mousemove : la flèche est déjà masquée, le disque encore invisible. En mouvement réduit, si vous coupez l'effet côté JS sans toucher au CSS. La parade est la même : ne déclarer cursor: none que sous une classe que le script ajoute au html (.js .elastic-zone), après avoir vérifié matchMedia('(hover: hover) and (pointer: fine)') et l'absence de préférence de mouvement réduit. Hors de la zone, le pointeur natif revient toujours — la règle ne s'applique qu'à elle.