Event-Driven UI

Lesson 16

Most tutorials teach mousedown, mousemove, and mouseup for drag interactions. The problem: those events vanish the moment the pointer leaves the element — which happens constantly during fast drags. Pointer events fix this by unifying mouse, touch, and stylus into a single API that stays locked to the element until you release.

Pointer events vs mouse events

pointerdown, pointermove, pointerup work identically to their mouse counterparts but handle touch and pen input too. More importantly, they unlock setPointerCapture — the key to reliable drag behavior.

The prompt pattern for drag & drop:

“Build a draggable card using pointerdown, pointermove, pointerup. Call setPointerCapture(e.pointerId) on pointerdown so events keep firing even if the pointer leaves the element. Track offsetX/offsetY on pointerdown as the grab anchor. On pointermove, update element.style.left and element.style.top. On pointerup, release capture.”

Claude Code will reliably produce a correct implementation when you name the three events and mention setPointerCapture explicitly. Without that hint, it often falls back to mouse events or misses the capture call.

el.addEventListener('pointerdown', e => {
  el.setPointerCapture(e.pointerId);
  offsetX = e.clientX - el.getBoundingClientRect().left;
  offsetY = e.clientY - el.getBoundingClientRect().top;
  dragging = true;
});
el.addEventListener('pointermove', e => {
  if (!dragging) return;
  el.style.left = e.clientX - offsetX + 'px';
  el.style.top  = e.clientY - offsetY + 'px';
});
el.addEventListener('pointerup', () => { dragging = false; });

Custom cursor with lerp

Default cursors are fine for utility interfaces but break immersion in creative UIs. A lerped custom cursor — one that follows the mouse with slight lag — adds perceived smoothness without animation libraries.

Prompt: “Replace the system cursor with a 12px white-border circle. Use requestAnimationFrame to lerp the circle position toward the real mouse position at factor 0.12. On hover over .magnetic elements, scale the circle to 40px and apply mix-blend-mode: difference.”

The lerp formula is: current += (target - current) * factor. Smaller factor = more lag. This runs in a requestAnimationFrame loop and needs cursor: none on the container.

IntersectionObserver for fire-once reveals

Scroll-triggered animations have two failure modes: firing on every scroll direction (annoying) or relying on GSAP when plain JS would do. IntersectionObserver with unobserve after trigger solves both.

Prompt: “Use IntersectionObserver with threshold 0.25 to add a .visible class to each .reveal-item when it enters the viewport. Call observer.unobserve(entry.target) inside the callback so it fires only once.”

const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;
    entry.target.classList.add('visible');
    observer.unobserve(entry.target);
  });
}, { threshold: 0.25 });

document.querySelectorAll('.reveal-item').forEach(el => observer.observe(el));

The CSS side is pure transition: opacity and transform set on the default state, transitioning when .visible is present. No JavaScript animation needed.

What the demo shows

The demo on this page has three live sections. Draggable Cards — three colored cards in a bounded area you can drag freely; double-click to snap back. Custom Cursor — a dark preview box with a lerped cursor circle that expands and inverts on hover over the magnetic buttons. IntersectionObserver Reveal — eight list items that slide in from the left as you scroll down, each firing exactly once.

All three use zero libraries — just the browser’s native event and observer APIs.

Source Code script.js
/* ── Section 1: Draggable Cards ──────────────────────────────── */
(function initDrag() {
  const arena = document.getElementById('dragArena');
  const cards = arena.querySelectorAll('.drag-card');

  // Store original positions (set in CSS, read on first interaction)
  const origins = [];

  cards.forEach((card, i) => {
    // Read initial position from CSS (absolute positioned)
    origins[i] = {
      left: card.offsetLeft,
      top:  card.offsetTop,
    };

    let dragging = false;
    let grabX = 0;
    let grabY = 0;

    card.addEventListener('pointerdown', e => {
      e.preventDefault();
      card.setPointerCapture(e.pointerId);

      const rect = card.getBoundingClientRect();
      grabX = e.clientX - rect.left;
      grabY = e.clientY - rect.top;

      dragging = true;
      card.classList.add('is-dragging');
    });

    card.addEventListener('pointermove', e => {
      if (!dragging) return;

      const arenaRect = arena.getBoundingClientRect();
      let newLeft = e.clientX - arenaRect.left - grabX;
      let newTop  = e.clientY - arenaRect.top  - grabY;

      // Clamp inside arena
      newLeft = Math.max(0, Math.min(arenaRect.width  - card.offsetWidth,  newLeft));
      newTop  = Math.max(0, Math.min(arenaRect.height - card.offsetHeight, newTop));

      card.style.left = newLeft + 'px';
      card.style.top  = newTop  + 'px';
    });

    card.addEventListener('pointerup', () => {
      dragging = false;
      card.classList.remove('is-dragging');
    });

    card.addEventListener('pointercancel', () => {
      dragging = false;
      card.classList.remove('is-dragging');
    });

    // Double-click: snap back to origin
    card.addEventListener('dblclick', () => {
      card.style.transition = 'left 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)';
      card.style.left = origins[i].left + 'px';
      card.style.top  = origins[i].top  + 'px';
      setTimeout(() => { card.style.transition = ''; }, 400);
    });
  });
})();


/* ── Section 2: Custom Cursor ────────────────────────────────── */
(function initCursor() {
  const stage    = document.getElementById('cursorStage');
  const ring     = document.getElementById('cursorRing');
  const magnetics = stage.querySelectorAll('.magnetic');

  let mouseX = -100, mouseY = -100;
  let ringX  = -100, ringY  = -100;
  let rafId;
  let isInStage = false;

  stage.addEventListener('pointerenter', () => {
    isInStage = true;
    ring.style.opacity = '1';
  });

  stage.addEventListener('pointerleave', () => {
    isInStage = false;
    ring.style.opacity = '0';
  });

  stage.addEventListener('pointermove', e => {
    const rect = stage.getBoundingClientRect();
    mouseX = e.clientX - rect.left;
    mouseY = e.clientY - rect.top;
  });

  magnetics.forEach(btn => {
    btn.addEventListener('mouseenter', () => ring.classList.add('is-magnetic'));
    btn.addEventListener('mouseleave', () => ring.classList.remove('is-magnetic'));
  });

  function lerp(a, b, t) { return a + (b - a) * t; }

  function animate() {
    if (isInStage) {
      ringX = lerp(ringX, mouseX, 0.12);
      ringY = lerp(ringY, mouseY, 0.12);
      ring.style.left = ringX + 'px';
      ring.style.top  = ringY + 'px';
    }
    rafId = requestAnimationFrame(animate);
  }

  ring.style.opacity = '0';
  ring.style.transition += ', opacity 0.2s';
  animate();
})();


/* ── Section 3: IntersectionObserver Reveal ──────────────────── */
(function initReveal() {
  const items = document.querySelectorAll('.reveal-item');

  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      entry.target.classList.add('visible');
      observer.unobserve(entry.target);
    });
  }, { threshold: 0.25 });

  items.forEach(el => observer.observe(el));
})();