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.


Daftar 15 Situs Judi Online yang Diblokir Kemenkominfo

Tak Kunjung Hilang, Benarkah Pemberantasan Judi Online di Indonesia Sulit Dilakukan?

Daftar 15 Situs Judi Online yang Diblokir Kemenkominfo

Daftar 10 Aplikasi Judi Online Paling Sering Dipakai di Indonesia

Aplikasi Judi Online Paling Banyak Dipakai Orang Indonesia

Apa Itu Judi Slot? Berikut Pengertian, Sejarah, Risiko, hingga Ancaman Pidananya