Cursore Personalizzato: Effetti Custom Cursor con CSS e JS
Perché il Cursore Personalizzato è ancora rilevante nel 2026
Nel 2026 il cursore personalizzato CSS JavaScript è diventato uno degli elementi distintivi dei siti portfolio di alto livello e dei prodotti SaaS premium. Non si tratta di un vezzo estetico: un custom cursor ben progettato comunica cura del dettaglio, aumenta il tempo di permanenza sulla pagina e — se implementato correttamente — non impatta affatto le performance. Il trucco sta tutto nel “come”.
Fino a qualche anno fa realizzare un cursore custom significava appoggiarsi a librerie pesanti o a soluzioni hacky con position: fixed e setTimeout. Oggi, grazie a CSS custom properties, requestAnimationFrame e all'API pointer-events, possiamo costruire esperienze fluide a 60fps senza dipendenze esterne. Se hai già lavorato con le animazioni interattive CSS e JS puro, molti concetti ti saranno familiari.
In questa guida vedremo: come nascondere il cursore nativo, creare un dot tracker animato, aggiungere stati hover reattivi, realizzare l'effetto “trailing” con lag, costruire un magnetic cursor per i bottoni e gestire l'accessibilità e le performance. Ogni sezione include codice pronto per la produzione.
Nascondere il Cursore Nativo e Strutturare il Markup
Il primo passo è nascondere il cursore di sistema senza perdere l'accessibilità. La proprietà CSS cursor: none applicata su body è il modo corretto: funziona in tutti i browser moderni e non interferisce con gli screen reader. Il nostro custom cursor vive nel DOM come due elementi separati — un dot piccolo e preciso, e un ring più grande con un leggero ritardo — che seguiranno il mouse tramite JavaScript.
<!-- Inserisci prima della chiusura di body -->
<div class="cursor-dot" aria-hidden="true"></div>
<div class="cursor-ring" aria-hidden="true"></div>L'attributo aria-hidden="true" è fondamentale: nasconde gli elementi decorativi agli screen reader. Il CSS di base imposta le proprietà geometriche e abilita la GPU tramite will-change: transform:
/* Nascondi cursore nativo su tutta la pagina */
* { cursor: none !important; }
/* Dot: il punto preciso sotto il mouse */
.cursor-dot {
position: fixed;
top: 0; left: 0;
width: 8px; height: 8px;
background: #06b6d4;
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
will-change: transform;
transition: width 0.2s ease, height 0.2s ease, background 0.2s ease;
}
/* Ring: il cerchio esterno con lag */
.cursor-ring {
position: fixed;
top: 0; left: 0;
width: 36px; height: 36px;
border: 1.5px solid rgba(6, 182, 212, 0.6);
border-radius: 50%;
pointer-events: none;
z-index: 9998;
transform: translate(-50%, -50%);
will-change: transform;
transition: width 0.3s ease, height 0.3s ease, border-color 0.3s ease;
}
/* Stato hover su elementi interattivi */
body.cursor-hover .cursor-dot {
width: 6px; height: 6px;
background: #ffffff;
}
body.cursor-hover .cursor-ring {
width: 54px; height: 54px;
border-color: rgba(6, 182, 212, 0.9);
background: rgba(6, 182, 212, 0.06);
}Nota l'uso di pointer-events: none: impedisce che gli elementi del cursore intercettino i click, lasciando funzionare normalmente tutti i link e i bottoni sottostanti. Questo è l'errore più comune che vedo nei portfolio — cursori custom che bloccano l'interazione.
Tracking con requestAnimationFrame e Lerp
Il JavaScript che muove il cursore è la parte più delicata. L'approccio sbagliato è aggiornare le coordinate direttamente nell'evento mousemove: genera troppi repaint e su schermi ad alta frequenza di aggiornamento produce jank. La soluzione corretta è separare la lettura dell'input dall'aggiornamento del DOM usando requestAnimationFrame e un'interpolazione lineare (lerp) per il ring:
const dot = document.querySelector('.cursor-dot');
const ring = document.querySelector('.cursor-ring');
// Posizione reale del mouse (aggiornata ad ogni mousemove)
let mouseX = 0, mouseY = 0;
// Posizione interpolata del ring (segue con lag)
let ringX = 0, ringY = 0;
// Fattore lerp: 0.12 = lento/morbido, 0.25 = reattivo
const LERP_FACTOR = 0.14;
// Funzione di interpolazione lineare
function lerp(start, end, factor) {
return start + (end - start) * factor;
}
// Aggiorna la posizione reale del mouse (non scrive nel DOM)
window.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
// Loop di rendering: legge dati e aggiorna DOM una volta per frame
function render() {
// Il dot segue il mouse in modo preciso
dot.style.transform = `translate(calc(-50% + ${mouseX}px), calc(-50% + ${mouseY}px))`;
// Il ring si avvicina con lerp (crea l'effetto trailing)
ringX = lerp(ringX, mouseX, LERP_FACTOR);
ringY = lerp(ringY, mouseY, LERP_FACTOR);
ring.style.transform = `translate(calc(-50% + ${ringX}px), calc(-50% + ${ringY}px))`;
requestAnimationFrame(render);
}
// Nascondi cursore quando esce dalla finestra
document.addEventListener('mouseleave', () => {
dot.style.opacity = '0';
ring.style.opacity = '0';
});
document.addEventListener('mouseenter', () => {
dot.style.opacity = '1';
ring.style.opacity = '1';
});
// Avvia il loop
requestAnimationFrame(render);
// Aggiungi/rimuovi classe hover su elementi interattivi
const interactives = document.querySelectorAll('a, button, [role="button"], input, textarea, select, label');
interactives.forEach(el => {
el.addEventListener('mouseenter', () => document.body.classList.add('cursor-hover'));
el.addEventListener('mouseleave', () => document.body.classList.remove('cursor-hover'));
});La funzione lerp è il cuore dell'effetto trailing: ad ogni frame, il ring si avvicina del 14% alla posizione reale del mouse. Questo crea quel caratteristico “inseguimento morbido” che si vede nei siti award-winning. Se vuoi qualcosa di simile per le animazioni scroll, dai un'occhiata alla guida sulle animazioni legate allo scroll con view-timeline.
Effetto Magnetic Cursor sui Bottoni
L'effetto “magnetic” fa sì che il cursore — e il bottone stesso — si attraggano reciprocamente quando il mouse entra nell'area di influenza. È uno degli effetti più richiesti nei portfolio e nei siti premium. La chiave è calcolare il delta tra la posizione del mouse e il centro del bottone, poi applicare una trasformazione proporzionale:
// Aggiungi data-magnetic a ogni bottone magnetico nel markup
// es: <button data-magnetic>Contattami</button>
document.querySelectorAll('[data-magnetic]').forEach(btn => {
const STRENGTH = 0.35; // intensità dell'effetto: 0 = nessun effetto, 1 = massimo
const RADIUS = 80; // area di influenza in pixel
btn.addEventListener('mousemove', (e) => {
const rect = btn.getBoundingClientRect();
const btnCX = rect.left + rect.width / 2;
const btnCY = rect.top + rect.height / 2;
const deltaX = e.clientX - btnCX;
const deltaY = e.clientY - btnCY;
const dist = Math.hypot(deltaX, deltaY);
if (dist < RADIUS) {
const moveX = deltaX * STRENGTH;
const moveY = deltaY * STRENGTH;
btn.style.transform = `translate(${moveX}px, ${moveY}px)`;
}
});
btn.addEventListener('mouseleave', () => {
// Riporta il bottone alla posizione originale con transizione CSS
btn.style.transform = 'translate(0px, 0px)';
});
});
// CSS necessario per la transizione di ritorno
// Aggiungilo al tuo foglio di stile:
// [data-magnetic] { transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1); }Il valore cubic-bezier(0.23, 1, 0.32, 1) è la curva di easing “elastic out” che dà quella sensazione premium al rilascio. Combinare questo effetto con le transizioni CSS fluide che già conosci porta i risultati a un altro livello.
Stati Contestuali: Testo, Immagini e Sezioni Speciali
Un custom cursor davvero raffinato cambia aspetto in base al contesto: si trasforma in un'icona “drag” sugli slider, mostra una lente sulle immagini, e può mostrare testo dinamico — come “VIEW” o “PLAY” — sopra sezioni specifiche. Questo si ottiene combinando classi CSS e attributi data-* sul DOM:
/* Cursore con testo dinamico (es. "VIEW" su card portfolio) */
.cursor-dot[data-label]::after {
content: attr(data-label);
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-family: 'Courier New', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
color: #ffffff;
white-space: nowrap;
pointer-events: none;
}
/* Stato "view": dot diventa un cerchio pieno grande */
body.cursor-view .cursor-dot {
width: 80px;
height: 80px;
background: #06b6d4;
mix-blend-mode: difference;
}
body.cursor-view .cursor-ring {
opacity: 0;
}
/* Stato "drag": mostra icona frecce */
body.cursor-drag .cursor-ring {
border-style: dashed;
animation: cursor-spin 2s linear infinite;
}
@keyframes cursor-spin {
to { rotate: 360deg; }
}
/* Ridotto per utenti con prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.cursor-dot, .cursor-ring {
transition: none !important;
animation: none !important;
}
}Il mix-blend-mode: difference è una tecnica potente: il cursore “inverte” i colori sottostanti, creando un effetto visivo che funziona su qualsiasi sfondo senza dover gestire manualmente il contrasto. L'ho usato in combinazione con l'approccio descritto nella guida agli effetti di testo hover CSS per risultati sorprendenti.
Per attivare gli stati contestuali via JavaScript è sufficiente aggiungere la logica agli event listener esistenti:
// Stato "view" su card portfolio
document.querySelectorAll('[data-cursor="view"]').forEach(el => {
el.addEventListener('mouseenter', () => {
document.body.classList.add('cursor-view');
dot.dataset.label = 'VIEW';
});
el.addEventListener('mouseleave', () => {
document.body.classList.remove('cursor-view');
delete dot.dataset.label;
});
});
// Stato "drag" su carousel/slider
document.querySelectorAll('[data-cursor="drag"]').forEach(el => {
el.addEventListener('mouseenter', () => document.body.classList.add('cursor-drag'));
el.addEventListener('mouseleave', () => document.body.classList.remove('cursor-drag'));
});
// Click feedback: piccola "pulsazione" al click
window.addEventListener('mousedown', () => {
dot.style.transform = dot.style.transform.replace(/scale\([^)]+\)/, '') + ' scale(0.75)';
ring.style.transform = ring.style.transform.replace(/scale\([^)]+\)/, '') + ' scale(1.4)';
});
window.addEventListener('mouseup', () => {
dot.style.transform = dot.style.transform.replace(/ scale\([^)]+\)/, '');
ring.style.transform = ring.style.transform.replace(/ scale\([^)]+\)/, '');
});Performance e Accessibilità: Le Regole Non Negoziabili
Un custom cursor può diventare un problema serio se non gestisci correttamente due aspetti: le performance su dispositivi mobili/touch e l'accessibilità per utenti con prefers-reduced-motion. Ecco le regole che applico in ogni progetto:
- Disabilita il cursore su touch device: rileva
window.matchMedia('(pointer: coarse)')e non inizializzare nulla su mobile. Su touch screen il cursore custom non ha senso e consuma risorse inutilmente. - Rispetta prefers-reduced-motion: disabilita le transizioni CSS e il loop lerp per utenti che hanno richiesto meno animazioni nel sistema operativo.
- Usa solo transform e opacity: queste proprietà non triggerano layout o paint, vengono gestite direttamente dalla GPU. Mai usare
top/leftomarginper spostare il cursore. - Cancella il loop su pagina nascosta: usa la Page Visibility API per fermare il
requestAnimationFramequando il tab non è visibile.
// Inizializzazione condizionale: solo su pointer fine (desktop/stylus)
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (isCoarse) {
// Touch device: rimuovi gli elementi del cursore e ripristina cursor: auto
document.querySelector('.cursor-dot')?.remove();
document.querySelector('.cursor-ring')?.remove();
document.documentElement.style.setProperty('--cursor-override', 'auto');
} else {
// Desktop: inizializza cursore custom
initCustomCursor();
}
function initCustomCursor() {
const dot = document.querySelector('.cursor-dot');
const ring = document.querySelector('.cursor-ring');
if (!dot || !ring) return;
let mouseX = 0, mouseY = 0;
let ringX = 0, ringY = 0;
let rafId = null;
const LERP = reducedMotion ? 1 : 0.14; // Con reduced-motion: no lag
window.addEventListener('mousemove', e => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function render() {
dot.style.transform = `translate(calc(${mouseX}px - 50%), calc(${mouseY}px - 50%))`;
ringX = ringX + (mouseX - ringX) * LERP;
ringY = ringY + (mouseY - ringY) * LERP;
ring.style.transform = `translate(calc(${ringX}px - 50%), calc(${ringY}px - 50%))`;
rafId = requestAnimationFrame(render);
}
// Pausa quando la pagina non è visibile
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cancelAnimationFrame(rafId);
} else {
rafId = requestAnimationFrame(render);
}
});
rafId = requestAnimationFrame(render);
}Seguire questi pattern significa che il tuo cursore custom avrà zero impatto sulle metriche Core Web Vitals — niente jank, niente layout shift, niente CPU waste su mobile.
FAQ e Domande Frequenti
Il cursore custom si vede su mobile?
No, e non deve. I dispositivi touch non hanno un cursore fisico, quindi il custom cursor va disabilitato su mobile tramite il media query (pointer: coarse). Se non lo fai, CSS applica cursor: none anche su touch, causando problemi di usabilità. La soluzione mostrata nell'ultima sezione di codice gestisce questo caso in modo robusto: rimuove gli elementi DOM e non avvia il loop requestAnimationFrame, risparmiando CPU e memoria.
Perché non usare CSS transform: translate() con variabili custom?
Puoi farlo, ed è una tecnica elegante: aggiorni --x e --y sugli elementi via JavaScript e CSS gestisce il transform. Il vantaggio è la separazione delle responsabilità; lo svantaggio è che aggiornare CSS custom properties dall'esterno del loop di rendering può in alcuni browser causare layout ricorrenti. L'approccio con style.transform diretto nel requestAnimationFrame è più prevedibile e testato in produzione su Chrome, Firefox e Safari.
Come integro il cursore custom in un progetto React/Next.js?
In React il pattern ideale è un componente CustomCursor che gestisce il proprio stato con useRef per i valori che cambiano ogni frame (evitando re-render) e useEffect per registrare/rimuovere gli event listener con cleanup. Il componente si monta nel layout root. Il trucco è non usare useState per le coordinate: ogni aggiornamento causerebbe un re-render a 60fps, uccidendo le performance. Usa invece useRef per le coordinate e aggiorna il DOM direttamente tramite il ref dell'elemento.
Il custom cursor impatta il SEO o l'accessibilità?
Se implementato correttamente, no. I due elementi .cursor-dot e .cursor-ring devono avere aria-hidden="true" (nascosti agli screen reader), pointer-events: none (non intercettano interazioni) e devono essere presenti solo su desktop via pointer: fine. Il CSS cursor: none applicato globalmente non ha impatto su SEO né su accessibilità — i motori di ricerca non renderizzano il cursore. Per la accessibilità web l'unica attenzione è rispettare prefers-reduced-motion.
Conclusione
Il cursore personalizzato CSS JavaScript è uno di quegli elementi che separano un sito “funzionante” da un sito “che si ricorda”. Con le tecniche viste in questa guida — loop requestAnimationFrame, interpolazione lerp, stati contestuali e magnetic cursor — puoi costruire esperienze premium che competono con i top del settore, senza librerie esterne e senza sacrificare le performance.
Il codice che hai visto è production-ready: gestisce device touch, reduced-motion, visibility API e accessibilità ARIA. Copialo, adattalo al tuo design system e usalo come base per le tue sperimentazioni. Se vuoi spingerti oltre, combina il custom cursor con le animazioni CSS moderne per creare transizioni di pagina che coinvolgono anche il cursore nel cambio di contesto.
Suggerimenti e Risorse
🔧 Tool: Usa Chrome DevTools Performance tab per verificare che il tuo cursore custom non causi frame drop. Filtra per “Rendering” e attiva “Paint flashing” — se vedi verde su ogni frame, hai un problema di repaint da risolvere.
💡 Pro tip: Testa sempre il cursore con la modalità “Emulate CSS media feature: prefers-reduced-motion” in DevTools. Un buon 15% degli utenti ha questa preferenza attiva e meritano un'esperienza degna.
🎯 Next step: Combina il magnetic cursor con una libreria di fisica leggera come matter.js o implementa un sistema di particelle al click usando Canvas — trovi una guida completa nell'articolo sull'effetto particelle che seguono il mouse con Canvas.

