Gravity Well Cursor
Soixante points de 3 à 8 px, teintes de 220° à 300°, chacun rappelé vers sa position d'origine à 3 % par image ; dans un rayon de 30 % de la zone autour du pointeur, une poussée jusqu'à 3 % par image les écarte. Le code repousse, il n'attire pas — et sa boucle requestAnimationFrame ne s'arrête plus après la première entrée.
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
Pour chaque zone [data-gravity-well], le script crée 60 div.gravity-particle : diamètre 3 + 5 × Math.random() (3 à 8 px), teinte 220 + 80 × Math.random() appliquée en hsl(h, 70%, 65%) — du bleu au magenta —, halo box-shadow: 0 0 2·taille px hsla(h, 70%, 65%, .4), et une position d'origine tirée au hasard, écrite en left/top en pourcentage de la zone. Chaque particule garde en mémoire homeX/homeY (l'origine) et x/y (la position courante). Le CSS de la particule : position: absolute, ronde, transition: none. La zone : 100% × 300px, dégradé radial #12122e → #0a0a1a, cursor: crosshair, position: relative, overflow: hidden. Jusqu'au 5 septembre 2026, le HTML vendu contenait en plus 60 particules figées : 120 points, la moitié immobiles ; il n'en reste que les 60 du script.
La boucle d(), en requestAnimationFrame, traite chaque particule en deux temps. D'abord la poussée : le pointeur est converti en pourcentages de la zone (a, n) à chaque mousemove, et la distance h entre la particule et le pointeur est calculée dans ces unités. Si h < 30 et que le pointeur est dans la zone (a > −100), la particule reçoit un déplacement de 3 × (1 − h / 30) unités le long de l'angle atan2 plus π — c'est-à-dire à l'opposé du pointeur : 3 % de la zone par image au contact, rien à 30 unités. Ensuite le rappel : x += .03 × (homeX − x), 3 % de l'écart restant par image, soit 95 % du retour en une centaine d'images — environ 1,6 s à 60 images par seconde (mesuré : à 0,03 px de l'origine deux secondes après la sortie du pointeur).
Mesuré dans Chromium, pointeur immobile au centre : 24 particules déplacées de plus de 2 px, la plus éloignée de 61 px, et toutes s'éloignent du pointeur — c'est une répulsion. Le nom et l'ancienne description promettaient une attraction ; la description du catalogue a été corrigée (« les particules proches du curseur sont repoussées »), le code n'a pas changé. Conséquence des unités en pourcentage : le champ de 30 unités mesure 30 % de la largeur sur 30 % de la hauteur, une ellipse de 223 × 90 px dans une zone de 744 × 300 px — deux fois et demie plus large que haute. Sur une zone carrée, le champ serait rond.
Le cycle de vie est le point faible. mouseenter lance la boucle une seule fois (drapeau o) ; mouseleave se contente d'envoyer le pointeur à −1000, ce qui coupe la poussée et laisse les particules rentrer — mais rien n'arrête jamais la boucle. Mesuré : une position forcée à la main est réécrite 120 ms après la sortie du pointeur, et le sera encore une heure plus tard. Chaque image coûte 60 écritures de left/top (donc une mise en page, malgré le will-change: transform déclaré sur des éléments dont le transform ne change jamais) et un appel getBoundingClientRect() dont le résultat n'est pas utilisé.
Accessibilité
- prefers-reduced-motion absent du code : soixante points s'écartent du pointeur et une boucle tourne en permanence, quelle que soit la préférence. Aucune règle CSS ne peut l'arrêter (positions écrites en ligne,
transition: none) : conditionnez l'initialisation entière à!matchMedia('(prefers-reduced-motion: reduce)').matches, ou créez les particules sans lancer la boucle — un champ d'étoiles immobile est un repli honnête. - L'effet est exclusivement souris : sans
mousemove, les particules restent à leur origine. Le pointeur natif reste visible (cursor: crosshair, pascursor: none) et la zone est undivnon focalisable : gardez-la décorative, aucune information ne doit dépendre du champ. - Contraste : les particules sont des éléments graphiques décoratifs (seuil 3:1). Sur le dégradé
#12122e → #0a0a1a, la teinte la moins visible (autour de 260°) mesure 4,0:1 et la plus visible 6,4:1 — toutes passent. Sur une page claire, non : 2,9:1 au minimum sur blanc ; baissez la luminosité de65%à55%dans la chaînehsl(). Le code ne contient aucun texte. - Lecteurs d'écran : 60
divvides, muets, créés une fois au chargement — l'arbre d'accessibilité n'est ensuite plus touché, seuls les styles changent. Posez tout de mêmearia-hidden="true"sur la zone[data-gravity-well]. - Intégration : la zone doit garder
position: relative(repère des pourcentages),overflow: hidden(les particules poussées vers un bord en sortent) et une hauteur explicite —300pxdans le code vendu. Comme les positions sont en pourcentage, le champ suit les proportions de la zone (ellipse sur une zone large). Prévoyez l'arrêt de la boucle : dansmouseleave, après 2 s, ou dès que toutes les particules sont à moins de 0,1 % de leur origine, mettez le drapeau à faux et sortez ded().
Compatibilité navigateur
Code ES5 (var, function) : il requiert requestAnimationFrame, getBoundingClientRect, les couleurs hsl()/hsla() et box-shadow. Zéro dépendance.
Sans JavaScript, la zone est un simple dégradé radial : aucune particule, puisque c'est le script qui les crée. Sans requestAnimationFrame (IE 9), les 60 points sont créés à leur origine et n'en bougent plus.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="gravity-zone" data-gravity-well></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 |
|---|---|---|
Nombre de particules (JS — h < 60) |
60 | Points créés au chargement, chacun avec sa boucle de mise à jour. Le coût est linéaire : 60 écritures de position par image. Au-delà de 150, passez les positions en transform: translate() pour éviter la mise en page. |
Rayon du champ (JS — h < 30 et 1 − h / 30) |
30 unités (30 % de la largeur × 30 % de la hauteur) | Portée de la répulsion, en pourcentage de chaque dimension : une ellipse sur une zone large. Changez les deux occurrences ensemble. 15 = trou étroit sous le pointeur ; 50 = presque toute la zone réagit. |
Force de poussée (JS — 3 × (1 − h / 30)) |
3 % de la zone par image au contact | Déplacement maximal par image, dégressif jusqu'au bord du champ. 1 : les points s'écartent doucement et le rappel les retient près de l'origine ; 6 : ils sont projetés loin puis reviennent lentement. |
Rappel vers l'origine (JS — .03 × (homeX − x)) |
.03 par image | Fraction de l'écart parcourue à chaque image : 95 % du retour en ~100 images (1,6 s). .1 = retour en 0,5 s, champ plus « rigide » ; .01 = points qui flottent plusieurs secondes. |
Tailles (JS — 3 + 5 × Math.random()) |
3 à 8 px | Diamètre des points ; le halo suit (0 0 2·taille). Des points de 1 à 3 px donnent un ciel étoilé ; de 6 à 14 px, des bulles. |
Couleurs (JS — 220 + 80 × Math.random(), hsl(h, 70%, 65%)) |
bleu → magenta, 70 % / 65 % | Plage de teinte et saturation/luminosité fixes. hsl(h, 70%, 55%) pour tenir 3:1 sur un fond clair ; une teinte constante pour un champ monochrome ; le halo reprend la même teinte à 40 % d'opacité. |
| Arrêt de la boucle (JS — à ajouter) | jamais (boucle permanente) | Dans d(), sortez sans redemander d'image quand le pointeur est parti et que chaque particule est à moins de 0,1 unité de son origine, et remettez o à faux pour que le prochain mouseenter relance. Le champ devient gratuit au repos. |
FAQ
Math.atan2(dy, dx) + Math.PI : l'angle vers le pointeur, retourné de 180°. Mesuré pointeur immobile au centre, 24 particules bougent, la plus éloignée de 61 px, et toutes finissent plus loin du pointeur qu'au départ. La description du catalogue a été mise en accord avec le code (« repoussées ») ; le nom, lui, hérite de l'intention initiale. Pour obtenir une vraie attraction, retirez le + Math.PI — mais sans amortissement, les points se ruent sur le pointeur, le traversent et oscillent autour : ajoutez une distance minimale (h > 3) ou réduisez la poussée à 1 pour qu'ils s'agglutinent sans trembler.(clientX − left) / width × 100 et (clientY − top) / height × 100, les particules vivent en left/top en %, et le rayon de 30 s'applique à ces unités : 30 % de 744 px horizontalement (223 px), 30 % de 300 px verticalement (90 px). Le champ est donc une ellipse aux proportions de la zone. C'est ce qui rend le code indépendant de la taille — mais si vous voulez un champ rond, convertissez en pixels avant de mesurer la distance, ou donnez à la zone un format carré.mouseenter lance d() une fois et met le drapeau o à vrai ; mouseleave ne fait que déplacer le pointeur virtuel à −1000, ce qui coupe la poussée. La boucle continue de rappeler des particules déjà à l'origine et de réécrire 60 positions par image — mesuré : une position forcée est réécrite 120 ms après la sortie, et le serait encore des heures plus tard. Sur une page qui reste ouverte, c'est un travail permanent et inutile. Le correctif tient en trois lignes : dans d(), si le pointeur est à −1000 et que chaque particule est à moins de 0,1 unité de son origine, positionnez-la exactement, mettez o = false et ne redemandez pas d'image ; le prochain mouseenter relancera la boucle.