Event-Driven UI
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. CallsetPointerCapture(e.pointerId)on pointerdown so events keep firing even if the pointer leaves the element. TrackoffsetX/offsetYon pointerdown as the grab anchor. On pointermove, updateelement.style.leftandelement.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.
이벤트 기반 UI는 사용자 인터랙션(클릭, 드래그, 인터섹션)에 반응합니다. Claude Code에 이벤트 타입, 트리거, 예상 반응을 명세하면 올바른 이벤트 리스너와 핸들러를 생성합니다. 데모는 세 가지 섹션을 보여줍니다: 드래그 가능한 카드, 커스텀 커서, IntersectionObserver 리빌.
/* ── 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));
})();