Ardent
Identités, sites et films pour des marques qui osent.Déplacez la souris dans la scène
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.
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 →
Ardent
Identités, sites et films pour des marques qui osent.Déplacez la souris dans la scène
Déplacez la souris sur la grille
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
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.
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.#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.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.@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.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.Requiert ES2015 (const/let, fonctions fléchées), classList, requestAnimationFrame et les variables CSS lues par la zone. Zéro dépendance.
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.
Copiez les trois blocs dans votre page. Aucune dépendance.
<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>
.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
}
// 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();
})();
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
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. |
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).
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.
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.