Cursor Tilt Effect
Le pointeur pilote rotateX/rotateY jusqu'à ±15° et un reflet radial via deux variables CSS, retour en 500 ms à la sortie. La chaîne transform écrite par le JS commence par perspective(600px) : la carte bascule vraiment, bord proche qui grandit et bord lointain qui rétrécit, sans rien exiger du parent.
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
Chaque [data-tilt-card] (200 × 140 px, dégradé 145deg #1e1e3a → #2a2a4e, border-radius: 16px) écoute mousemove. Le script mesure la carte (getBoundingClientRect), calcule la position du pointeur par rapport au centre et en tire deux angles : rotateX = (y − cy) / cy × −15 et rotateY = (x − cx) / cx × 15, soit ±15° aux bords (mesuré : 13,7° à 97 % de la largeur), 0° au centre. Il écrit transform: perspective(600px) rotateX(…) rotateY(…) scale(1.05) et box-shadow: 0 20px 40px rgba(99,102,241,.3) en ligne. Les signes sont choisis pour que le bord sous le pointeur recule, comme si on appuyait dessus.
Le reflet est un calque .tilt-shine en position: absolute; inset: 0, pointer-events: none, dont le fond est radial-gradient(circle at var(--shine-x, 50%) var(--shine-y, 50%), rgba(255,255,255,.12) 0%, transparent 60%). À chaque mouvement, le JS écrit --shine-x et --shine-y en pourcentage de la carte : la tache claire suit le pointeur. Au mouseenter, la transition est réglée sur box-shadow .3s ease seulement — la rotation suit donc le pointeur sans lissage ; au mouseleave, transform revient à perspective(600px) rotateX(0) rotateY(0) scale(1), l'ombre à none, et la transition passe à transform .5s cubic-bezier(.23, 1, .32, 1), box-shadow .5s ease : le retour prend 500 ms.
La perspective est portée par la chaîne transform elle-même : perspective(600px) en tête, à chaque écriture, y compris au retour. C'est ce qui donne le point de fuite — sans lui, une rotation 3D est projetée orthogonalement et la carte ne bascule pas, elle se comprime : mesuré à 13,7° sans perspective, sa boîte passe de 210 à 204 px de large (cos 13,7°) et garde 147 px de haut, un rétrécissement de 3 % sans trapèze ni profondeur. Avec une perspective de 600 px, la même carte mesure 154 px de haut au même angle : le bord proche grandit, le bord lointain rétrécit, la 3D apparaît. Porter la perspective dans le transform plutôt que sur le parent rend chaque carte autonome : son point de fuite est centré sur elle et ne dépend pas du conteneur où vous la posez.
Une promesse reste inerte : .tilt-icon et .tilt-label portent transform: translateZ(30px) et translateZ(20px), et la carte transform-style: preserve-3d, pour que le contenu flotte au-dessus de la face. Or la carte a aussi overflow: hidden, qui force transform-style: flat selon la spécification : les enfants sont aplatis dans le plan de la carte. Mesuré avec une perspective de 600 px : l'icône fait 20,2 px de large en overflow: hidden et 21,3 px en overflow: visible (× 1,053 = 600 / 570), preuve que le relief n'agit qu'une fois l'overflow retiré — mais c'est lui qui découpe le reflet aux coins arrondis. Le code choisit implicitement le reflet contre le relief.
Accessibilité
- prefers-reduced-motion absent du code : l'inclinaison, le zoom de 5 % et le retour de 500 ms jouent quelle que soit la préférence. Le plus sûr est de ne pas poser les écouteurs quand
matchMedia('(prefers-reduced-motion: reduce)').matches; à défaut,@media (prefers-reduced-motion: reduce) { .tilt-card { transform: none !important; transition: none !important } }neutralise les écritures en ligne du JS. - L'effet est exclusivement souris et la carte est un
divàcursor: pointerqui ne fait rien au clic : la main promise n'a pas d'action derrière. Si la carte mène quelque part, faites-en unaou unbuttonet donnez-lui un:focus-visible— au clavier, aucune inclinaison ne signalera le focus, ce qui est acceptable tant que le focus est visible autrement. - Contraste : le libellé
rgba(255,255,255,.7)en 13,6 px mesure 8,6:1 sur#1e1e3aet 7,5:1 sur#2a2a4e, 5,6:1 sous la tache du reflet (blanc à 12 %) — AA partout, AAA hors reflet. Le pointeur natif reste visible (aucuncursor: none). - Lecteurs d'écran :
.tilt-shineest undivvide sans rôle — inoffensif, mais ajoutezaria-hidden="true"pour que rien ne le liste. Les transformations n'altèrent pas l'arbre d'accessibilité : l'icône (un émoji, lu par son nom Unicode — donnez-luiaria-hiddenou unaria-label) et le libellé restent lus tels quels. - Intégration : la perspective est déjà en tête de la chaîne
transform(perspective(600px)), rien à ajouter sur le parent ;scale(1.05)fait déborder la carte de 5 px de chaque côté sur ses voisines — prévoyez une marge ou unz-indexau survol ;will-change: transformréserve un calque de composition par carte, à retirer au-delà d'une dizaine d'instances. La remise à zéro à la sortie est faite par le code (transform, ombre, transition).
Compatibilité navigateur
Code ES5 (var, function). Le CSS requiert les transformations 3D (rotateX/rotateY), les propriétés personnalisées (var()) pour le reflet et la propriété raccourcie inset, qui dimensionne le calque de reflet. Zéro dépendance.
Sans JavaScript, la carte est statique avec un reflet centré (les variables retombent sur 50 %). Sans inset (Safari avant 14.1, Firefox avant 66), le calque de reflet n'a ni taille ni position : plus de tache claire, l'inclinaison et l'ombre restent. Sans var() (navigateurs de 2016), le dégradé est invalide et le reflet disparaît de même.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="tilt-card" data-tilt-card>
<div class="tilt-shine"></div>
<div class="tilt-icon">⚖</div>
<div class="tilt-label">Tilt me</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 |
|---|---|---|
perspective (JS — perspective(600px) en tête du transform) |
600 px | Point de fuite de la bascule, écrit dans la chaîne transform au mousemove et au retour : modifiez la valeur aux deux endroits. Plus elle est petite, plus la 3D est forte : 400px = très marqué, 1200px = discret. Une perspective posée en plus sur le parent se cumulerait avec celle-ci. |
Amplitude (JS — × −15 et × 15) |
±15° aux bords | Angle maximal atteint quand le pointeur touche un bord. 8 pour une carte large ou du texte dense ; 25 pour un objet ludique. Les deux coefficients peuvent différer (moins de rotateX sur une carte haute). |
Zoom au survol (JS — scale(1.05)) |
1.05 | Agrandissement pendant le survol, 5 px de chaque côté sur 200 px. 1 supprime le débordement sur les voisines ; 1.08 accentue la mise en avant. |
Ombre au survol (JS — 0 20px 40px rgba(99,102,241,.3)) |
indigo, 40 px de flou, décalée de 20 px | Ombre écrite au mousemove, retirée au mouseleave (none). Une ombre neutre rgba(0,0,0,.45) convient à des visuels ; le décalage vertical suggère la hauteur de la carte. |
Retour (JS — transform 0.5s cubic-bezier(0.23,1,0.32,1)) |
.5s, courbe ease-out prononcée | Durée et courbe du retour à plat à la sortie. .25s = carte nerveuse ; .8s avec cubic-bezier(.34, 1.56, .64, 1) = léger rebond au repos. |
Reflet (CSS — rgba(255,255,255,.12) 0%, transparent 60%) |
blanc à 12 %, rayon 60 % | Intensité et étendue de la tache claire. .2 sur des visuels sombres ; transparent 40% pour un reflet plus ponctuel. Supprimez le calque .tilt-shine pour un tilt sec. |
Taille et face (CSS — .tilt-card 200 × 140, dégradé 145deg) |
200 × 140 px, #1e1e3a → #2a2a4e | La géométrie est libre : les angles sont calculés en proportion de la largeur et de la hauteur réelles. Sur une grande carte, baissez l'amplitude ; le dégradé peut céder la place à une image de fond, le reflet se pose dessus. |
FAQ
transform écrite par le script : 'perspective(600px) rotateX(' + …, au mousemove comme au retour du mouseleave — l'ordre compte, la perspective doit être la première fonction. Sans elle, le navigateur projetterait la rotation orthogonalement : bord proche et bord lointain garderaient la même taille, la carte se contenterait de se rétrécir (mesuré : 204 px de large au lieu de 210 à 13,7°, hauteur inchangée) ; avec, la même rotation produit un trapèze de 154 px de haut. La porter dans le transform plutôt qu'en perspective: 600px sur le parent rend la carte autonome — le point de fuite est centré sur elle et ne dépend pas de son conteneur — au prix d'une valeur à modifier à deux endroits dans le JS. Si vous préférez la propriété sur le parent, retirez la fonction des deux chaînes pour ne pas cumuler.overflow: hidden. La spécification impose qu'un overflow autre que visible force transform-style: flat : le preserve-3d déclaré est ignoré et les enfants sont aplatis dans le plan de la carte, translateZ compris. Mesuré avec une perspective de 600 px : l'icône mesure 20,2 px en overflow: hidden et 21,3 px en overflow: visible, soit le grossissement attendu de 600 / (600 − 30). Le dilemme : overflow: hidden est ce qui découpe le reflet aux coins arrondis. Pour avoir les deux, retirez l'overflow de la carte et donnez au calque .tilt-shine son propre border-radius: 16px (ou un clip-path: inset(0 round 16px)).querySelectorAll('[data-tilt-card]') : chaque carte a ses propres écouteurs et son propre reflet (card.querySelector('.tilt-shine'), facultatif). Pour une carte cliquable, remplacez le div par un a en display: flex — le code ne dépend pas de la balise. Deux précautions : scale(1.05) fait déborder la carte survolée sur ses voisines, donnez-lui position: relative; z-index: 1 au survol ou une marge de 6 px ; et will-change: transform crée un calque de composition par carte, à supprimer si la grille en compte des dizaines. Au tactile, aucun mousemove : la carte reste plate et cliquable.