Cursor Stickers: micro-interacción en JS accesible y rápida
Un efecto ligero que deja una estela de stickers siguiendo el cursor. Ideal para héroes o secciones “wow”, con **rendimiento y accesibilidad**: usa requestAnimationFrame, umbral por distancia y respeta prefers-reduced-motion. Abajo tienes **demo en vivo** y el **código copiable** para producción y para **Elementor**.
Demo en vivo
Pasa el cursor o toca el área para activar el efecto:
Cómo funciona (versión corta para devs)
- Umbral por distancia: solo colocamos un sticker si el puntero se movió al menos
thresholdpx; evita saturar. - Throttling con
requestAnimationFrame: agrupamos eventos de puntero por frame. - Transform & opacity: animamos con
transform/opacitypara evitar relayout. - Accesibilidad: respetamos
prefers-reduced-motiony marcamos imágenes decorativas conalt="".
Referencia rápida: Pointer Events (MDN), requestAnimationFrame (MDN), web.dev/performance.
Código copiable (vanilla, sección standalone)
Este bloque inserta la zona interactiva en cualquier página. Incluye estilos y JS encapsulados por ID.
<section id="cursor-stickers">
<style>
#cursor-stickers .stage{position:relative;height:420px;border-radius:20px;overflow:hidden;
background: radial-gradient(500px 240px at 20% 10%, rgba(110,231,255,.18), transparent 60%),
radial-gradient(500px 240px at 80% 90%, rgba(179,136,255,.14), transparent 65%),
rgba(255,255,255,.02);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.15), 0 10px 40px rgba(0,0,0,.35)}
#cursor-stickers .wrap{position:absolute; inset:0; contain:layout paint}
#cursor-stickers .sticker{position:absolute;width:88px;height:88px;pointer-events:none;
transform:translate(-50%,-50%) scale(.9);opacity:0;filter:drop-shadow(0 6px 10px rgba(0,0,0,.5));
transition:opacity .25s ease, transform .25s ease; will-change:transform,opacity}
#cursor-stickers .sticker.visible{opacity:.95}
#cursor-stickers .sticker.bump{transform: translate(-50%,-50%) var(--tf, scale(1.08) rotate(0deg))}
#cursor-stickers .sticker.fade{opacity:0}
@media (prefers-reduced-motion: reduce){ #cursor-stickers .sticker{transition:none} }
</style>
<div class="stage" role="region" aria-label="Zona interactiva">
<div class="wrap" aria-hidden="true">
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/limon-1.png" alt="" />
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/cassete.png" alt="" />
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/paella1.png" alt="" />
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/andres-1-scaled.webp" alt="" />
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/boca.png" alt="" />
<img class="sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/sombrilla-1.png" alt="" />
</div>
</div>
<script>(function(){
const root = document.getElementById('cursor-stickers'); if(!root) return;
const stage = root.querySelector('.stage'); const stickers = [...root.querySelectorAll('.sticker')];
let idx=0; let last={x:null,y:null}; const threshold=110; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf=null,pending=null;
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const place=(p)=>{
const el=stickers[idx]; idx=(idx+1)%stickers.length;
let ang=0,scale=1.08; if(last.x!==null){const dx=p.x-last.x,dy=p.y-last.y; ang=Math.atan2(dy,dx)*180/Math.PI; const sp=Math.min(1,Math.hypot(dx,dy)/600); scale=1+sp*0.25;}
el.style.left=p.x+'px'; el.style.top=p.y+'px'; el.style.setProperty('--tf',`scale(${scale}) rotate(${ang}deg)`); el.classList.add('visible');
if(!reduced){ requestAnimationFrame(()=>{ el.classList.add('bump'); setTimeout(()=>el.classList.add('fade'),220); setTimeout(()=>{el.classList.remove('visible','bump','fade'); el.removeAttribute('style');},450); }); }
else{ setTimeout(()=>{el.classList.remove('visible'); el.removeAttribute('style');},160); }
last=p;
};
const schedule=(p)=>{ pending=p; if(raf) return; raf=requestAnimationFrame(()=>{raf=null; if(!pending) return; const q=pending; pending=null; if(last.x===null||dist(q,last)>threshold) place(q);}); };
const rel=(e)=>{const r=stage.getBoundingClientRect(); const t=('touches'in e&&e.touches[0])?e.touches[0]:e; return {x:t.clientX-r.left,y:t.clientY-r.top};};
const onMove=(e)=>{const p=rel(e); if(p.x<0||p.y<0||p.x>stage.clientWidth||p.y>stage.clientHeight) return; schedule(p);};
stage.addEventListener('pointermove',onMove,{passive:true}); stage.addEventListener('touchmove',onMove,{passive:true});
stage.addEventListener('pointerenter',e=>schedule(rel(e)),{passive:true});
stickers.forEach(img=>{ const i=new Image(); i.src=img.src; });
})();</script>
</section>
Snippet compatible con Elementor (widget HTML)
Pega esto en un **único widget HTML**. No usa <head>/<body> y está totalmente “scoped” por ID.
<section id="esw-stickers" class="esw">
<style>
#esw-stickers .esw__stage{position:relative;height:420px;border-radius:20px;overflow:hidden;
background: radial-gradient(500px 240px at 20% 10%, rgba(110,231,255,.18), transparent 60%),
radial-gradient(500px 240px at 80% 90%, rgba(179,136,255,.14), transparent 65%),
rgba(255,255,255,.02);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.15), 0 10px 40px rgba(0,0,0,.35)}
#esw-stickers .esw__wrap{position:absolute; inset:0; contain:layout paint}
#esw-stickers .esw__sticker{position:absolute;width:88px;height:88px;pointer-events:none;
transform:translate(-50%,-50%) scale(.9);opacity:0;filter:drop-shadow(0 6px 10px rgba(0,0,0,.5));
transition:opacity .25s ease, transform .25s ease; will-change:transform,opacity}
#esw-stickers .esw__sticker.visible{opacity:.95}
#esw-stickers .esw__sticker.bump{transform: translate(-50%,-50%) var(--tf, scale(1.08) rotate(0deg))}
#esw-stickers .esw__sticker.fade{opacity:0}
@media (prefers-reduced-motion: reduce){ #esw-stickers .esw__sticker{transition:none} }
</style>
<div class="esw__stage" role="region" aria-label="Zona interactiva con stickers">
<div class="esw__wrap" aria-hidden="true">
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/limon-1.png" alt="" />
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/cassete.png" alt="" />
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/paella1.png" alt="" />
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/andres-1-scaled.webp" alt="" />
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/boca.png" alt="" />
<img class="esw__sticker" src="https://elsaltoweb.es/wp-content/uploads/2024/05/sombrilla-1.png" alt="" />
</div>
</div>
<script>(function(){
const root=document.getElementById('esw-stickers'); if(!root) return;
const stage=root.querySelector('.esw__stage'); const stickers=[...root.querySelectorAll('.esw__sticker')];
let idx=0; let last={x:null,y:null}; const threshold=110; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf=null,pending=null; const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
function place(p){const el=stickers[idx]; idx=(idx+1)%stickers.length; let ang=0,sc=1.08;
if(last.x!==null){const dx=p.x-last.x,dy=p.y-last.y; ang=Math.atan2(dy,dx)*180/Math.PI; const sp=Math.min(1,Math.hypot(dx,dy)/600); sc=1+sp*0.25;}
el.style.left=p.x+'px'; el.style.top=p.y+'px'; el.style.setProperty('--tf',`scale(${sc}) rotate(${ang}deg)`); el.classList.add('visible');
if(!reduced){requestAnimationFrame(()=>{el.classList.add('bump'); setTimeout(()=>el.classList.add('fade'),220); setTimeout(()=>{el.classList.remove('visible','bump','fade'); el.removeAttribute('style');},450);});}
else{setTimeout(()=>{el.classList.remove('visible'); el.removeAttribute('style');},160);}
last=p;
}
function schedule(p){pending=p; if(raf) return; raf=requestAnimationFrame(()=>{raf=null; const q=pending; pending=null; if(!q) return; if(last.x===null||dist(q,last)>threshold) place(q);});}
function rel(e){const r=stage.getBoundingClientRect(); const t=('touches'in e&&e.touches[0])?e.touches[0]:e; return {x:t.clientX-r.left,y:t.clientY-r.top};}
function onMove(e){const p=rel(e); if(p.x<0||p.y<0||p.x>stage.clientWidth||p.y>stage.clientHeight) return; schedule(p);}
stage.addEventListener('pointermove',onMove,{passive:true}); stage.addEventListener('touchmove',onMove,{passive:true});
stage.addEventListener('pointerenter',e=>schedule(rel(e)),{passive:true});
stickers.forEach(img=>{const i=new Image(); i.src=img.src;});
})();</script>
</section>
Nota Elementor: si tu rol no permite <script>, pega solo el HTML+CSS y añade el JS con un plugin tipo “Code Snippets” o encola un archivo con wp_enqueue_script.
SEO y mejores prácticas
- Título H1 + meta description: ya incluidos. Ajusta el
<title>y ladescriptiona tu keyword objetivo. - Enlaces salientes de calidad: usa referencias oficiales: Documentación de Elementor, Soporte WordPress.
- Accesibilidad: respeta
prefers-reduced-motion, usaalt=""en decorativos y roles ARIA en la zona interactiva. - Rendimiento: imágenes optimizadas (WebP donde aplique) y animaciones con
transform/opacity. - FAQ schema: añade preguntas comunes (ver abajo) para ampliar cobertura long-tail.
Si quieres convertir esto en un bloque reusable, mi recomendación es crear un **shortcode** y exponer opciones (tamaño, densidad, assets). En WP: add_shortcode('cursor_stickers', ...).
FAQ
¿Funciona en móviles?
Sí, escucha pointermove y touchmove. En móviles se activa al arrastrar el dedo sobre la zona.
¿Cómo evito mareos en usuarios sensibles?
El script detecta prefers-reduced-motion y simplifica la animación automáticamente.
¿Puedo usar SVG en lugar de PNG?
Sin problema. Cambia las src por SVG optimizados. Mantén el alt="" si son decorativos.
ElSaltoWeb News