CurseursGratuit

Dot Follower

Un point de 8 px collé au pointeur et un anneau de 40 px qui le rejoint en retard — 15 % de l'écart restant à chaque frame, ralenti encore par une transition CSS de 150 ms — tous deux en position: fixed et affichés seulement tant que la souris est dans la zone.

JSSmooth
Survolez cette zone Curseur point + anneau
Survolez ou cliquez la scène pour interagir

Cet effet est gratuit — la catégorie Curseurs en contient 26 au total, dont 1 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →

Exemples d'usage

Studio · Direction artistique

Ardent

Identités, sites et films pour des marques qui osent.
BrandingWebMotion

Déplacez la souris dans la scène

① Hero d'un studio de design — point et anneau comme signature

QuandPage d'accueil sombre d'un studio ou d'un freelance : titre, accroche, une ligne de services.
PourquoiUn curseur personnalisé est un marqueur d'identité à faible coût : 8 px de point et un anneau qui traîne suffisent à signaler « site soigné » dès le premier geste, sans rien ajouter au contenu.
RéglagesAnneau 48 px (offset −24), facteur 0.12 pour une traîne plus visible, transition retirée pour que seule la boucle lisse le mouvement.
Projets récents 6 sur 24
Nova — identité
Bloom — e-commerce
Tide — appli mobile
Solar — packaging
Atlas — rapport annuel
Ember — motion

Déplacez la souris sur la grille

② Grille de projets — le curseur signale une zone explorable

QuandGrille de vignettes d'un portfolio photo ou d'une agence : trois à six cartes, un titre par carte.
PourquoiL'anneau qui suit le pointeur invite à balayer la grille ; il reste neutre au-dessus des images, là où une flèche système paraît étrangère à l'univers visuel.
RéglagesPoint 6 px (offset −3), anneau 56 px à 1,5 px de bordure, couleur claire #f5f5f4, facteur 0.1.
Nouveau · version 3.0

Des tableaux de bord enfin lisibles

Connectez vos données, choisissez un modèle, partagez un lien. Sans SQL, sans diaporama.

Déplacez la souris dans la scène

③ Landing SaaS — curseur discret sur le bloc d'accroche

QuandHero d'un produit logiciel : badge, titre, sous-titre, deux boutons.
PourquoiIci le curseur ne doit pas voler la vedette au bouton : un anneau compact et rapide accompagne le geste sans le ralentir, et la zone est limitée au hero pour ne pas gêner la lecture plus bas.
RéglagesAnneau 32 px (offset −16), facteur 0.25 (rattrapage en une quinzaine de frames), règle .cursor-active { cursor: none } conservée pour une seule flèche à l'écran.

Comment ça marche

Deux éléments vides portent le curseur : #cursorDot (.cursor-dot, 8 × 8 px, #6366f1, border-radius: 50%) et #cursorRing (.cursor-ring, 40 × 40 px, border: 2px solid #6366f1). La classe commune .custom-cursor les sort du flux : position: fixed, pointer-events: none (ils n'interceptent jamais un clic), z-index: 9999 et display: none au repos. La zone #zone-dot (300 px de haut) reçoit trois écouteurs : mouseenter passe les deux éléments en display: block et ajoute la classe cursor-active, mouseleave fait l'inverse.

Le point est sans délai : à chaque mousemove, left = clientX − 4 et top = clientY − 4, soit le centre exactement sous le pointeur (écart mesuré : 0 px). L'anneau, lui, n'écoute pas la souris : une boucle requestAnimationFrame permanente calcule ringX += (dotX − ringX) × 0.15 puis écrit left = ringX − 20. Chaque frame comble 15 % de l'écart restant — une approche exponentielle : après un saut, il reste 85 % de la distance à la frame 1, 20 % à la frame 10, moins de 1 % au-delà de la frame 30 (0,85³¹ × 456 px ≈ 3 px).

Mais .cursor-ring déclare aussi transition: all .15s ease-out, qui s'applique à left et top : chaque écriture de la boucle relance une transition de 150 ms depuis la position en cours. Mesuré dans Chromium à 120 Hz après un saut de 456 px : 287 px d'écart à la frame 10, 55 px à la frame 30, moins de 3 px seulement à la frame 62 (≈ 0,5 s) — le double des 30 frames obtenues en retirant la transition. Le lissage est compté en frames, pas en millisecondes : sur un écran 60 Hz la part « boucle » prend deux fois plus de temps.

Deux détails d'initialisation comptent. ringX et ringY valent 0 au départ : à la première entrée, l'anneau part du coin haut-gauche de la fenêtre (mesuré : 23 px, 23 px à la frame 0 pour un pointeur en 152, 150 ; encore 17 px d'écart 30 frames plus tard). Et la boucle ne s'arrête jamais — 121 appels de requestAnimationFrame par seconde mesurés hors survol, curseur caché. Enfin, la règle .cursor-zone définit elle-même, scopées sur la zone, les cinq variables qu'elle lit (--bg-elevated: #1a1a24, --radius-xl: 1rem, --space-4: 1rem, --text-muted: #8b8b93, --text-secondary: #a1a1aa) : sur une page vierge, la zone se rend avec son fond, son arrondi et ses couleurs de texte, sans rien imposer au reste de la page.

Accessibilité

  • Le curseur natif est masqué pendant le survol : le JS ajoute la classe cursor-active à la zone et le CSS livré fournit .cursor-active { cursor: none } — le point et l'anneau sont alors le seul repère du pointeur. C'est un réglage : retirez cette règle pour garder la flèche système, ce qui est préférable si la zone contient des éléments cliquables de précision.
  • Contraste du point et de l'anneau (WCAG 1.4.11, composant d'interface, seuil 3:1) : #6366f1 sur #1a1a24 = 3,9:1, sur #0a0a0f = 4,4:1, sur blanc = 4,5:1 — conforme, mais un disque de 8 px reste petit. Si le curseur natif est masqué, ce point devient le seul repère : préférez #a5b4fc (8,7:1 sur #1a1a24) ou grossissez-le à 10-12 px.
  • Tactile : un tap déclenche les événements souris émulés. Mesuré avec émulation tactile : après un tap dans la zone, point et anneau s'affichent, figés au point touché, et la classe cursor-active reste posée même après un tap hors de la zone (aucun mouseleave). Conditionnez l'initialisation à matchMedia('(hover: hover) and (pointer: fine)').matches.
  • prefers-reduced-motion absent : l'anneau qui glisse est une animation déclenchée par l'interaction (WCAG 2.3.3). Sous @media (prefers-reduced-motion: reduce), masquez l'anneau (.cursor-ring { display: none !important }) ou passez le facteur 0.15 à 1 dans le JS pour un anneau collé sans traîne.
  • Lecteurs d'écran et clavier : les deux div sont vides et pointer-events: none, rien n'est annoncé — ajoutez tout de même aria-hidden="true" sur chacun. Aucun mousemove au clavier : les éléments interactifs de la zone gardent leur anneau de focus natif. Attention au z-index: 9999 : le curseur passe au-dessus de vos modales et menus.

Compatibilité navigateur

Requiert ES2015 (const/let, fonctions fléchées), classList, requestAnimationFrame et les variables CSS lues par la zone. Zéro dépendance.

Chrome 49+✓ Complet
Firefox 45+✓ Complet
Safari 10+✓ Complet
Edge 15+✓ Complet
Mobile iOS✓ Inactif (curseur figé au tap)
Android Chrome✓ Inactif (curseur figé au tap)

Sans JavaScript, les deux éléments restent en display: none : la zone est une simple boîte et le curseur natif fait son travail. Sans variables CSS (navigateurs antérieurs à 2016), la zone perd fond, arrondi et couleurs de texte mais l'effet lui-même fonctionne.

Le code

Copiez les trois blocs dans votre page. Aucune dépendance.

HTML
<div class="custom-cursor cursor-dot" id="cursorDot"></div>
<div class="custom-cursor cursor-ring" id="cursorRing"></div>
<div class="cursor-zone" id="zone-dot">
  Survolez cette zone
  <span class="info-text">Curseur point + anneau</span>
</div>
CSS
.custom-cursor {
  position: fixed;
  pointer-events: none;
  z-index: 9999;
  display: none
}

.cursor-dot {
  width: 8px;
  height: 8px;
  background: #6366f1;
  border-radius: 50%
}

.cursor-ring {
  width: 40px;
  height: 40px;
  border: 2px solid #6366f1;
  border-radius: 50%;
  transition: all .15s ease-out
}

.cursor-zone {
  /* Ces variables venaient du design system du SITE : chez l'acheteur
     elles n'existent pas, le fond disparaissait et les coins devenaient carres.
     Scopees ici, elles cascadent sans polluer sa page. */
  --bg-elevated: #1a1a24;
  --radius-xl: 1rem;
  --space-4: 1rem;
  --text-muted: #8b8b93;
  --text-secondary: #a1a1aa;

   width: 100%;
   height: 300px;
   background: var(--bg-elevated);
   border-radius: var(--radius-xl);
   display: flex;
   align-items: center;
   justify-content: center;
   font-weight: 600;
   color: var(--text-secondary);
   position: relative;
   overflow: hidden;
   }

.info-text  {
   position: absolute;
   bottom: var(--space-4);
   left: 50%;
   transform: translateX(-50%);
   font-size: 0.875rem;
   color: var(--text-muted);
   }

/* Le JS pose cette classe au survol d'une cible : sans regle, le curseur
   natif restait visible par-dessus le curseur personnalise. */
.cursor-active {
  cursor: none
}
JavaScript (fx-0197)
// Zone 1: Dot Follower
    (function(){
      const zoneDot = document.getElementById('zone-dot');
      if (!zoneDot) return;
      const cursorDot = document.getElementById('cursorDot');
      const cursorRing = document.getElementById('cursorRing');
      if (!cursorDot || !cursorRing) return;
      let ringX = 0, ringY = 0;
      let dotX = 0, dotY = 0;

      zoneDot.addEventListener('mouseenter', () => {
        zoneDot.classList.add('cursor-active');
        cursorDot.style.display = 'block';
        cursorRing.style.display = 'block';
      });
      zoneDot.addEventListener('mouseleave', () => {
        zoneDot.classList.remove('cursor-active');
        cursorDot.style.display = 'none';
        cursorRing.style.display = 'none';
      });
      zoneDot.addEventListener('mousemove', (e) => {
        dotX = e.clientX;
        dotY = e.clientY;
        cursorDot.style.left = dotX - 4 + 'px';
        cursorDot.style.top = dotY - 4 + 'px';
      });

      function animateRing() {
        ringX += (dotX - ringX) * 0.15;
        ringY += (dotY - ringY) * 0.15;
        cursorRing.style.left = ringX - 20 + 'px';
        cursorRing.style.top = ringY - 20 + 'px';
        requestAnimationFrame(animateRing);
      }
      animateRing();
    })();

Personnaliser

Options passées à l'API ou attributs data-* :

Option / propriétéDéfautEffet
Facteur de lissage (JS — (dotX - ringX) * 0.15) 0.15 Part de l'écart comblée à chaque frame. 0.3 = anneau vif (≤ 3 px en ~14 frames) ; 0.08 = traîne longue. Compté en frames : deux fois plus lent en millisecondes à 60 Hz qu'à 120 Hz.
Taille de l'anneau (CSS — .cursor-ring 40px) + offset (JS — ringX - 20) 40 px / −20 Les deux valeurs sont couplées : l'offset doit rester égal à la moitié de la taille pour que l'anneau soit centré. 56 px ⇒ ringX - 28.
Taille du point (CSS — .cursor-dot 8px) + offset (JS — dotX - 4) 8 px / −4 Même couplage. 12 px ⇒ dotX - 6. Au-delà de 12 px le point masque ce qu'il survole.
Couleur (CSS — .cursor-dot background, .cursor-ring border) #6366f1 Point et anneau sont indépendants : point plein blanc + anneau de la couleur d'accent est une combinaison lisible sur tout fond.
transition (CSS — .cursor-ring) all .15s ease-out Double le temps de rattrapage mesuré (62 frames au lieu de 30). Supprimez-la pour un anneau piloté uniquement par la boucle, ou limitez-la à width, height, border-color si vous ajoutez des états.
Position initiale (JS — let ringX = 0, ringY = 0) 0, 0 Cause du vol depuis le coin haut-gauche à la première entrée. Dans mouseenter, ajoutez ringX = e.clientX; ringY = e.clientY — l'anneau apparaît alors directement sous le pointeur.
z-index (CSS — .custom-cursor) 9999 Empilement du curseur. Abaissez-le sous celui de vos modales si le curseur ne doit pas les survoler, ou masquez-le à l'ouverture d'une boîte de dialogue.

FAQ

Pourquoi l'anneau arrive-t-il depuis le coin de la fenêtre la première fois ?

Parce que ringX et ringY sont initialisés à 0 et que la boucle tourne déjà : au premier mouseenter, l'anneau s'affiche là où la boucle l'a laissé, soit en (0, 0), puis rejoint le pointeur à 15 % par frame — mesuré à 17 px d'écart encore 30 frames après l'entrée. Le correctif tient en une ligne dans mouseenter : ringX = e.clientX; ringY = e.clientY;. C'est exactement ce que fait Cursor Expand (fx-0209).

Où placer les deux div du curseur dans ma page ?

Juste avant </body>, hors de tout conteneur. position: fixed avec left = clientX suppose que la fenêtre est le repère ; si un ancêtre porte transform, filter, perspective, will-change: transform ou contain: paint, il devient le bloc conteneur et le curseur se décale de la position de cet ancêtre — typiquement un point qui apparaît quelques centaines de pixels trop bas. Le code livré place les deux div juste avant la zone de démo, au même niveau : déplacez-les à la racine dès l'intégration.

Que coûte la boucle requestAnimationFrame qui ne s'arrête jamais ?

Mesuré : 121 appels par seconde à 120 Hz, curseur caché, avec deux écritures de left/top par frame — un recalcul de style et de position pour un élément hors flux, quasi gratuit isolément, mais inutile 99 % du temps et multiplié par le nombre d'instances. Lancez animateRing() dans mouseenter et stockez l'identifiant retourné par requestAnimationFrame pour l'annuler avec cancelAnimationFrame dans mouseleave. Pour aller plus loin, écrivez transform: translate3d(x, y, 0) au lieu de left/top : le déplacement reste sur le compositeur, sans relayout.