Cursor Expand
Un disque fixe de 22 px qui suit le pointeur à 15 % de l'écart par frame et s'ouvre en anneau translucide selon l'élément visé — 64 px sur un lien, 96 px sur un bouton — en 250 ms. La position est initialisée à l'entrée dans la zone, l'état visé est lu sur l'attribut data-expand de la cible.
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 #zone-expand contient un .expand-btn portant data-expand="button" et un .expand-link portant data-expand="link". Le curseur est #cursorExpand (.cursor-expand) : position: fixed, 22 × 22 px, #6366f1, box-shadow: 0 0 0 4px rgba(99,102,241,.25), border-radius: 50%, pointer-events: none, z-index: 9999, display: none et transform: translate(-50%, -50%) — il est centré sur left/top par le CSS, sans soustraction de demi-taille dans le JS. Ses transitions : width et height en 250 ms cubic-bezier(.2,.8,.2,1), background, border-color et box-shadow en 250 ms.
mouseenter(e) initialise la position sur le pointeur — expandX = currentExpandX = e.clientX, idem en Y — puis ajoute cursor-active à la zone (règle cursor: none !important fournie) et affiche le disque. Mesuré : à la frame 0 le disque est déjà sous le pointeur (30, 30 pour un pointeur en 30, 30), pas de vol depuis le coin comme dans Dot Follower. mouseleave le cache et remet className = 'cursor-expand', ce qui purge l'état. mousemove mémorise la position, remonte de e.target au button ou au a le plus proche (e.target.closest('button, a'), à défaut e.target lui-même), lit son attribut data-expand, remet la classe de base puis ajoute on-button ou on-link.
Les deux états sont du CSS pur. .on-link : 64 px, fond rgba(99,102,241,.12), border: 2px solid #a5b4fc, box-shadow: 0 0 24px rgba(99,102,241,.45). .on-button : 96 px, fond rgba(99,102,241,.22), bordure #c7d2fe, ombre 0 0 32px rgba(99,102,241,.55) (valeurs mesurées). Le disque plein devient un anneau translucide qui encercle la cible. La largeur de bordure n'est pas dans la liste des transitions : elle saute de 0 à 2 px, seule sa couleur est animée.
Le déplacement passe par une boucle requestAnimationFrame permanente : currentExpandX += (expandX − currentExpandX) × 0.15, puis left/top. Mesuré à 120 Hz après un saut de 255 px : 113 px d'écart à la frame 5, 50 à la frame 10, 10 à la frame 20, moins de 1 px à la frame 39 (≈ 0,32 s ; deux fois plus en millisecondes à 60 Hz). Aucune transition CSS sur left/top, donc le lissage est celui de la seule boucle. La boucle tourne aussi hors survol (121 appels rAF/s mesurés, disque caché).
Accessibilité
- Deux curseurs à l'écran sur les cibles : la zone reçoit
cursor: none, mais les feuilles de style navigateur posentbutton { cursor: default }eta { cursor: pointer }, et une valeur déclarée l'emporte sur une valeur héritée. Mesuré :defaultsur le bouton,pointersur le lien pendant que l'anneau est affiché — la flèche ou la main système réapparaissent au centre de l'anneau. Ajoutez.cursor-active a, .cursor-active button { cursor: none }. - Contraste du libellé du bouton : blanc 13,3 px (taille par défaut d'un
button, aucune taille déclarée) sur le dégradé#6366f1 → #8b5cf6= 4,5:1 puis 4,2:1 — sous 4,5:1 sur la moitié violette. Passez à#4f46e5 → #7c3aed(6,3:1 et 5,7:1). Lien#6366f1sur#1a1a24= 3,9:1 :#a5b4fc(8,7:1) ou#818cf8(5,8:1). L'anneau au repos fait 3,9:1 sur la zone, au-dessus du seuil de 3:1 des composants. - Tactile : mesuré avec émulation tactile, un tap dans la zone affiche le disque figé et laisse
cursor-activemême après un tap hors zone. Conditionnez l'initialisation àmatchMedia('(hover: hover) and (pointer: fine)').matches. Bouton et lien restent utilisables au doigt. - Clavier :
Tabatteint bien le bouton et le lien (éléments natifs,outlineintact), mais l'anneau ne réagit jamais au focus — il n'écoute quemousemove. Donnez aux cibles un état:focus-visiblequi reproduise l'idée (par exempleoutline: 2px solid #c7d2fe; outline-offset: 6px) pour que l'utilisateur clavier ait son équivalent. - prefers-reduced-motion absent : le glissement à 15 % par frame et l'ouverture en 250 ms sont des animations d'interaction. Sous
@media (prefers-reduced-motion: reduce), retirez les transitions de.cursor-expandet mettez le facteur à 1 (disque collé, changement d'état instantané). Ledivest vide : ajoutezaria-hidden="true".
Compatibilité navigateur
Requiert ES2015 (const/let, fonctions fléchées), classList, Element.closest, getAttribute, requestAnimationFrame, les variables CSS de la zone et une transition CSS sur width/height/box-shadow. Zéro dépendance.
Sans JavaScript, le disque reste en display: none, la flèche système est conservée et le bouton comme le lien fonctionnent normalement. Sans transitions CSS, l'anneau change de taille d'un coup au passage sur une cible.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="cursor-zone" id="zone-expand">
<div class="expand-zone-content">
<button class="expand-btn" data-expand="button">Bouton</button>
<a href="#" class="expand-link" data-expand="link" onclick="return false;">Lien cliquable</a>
</div>
<span class="info-text">Curseur qui s'agrandit</span>
</div>
<!-- Ce div est le curseur lui-même : le script le déplace et le fait
apparaître. Sans lui, le script sort et l'effet ne rend rien. -->
<div class="cursor-expand" id="cursorExpand"></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 |
|---|---|---|
Facteur de lissage (JS — (expandX - currentExpandX) * 0.15) |
0.15 | Part de l'écart comblée par frame. 0.3 = disque presque collé ; 0.08 = traîne longue. Compté en frames, donc deux fois plus lent en millisecondes à 60 Hz. |
| Tailles (CSS — .cursor-expand 22px, .on-link 64px, .on-button 96px) | 22 / 64 / 96 px | Diamètres des trois états. Le translate(-50%, -50%) garde le centre en place quelle que soit la taille : aucun offset à ajuster dans le JS. |
Attributs ciblés (HTML — data-expand="button|link" ; JS — expandType) |
button, link | Ajoutez une valeur (data-expand="drag") et sa branche else if qui pose on-drag, puis la règle CSS correspondante : un état par nature de cible. |
Lecture de la cible (JS — e.target.closest('button, a')) |
closest('button, a') || e.target | Un span ou un svg placé dans le bouton remonte au bouton : l'état on-button vaut pour tout son contenu. Le sélecteur ne connaît que button et a — pour typer un div ou un label porteur de data-expand, élargissez-le à closest('[data-expand]'). |
| Transitions (CSS — .cursor-expand transition) | 250 ms, cubic-bezier(.2,.8,.2,1) | Durée et courbe de l'ouverture. Ajoutez border-width .25s pour animer aussi l'apparition de la bordure, qui saute aujourd'hui de 0 à 2 px. |
| Couleurs des états (CSS — .on-link / .on-button background, border, box-shadow) | indigo translucide, bordures #a5b4fc / #c7d2fe | Habillage des anneaux. mix-blend-mode: difference avec un disque blanc inverse ce qu'il survole et reste lisible sur tout fond. |
Position initiale (JS — mouseenter) |
e.clientX / e.clientY | Déjà en place : le disque apparaît sous le pointeur. Ne supprimez pas ces deux lignes, sinon il repart de (0, 0) à chaque première entrée. |
FAQ
cursor est une propriété héritée et que les navigateurs déclarent eux-mêmes cursor: default sur button et cursor: pointer sur a : une valeur déclarée sur l'élément bat le none hérité de la zone, même avec !important sur la zone. Mesuré : default sur le bouton, pointer sur le lien. Corrigez avec .cursor-active a, .cursor-active button { cursor: none } ou, plus large, .cursor-active * { cursor: none !important }.button ou un a : e.target est l'élément le plus profond sous le pointeur (un span, un svg), mais le code remonte avec closest('button, a') avant de lire data-expand — l'état posé sur le bouton vaut pour tout son contenu. En revanche, closest ne cherche que ces deux balises : sur un div ou un label porteur de data-expand, un enfant survolé rend e.target lui-même, sans attribut, et l'anneau retombe à 22 px. Remplacez alors le sélecteur par closest('[data-expand]') ; la lecture qui suit ne change pas.</body>, hors de tout conteneur. position: fixed avec left = clientX prend la fenêtre pour repère ; un ancêtre porteur de transform, filter, perspective, will-change: transform ou contain: paint devient le bloc conteneur et décale le disque de la position de cet ancêtre. Le code livré place le div dans la boîte de démo : déplacez-le à la racine dès l'intégration.