Pubblicato il 29 settembre 2026
Effetti al passaggio del mouse con CSS e JavaScript: spotlight, card 3D e riflessi
- css
- javascript
- sviluppo-web
Nel primo articolo di questa serie ho ricostruito in CSS puro alcuni sfondi resi popolari da librerie come Aceternity UI. Qui faccio lo stesso con gli effetti che reagiscono al mouse: una luce che segue il cursore dentro una card, una card che si inclina in 3D verso il puntatore, un riflesso che scorre sulla superficie.
Nella libreria originale questi effetti sono componenti React con dipendenze esterne. Secondo le istruzioni di installazione, 3D Card Effect richiede motion, clsx e tailwind-merge, mentre Card Spotlight aggiunge anche three e @react-three/fiber. Le versioni qui sotto usano solo CSS e poche righe di JavaScript standard, e condividono lo stesso schema, che conviene capire una volta sola.
Lo schema comune: JavaScript misura, il CSS disegna
Tutti e tre gli effetti funzionano così:
- un listener
pointermovericeve la posizione del puntatore (clientX,clientY), relativa alla finestra; getBoundingClientRect()dà la posizione della card, e la differenza tra i due valori è la posizione del puntatore dentro la card;- il valore viene scritto in una variabile CSS con
style.setProperty("--x", ...); - il CSS usa quella variabile per posizionare un gradiente o calcolare una rotazione.
Il vantaggio di questa divisione è che JavaScript non tocca mai stili "visivi" direttamente: passa solo numeri. Tutto l'aspetto dell'effetto (colori, raggio, transizioni, stato con il focus) resta nel CSS, dove è facile modificarlo e dove si può disattivare con una media query.
Uso i Pointer Events invece dei vecchi mousemove, perché gestiscono con un solo modello mouse, penna e touch.
Un aggiornamento per frame, non uno per evento
La frequenza con cui arriva pointermove dipende dal browser e dal dispositivo, e aggiornare le variabili più spesso di quanto lo schermo si aggiorni sarebbe lavoro sprecato. Per questo il codice raccoglie gli aggiornamenti con requestAnimationFrame: a ogni evento cancella il frame programmato in precedenza e ne programma uno nuovo, così il lavoro vero avviene al massimo una volta per frame, con l'ultima posizione disponibile.
Un chiarimento sulle metriche, per evitare un equivoco diffuso: INP, la metrica dei Core Web Vitals sulla reattività, osserva solo clic, tap e pressioni di tasti. La pagina di web.dev specifica che il passaggio del mouse (hovering) e lo scroll "non sono osservati ai fini dell'INP". Un handler di pointermove pesante, però, occupa il thread principale: se in quel momento arriva un clic, la risposta si fa attendere. Tenere l'handler leggero serve comunque.
Spotlight: una luce che segue il cursore
Lo spotlight è il caso più semplice: uno pseudo-elemento ::before copre la card e contiene un radial-gradient il cui centro è var(--x) var(--y). Quando il puntatore si muove, il centro della luce lo segue.
Il gradiente è invisibile di default (opacity: 0) e compare con :hover. Compare anche con :focus-visible: la card ha tabindex="0", quindi chi naviga da tastiera, arrivandoci con il tasto Tab, vede la luce accesa al centro (le variabili hanno 50% come valore di riserva). L'effetto non è riservato a chi usa il mouse.
Demo
Spotlight che segue il puntatore
Codice
index.html
<div class="spotlight" tabindex="0">…</div>styles.css
.spotlight {
position: relative;
isolation: isolate;
overflow: hidden;
}
.spotlight::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(220px circle at var(--x, 50%) var(--y, 50%), rgb(16 185 129 / 0.3), transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.spotlight:hover::before,
.spotlight:focus-visible::before {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.spotlight::before { transition: none; }
}script.js
const card = document.querySelector(".spotlight");
let frame = 0;
card.addEventListener("pointermove", (e) => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const rect = card.getBoundingClientRect();
card.style.setProperty("--x", `${e.clientX - rect.left}px`);
card.style.setProperty("--y", `${e.clientY - rect.top}px`);
});
});Nota: la variabile viene aggiornata anche quando la luce è invisibile, ma costa poco. Quello che il browser deve rifare a ogni movimento è ridisegnare il gradiente in una nuova posizione, un lavoro limitato alla superficie della card.
Card 3D: rotateX, rotateY e perspective
La card che si inclina verso il puntatore sembra complessa, ma è tutta nella formula. Prendendo la posizione del puntatore normalizzata tra 0 e 1 sia in orizzontale (x) sia in verticale (y):
- la rotazione attorno all'asse verticale è
(x - 0.5) × 2 × MAX: a sinistra vale-MAX, al centro 0, a destra+MAX; - la rotazione attorno all'asse orizzontale è
(0.5 - y) × 2 × MAX, con il segno invertito perché la parte della card sotto il puntatore deve "abbassarsi".
Perché la rotazione sembri tridimensionale serve perspective sul contenitore: definisce la distanza tra l'osservatore e il piano, e quindi quanto è marcato l'effetto prospettico. Valori piccoli danno una deformazione forte, valori grandi una più sottile. Nella demo uso 800px.
Demo
Card 3D che si inclina verso il puntatore
Codice
index.html
<div class="tilt-wrap">
<div class="tilt" tabindex="0">…</div>
</div>styles.css
.tilt-wrap {
perspective: 800px;
}
.tilt {
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 0.15s ease-out;
will-change: transform;
}script.js
const card = document.querySelector(".tilt");
const MAX = 12;
const enabled = matchMedia(
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"
).matches;
let frame = 0;
if (enabled) {
card.addEventListener("pointermove", (e) => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const r = card.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
card.style.setProperty("--rx", `${(0.5 - y) * 2 * MAX}deg`);
card.style.setProperty("--ry", `${(x - 0.5) * 2 * MAX}deg`);
});
});
card.addEventListener("pointerleave", () => {
cancelAnimationFrame(frame);
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
});
}L'effetto usa solo transform, cioè una delle due proprietà che la guida di web.dev sulle animazioni ad alte prestazioni consiglia di animare. Una transition breve (0.15s) ammorbidisce i salti tra una posizione e l'altra; su pointerleave le variabili tornano a 0deg e la card si raddrizza con la stessa transizione.
Riflesso: una fascia di luce spostata con translate
Il riflesso ("glare") è una fascia chiara diagonale che scorre sulla card seguendo il puntatore, come la luce su una superficie lucida. La tentazione è spostarla con background-position, ma per le stesse ragioni viste nell'articolo sugli sfondi conviene muovere l'intero livello con transform.
Il trucco è rendere lo pseudo-elemento tre volte più largo della card (inset: 0 -100%), con la fascia chiara al centro del gradiente. Con translateX(0) la fascia sta al centro della card; spostando il livello di x - larghezza / 2 pixel, il centro della fascia coincide con la x del puntatore. La larghezza extra garantisce che il bordo del livello non entri mai nella card.
Perché sembri un riflesso vero e non una "barra" bianca, la fascia non ha bordi netti: il gradiente sale gradualmente di intensità dal 25% al 50% della sua larghezza e scende in modo simmetrico fino al 75%, con diversi stop intermedi (8%, 30%, 70% e 100% dell'intensità scelta). Più stop ci sono, più la sfumatura è morbida.
Demo
Riflesso che segue il puntatore
Codice
index.html
<div class="glare" tabindex="0">…</div>styles.css
.glare {
position: relative;
overflow: hidden;
}
.glare::after {
content: "";
position: absolute;
inset: 0 -100%;
background: linear-gradient(
105deg,
transparent 25%,
rgb(255 255 255 / 0.02) 33%,
rgb(255 255 255 / 0.075) 41%,
rgb(255 255 255 / 0.175) 47%,
rgb(255 255 255 / 0.25) 50%,
rgb(255 255 255 / 0.175) 53%,
rgb(255 255 255 / 0.075) 59%,
rgb(255 255 255 / 0.02) 67%,
transparent 75%
);
transform: translateX(var(--gx, 0px));
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.glare:hover::after,
.glare:focus-visible::after {
opacity: 1;
}script.js
const card = document.querySelector(".glare");
let frame = 0;
card.addEventListener("pointermove", (e) => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const r = card.getBoundingClientRect();
card.style.setProperty("--gx", `${e.clientX - r.left - r.width / 2}px`);
});
});Riflesso e tilt si combinano bene nella stessa card: basta usare un unico listener che scrive tutte le variabili (--rx, --ry, --gx) nello stesso frame.
Touch, tastiera e reduced motion
Un effetto legato al mouse va pensato anche per chi il mouse non lo usa.
Touch. La media feature hover indica se il dispositivo di input principale può passare "sopra" gli elementi: hover: none identifica, secondo MDN, i dispositivi che non possono farlo o non possono farlo comodamente, come molti smartphone. Nel codice del tilt la condizione (hover: hover) and (pointer: fine) attiva l'effetto solo con un puntatore preciso. Su touch la card resta ferma, e non è una perdita: è decorazione, il contenuto è lo stesso.
Tastiera. Spotlight e riflesso compaiono anche con :focus-visible, come visto sopra. Il tilt non ha una versione da tastiera, e va bene così: una card inclinata non comunica nessuna informazione in più.
Reduced motion. Il criterio WCAG 2.2 2.3.3 Animation from Interactions (livello AAA) chiede che le animazioni di movimento innescate da un'interazione si possano disattivare, a meno che non siano essenziali. La sua definizione di "motion animation" esclude i cambiamenti di colore, sfocatura e opacità che non alterano dimensione, forma o posizione percepite: una luce che si accende rientra nell'esclusione, una card che ruota no. Per questo nelle demo, con prefers-reduced-motion: reduce attivo, il tilt non viene proprio agganciato (la condizione è nella stessa media query del JavaScript) e le transizioni di luce e riflesso vengono rimosse.
Anche se il livello AAA non è sempre un obiettivo di progetto, qui rispettarlo costa una riga di media query.
Riepilogo
- Spotlight:
radial-gradientcentrato suvar(--x) var(--y), visibile con:hovere:focus-visible. - Card 3D:
rotateXerotateYcalcolati dalla posizione del puntatore,perspectivesul contenitore. Attivo solo con mouse e senza reduced motion. - Riflesso: fascia su un livello largo il triplo della card, spostata con
translateX. - In comune:
pointermove→getBoundingClientRect→ variabile CSS, con un solo aggiornamento per frame viarequestAnimationFrame.
Nel terzo e ultimo articolo della serie il motore dell'animazione diventa lo scroll: reveal, parallasse e marquee infinito, con le nuove scroll-driven animations del CSS e un fallback per i browser che non le supportano ancora.