Déplacez la souris sur la carte
Holo Tilt & Glare
Une carte de 250 × 156 px inclinée jusqu'à ±11° / ±9° par le pointeur, un glare conique irisé centré sous le curseur et une couche de micro-stries qui glisse à l'inverse — six variables CSS écrites à chaque frame par une boucle rAF lissée (12 % du chemin par image), qui s'arrête au repos. Sans JS, la carte est statique.
Cet effet est gratuit — la catégorie Cartes en contient 41 au total, dont 6 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →
Exemples d'usage
Déplacez la souris sur la carte
Déplacez la souris sur la carte
Comment ça marche
Trois calques dans .htg-card (250 × 156 px, border-radius: 14px; overflow: hidden, ombre 0 18px 40px rgba(0,0,0,.5)), elle-même dans .htg-wrap qui porte perspective: 900px. Le fond de la carte est un radial-gradient blanc à 16 % centré sur (--htg-mx, --htg-my) posé sur un conic-gradient à cinq arrêts (from 230deg at 40% 30% : #1c1440 → #232e63 → #14324d → #3a1c55 → #1c1440). Son transform est rotateX(var(--htg-ry)) rotateY(var(--htg-rx)) — les noms sont croisés (rx alimente rotateY), le rendu est juste, la lecture du code moins.
Le glare : .htg-glare (inset: 0) est un conic-gradient(from 0deg at var(--htg-mx) var(--htg-my), …) à six arrêts — cyan .5, rose .45, jaune .4, violet .45, vert .4, cyan .5 — centré sous le pointeur, en mix-blend-mode: screen (il ne peut qu'éclaircir), opacité .62, filter: blur(6px). La foil : .htg-foil déborde de 30 % (inset: -30%) et répète un repeating-linear-gradient(104deg) de période 7 px (1 px blanc à 9 %, 2 px vides, 1 px blanc à 4,3 %, 3 px vides) en color-dodge ; elle est translatée de (--htg-fx, --htg-fy) = −(mx − 50) × 0,5 px, −(my − 50) × 0,4 px, soit jusqu'à ±25 / ±20 px à l'opposé du pointeur — la parallaxe inverse qui fait lire les stries comme une couche sous la surface.
Le script, par .htg-wrap : pointermove normalise la position du pointeur dans le rectangle de la carte (px, py de 0 à 1) et fixe des cibles — tx = (px − .5) × 22 → rotateY ±11°, ty = −(py − .5) × 18 → rotateX ±9°, tmx, tmy = px, py × 100 pour le centre du glare. Une boucle requestAnimationFrame avance chaque valeur de 12 % du chemin restant par image (95 % en ≈ 23 images, ≈ 390 ms à 60 Hz ; mesuré 325 ms pour revenir au repos), écrit les six variables (toFixed) et s'arrête dès que les rotations sont à moins de .05° et mx à moins de .2 % de leur cible — au repos, rien ne tourne. pointerleave renvoie les cibles à 0°, 0°, 70 %, 25 % : les valeurs par défaut du CSS, la carte se repose exactement là où elle était.
Deux choses que le code veut faire et ne fait pas. .htg-content porte transform: translateZ(24px) et la carte transform-style: preserve-3d pour détacher le texte de la surface — mais overflow: hidden sur la même carte force la valeur utilisée à flat : le contenu n'est pas soulevé. Mesuré dans Chromium, carte inclinée : le rectangle du nom est identique avec et sans translateZ ; il se décale de 6 px dès que l'on passe overflow: visible. Et le test d'arrêt de la boucle ignore --htg-my : l'amortissement étant uniforme, la valeur se pose à ~0,3 % près, sans conséquence visible. Sous prefers-reduced-motion: reduce, le script sort avant d'attacher le moindre écouteur : carte fixe, glare à 70 % / 25 %.
Accessibilité
- prefers-reduced-motion géré dans le JS : aucun écouteur n'est posé, la carte reste dans son état de repos avec sa matière holographique (glare fixe à 70 % / 25 %) — le repli est visuellement complet.
.htg-wrapportetabindex="0"et unaria-labelmais aucunrole: c'est un arrêt de tabulation qui ne réagit à aucune touche (le script n'écoute que le pointeur), et unaria-labelsur unedivsans rôle est ignoré par plusieurs lecteurs d'écran. Soit retireztabindex(élément décoratif), soit ajoutezrole="img"avec un libellé qui décrit la carte (« Carte membre, niveau Or, A. Dubois ») — et traduisez-le, le libellé vendu est en français.- Contraste mesuré (Chromium, texte rendu transparent, pixels sous le texte, glare compris, trois positions de pointeur) : nom
#fff16,8 px gras ≥ 4,7:1 partout — texte large, seuil 3:1, conforme ; numérorgba(255,255,255,.75)11,2 px : 3,4 à 4,1:1 au minimum, sous 4,5:1 ; badge#7dffc49,3 px gras : 3,3 à 4,1:1 au minimum (médiane 4,1 à 5,2) dans le passage cyan-vert du glare, sous 4,5:1. Correction calculée sur le pixel le plus clair mesuré : un dégradé sombre sous le contenu,.htg-content { background: linear-gradient(transparent 40%, rgba(10,8,30,.55)) }→ numéro 7,2:1 (11,3:1 en#fff) ; et.htg-badge { background: rgba(10,8,30,.6) }→ badge 7,2 à 9,7:1. - Tailles : badge
.58rem= 9,3 px, numéro.7rem= 11,2 px — sous toute taille de lecture confortable ; l'ombretext-shadow: 0 1px 3px rgba(0,0,0,.65)ne compte pas pour WCAG (le minimum mesuré ne change pas avec elle). Passez le badge à 11 px au moins. - Tactile : les Pointer Events couvrent le doigt — glisser sur la carte l'incline, le défilement n'est pas bloqué,
pointerleavese déclenche au relâchement et la carte revient.will-change: transformsur un élément de 250 × 156 px est peu coûteux. Aucun contenu n'est généré : le nom accessible dépend du seul libellé.
Compatibilité navigateur
Requiert conic-gradient(), mix-blend-mode (screen, color-dodge), les transformations 3D avec perspective, les propriétés personnalisées écrites par setProperty et les Pointer Events. Aucune bibliothèque.
Sans JavaScript, la carte est statique : glare à 70 % / 25 %, foil non décalée — la matière holographique reste, seul le mouvement manque. Sans conic-gradient (navigateurs d'avant 2019-2020), la déclaration background entière est invalide et la carte perd son fond : ajoutez background-color: #1c1440 en repli.
Le code
Copiez les trois blocs dans votre page. Aucune dépendance.
<div class="htg-wrap" tabindex="0" aria-label="Carte holographique pilotée au pointeur">
<div class="htg-card">
<span class="htg-glare" aria-hidden="true">
</span>
<span class="htg-foil" aria-hidden="true">
</span>
<span class="htg-content">
<span class="htg-badge">GOLD MEMBER</span>
<strong class="htg-name">A. Dubois</strong>
<span class="htg-num">MBR‑2026‑0042</span>
</span>
</div>
</div>
.htg-wrap {
perspective: 900px;
display: inline-block;
border-radius: 16px;
}
.htg-wrap:focus-visible {
outline: rgb(168, 85, 247) solid 2px;
outline-offset: 6px;
}
.htg-card {
position: relative;
width: 250px;
height: 156px;
border-radius: 14px;
overflow: hidden;
background: radial-gradient(120% 160% at var(--htg-mx, 70%) var(--htg-my, 20%), rgba(255,255,255,.16), transparent 55%),
conic-gradient(from 230deg at 40% 30%, #1c1440, #232e63, #14324d, #3a1c55, #1c1440);
transform: rotateX(var(--htg-ry, 0deg)) rotateY(var(--htg-rx, 0deg));
box-shadow: rgba(0, 0, 0, 0.5) 0px 18px 40px;
transform-style: preserve-3d;
will-change: transform;
}
.htg-glare {
position: absolute;
inset: 0px;
pointer-events: none;
background: conic-gradient(from 0deg at var(--htg-mx, 70%) var(--htg-my, 25%),
rgba(98,230,255,.5), rgba(255,138,225,.45), rgba(255,233,92,.4), rgba(177,140,255,.45), rgba(125,255,196,.4), rgba(98,230,255,.5));
mix-blend-mode: screen;
opacity: 0.62;
filter: blur(6px);
}
.htg-foil {
position: absolute;
inset: -30%;
pointer-events: none;
background: repeating-linear-gradient(104deg, rgba(255, 255, 255, 0.09) 0px, rgba(255, 255, 255, 0.09) 1px, transparent 1px, transparent 3px, rgba(255, 255, 255, 0.043) 3px, rgba(255, 255, 255, 0.043) 4px, transparent 4px, transparent 7px);
mix-blend-mode: color-dodge;
transform: translate(var(--htg-fx, 0px), var(--htg-fy, 0px));
}
.htg-content {
position: absolute;
inset: 0px;
padding: 16px 18px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 3px;
transform: translateZ(24px);
text-shadow: rgba(0, 0, 0, 0.65) 0px 1px 3px;
}
.htg-badge {
align-self: flex-start;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.16em;
color: rgb(125, 255, 196);
border: 1px solid rgba(125, 255, 196, 0.45);
border-radius: 20px;
padding: 3px 9px;
margin-bottom: auto;
}
.htg-name {
font-size: 1.05rem;
color: rgb(255, 255, 255);
}
.htg-num {
font-size: 0.7rem;
letter-spacing: 0.18em;
color: rgba(255, 255, 255, 0.75);
}
(function() {
/* fx-0821 — Holo Tilt & Glare : tilt + glare + foil pilotés au pointeur (rAF + lerp) */
function initHtg() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('.htg-wrap').forEach(function(wrap) {
var card = wrap.querySelector('.htg-card');
if (!card) return;
var tx = 0, ty = 0, tmx = 70, tmy = 25, x = 0, y = 0, mx = 70, my = 25, raf = null;
function loop() {
x += (tx - x) * 0.12; y += (ty - y) * 0.12; mx += (tmx - mx) * 0.12; my += (tmy - my) * 0.12;
card.style.setProperty('--htg-rx', x.toFixed(2) + 'deg');
card.style.setProperty('--htg-ry', y.toFixed(2) + 'deg');
card.style.setProperty('--htg-mx', mx.toFixed(1) + '%');
card.style.setProperty('--htg-my', my.toFixed(1) + '%');
card.style.setProperty('--htg-fx', (-(mx - 50) * 0.5).toFixed(1) + 'px');
card.style.setProperty('--htg-fy', (-(my - 50) * 0.4).toFixed(1) + 'px');
if (Math.abs(tx - x) > 0.05 || Math.abs(ty - y) > 0.05 || Math.abs(tmx - mx) > 0.2) raf = requestAnimationFrame(loop);
else raf = null;
}
function kick() { if (!raf) raf = requestAnimationFrame(loop); }
wrap.addEventListener('pointermove', function(e) {
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
tx = (px - 0.5) * 22; ty = -(py - 0.5) * 18; tmx = px * 100; tmy = py * 100; kick();
});
wrap.addEventListener('pointerleave', function() { tx = 0; ty = 0; tmx = 70; tmy = 25; kick(); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initHtg);
else initHtg();
})();
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Amplitude (JS — tx = (px − .5) * 22, ty = −(py − .5) * 18) |
±11° / ±9° | Degrés d'inclinaison horizontale et verticale. 30 / 24 = carte très mobile ; 12 / 10 = discret. Au-delà de 30°, les bords révèlent l'absence d'épaisseur. |
Lissage (JS — * 0.12, quatre occurrences) |
0,12 | Fraction du chemin restant parcourue par image. .25 = réactif (~180 ms) ; .06 = lourd (~800 ms). La valeur dépend du taux de rafraîchissement : à 120 Hz, deux fois plus vite. |
| Glare (CSS — .htg-glare : six arrêts, opacity .62, blur 6px) | cyan, rose, jaune, violet, vert | Les couleurs du reflet en mode screen. Opacité .4 pour une carte sobre ; supprimez le blur pour des rayons nets ; deux couleurs suffisent pour un reflet bicolore. |
| Foil (CSS — .htg-foil repeating-linear-gradient 104deg, période 7 px) | 1 px blanc 9 % / 1 px blanc 4,3 % | Les micro-stries. Espacez (transparent 1px 5px) pour un grain plus visible ; changez l'angle (60deg) pour croiser la lumière du glare. |
Parallaxe inverse (JS — −(mx − 50) * 0.5, −(my − 50) * 0.4) |
±25 / ±20 px | Course de la foil. Le signe négatif fait la profondeur ; en positif la couche paraît collée dessus. Restez sous la marge de 30 % (inset −30 %), sinon la foil découvre les bords. |
| Repos (JS — pointerleave 70 / 25 ; CSS — var(--htg-mx, 70%) var(--htg-my, 25%)) | 70 % / 25 % | Position du reflet au repos. Les valeurs JS et les défauts CSS doivent rester identiques, sinon la carte saute au premier survol puis au retour. |
| Fond (CSS — .htg-card conic-gradient from 230deg) | #1c1440 → #232e63 → #14324d → #3a1c55 | La matière de base. Gardez des tons sombres : le glare éclaircit (screen) et le texte blanc a besoin de fond. Ajoutez background-color en repli pour les navigateurs sans conic-gradient. |
FAQ
Le texte n'a aucun relief quand la carte s'incline — le translateZ(24px) ne sert à rien ?
Dans le code livré, non. overflow: hidden sur .htg-card aplatit le contexte 3D (transform-style utilisé = flat), et un translateZ dans un contexte aplati n'a aucun effet visible — mesuré : le nom ne bouge pas d'un pixel. Pour retrouver le relief, déplacez le rognage : enveloppez .htg-glare et .htg-foil dans un span à inset: 0; border-radius: 14px; overflow: hidden, et retirez overflow: hidden de la carte. Le texte se détache alors de 24 px et se déplace légèrement à contresens de la rotation.
Plusieurs cartes sur la même page ?
Oui : le script fait querySelectorAll('.htg-wrap') et donne à chaque carte son propre état et sa propre boucle, qui ne tourne que pendant qu'elle bouge. Coût par carte en mouvement : six setProperty par image et un getBoundingClientRect par pointermove. Aucun id, aucune variable globale.
Comment garder l'inclinaison sans le reflet irisé — ou l'inverse ?
Sans glare : supprimez le span.htg-glare (le script continue d'écrire --htg-mx/my, qui ne servent plus qu'au halo blanc du fond). Sans inclinaison : mettez tx = 0; ty = 0 dans pointermove ou remplacez la ligne transform de la carte par none — le reflet et la foil continuent de suivre le pointeur, la carte reste de face.