Cursor Stickers: micro-interacción en JS accesible y rápida (con demo y código)
ElSaltoWeb.esFrontend • UI/UX

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**.

Tiempo de lectura: 6–8 min Actualizado: 18/08/2025

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 threshold px; evita saturar.
  • Throttling con requestAnimationFrame: agrupamos eventos de puntero por frame.
  • Transform & opacity: animamos con transform/opacity para evitar relayout.
  • Accesibilidad: respetamos prefers-reduced-motion y marcamos imágenes decorativas con alt="".

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 la description a tu keyword objetivo.
  • Enlaces salientes de calidad: usa referencias oficiales: Documentación de Elementor, Soporte WordPress.
  • Accesibilidad: respeta prefers-reduced-motion, usa alt="" 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', ...).

Probar de nuevo la demo

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.


Crea un Artículo sobre WordPress: Guía Completa para Principiantes

Agentes de IA con LangChain

Agentes de IA con LangChain: arquitectura, seguridad y resultados

Efecto de estrellas detrás de tu ratón

¿Te fascina el cielo estrellado? 🌌🌠 Explora este proyecto "Star Trails with Options" y personaliza la magia del firmamento. 💫🔧
Slider CSS Moderno UI/UX Impactante para Landing Pages

Slider responsivo solo HTML y CSS

Slider totalmente personalizado que hemos creado utilizando solo HTML y CSS.

NBA LEGENDS

Leyendas de la NBA: Quiénes son los GOATs de la Historia

Cómo mejorar la experiencia de usuario (UX) con un diseño web profesional

UI/UX en acción: claves para optimizar tu web y aumentar conversiones