Patterns Medium
Gesture Animations
Collection of gesture-based animations including hover scale, tap shrink, drag with constraints, and focus glow. Each gesture type demonstrates a different interaction pattern.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(0.75rem, 3vw, 2rem);
}
.demo {
width: min(560px, 100%);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.demo-title {
font-size: 1.25rem;
font-weight: 700;
color: #f4f4f5;
}
.demo-subtitle {
font-size: 0.8rem;
color: #52525b;
margin-top: 0.25rem;
}
/* โโ Gesture cards grid โโ */
.gesture-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.gesture-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.gesture-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #52525b;
}
.gesture-hint {
font-size: 0.7rem;
color: #3f3f46;
text-align: center;
}
/* โโ 1. Hover Scale โโ */
.hover-target {
width: clamp(64px, 20vw, 100px);
height: clamp(64px, 20vw, 100px);
border-radius: 1rem;
background: linear-gradient(135deg, #6d28d9, #a78bfa);
display: grid;
place-items: center;
font-size: 1.75rem;
cursor: pointer;
will-change: transform;
}
/* โโ 2. Tap Shrink โโ */
.tap-target {
width: clamp(64px, 20vw, 100px);
height: clamp(64px, 20vw, 100px);
border-radius: 1rem;
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
display: grid;
place-items: center;
font-size: 1.75rem;
cursor: pointer;
will-change: transform;
user-select: none;
}
/* โโ 3. Drag Constrained โโ */
.drag-area {
width: 100%;
height: clamp(80px, 25vw, 120px);
background: rgba(255, 255, 255, 0.02);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
position: relative;
overflow: hidden;
}
.drag-target {
width: 48px;
height: 48px;
border-radius: 0.75rem;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
cursor: grab;
will-change: transform;
touch-action: none;
display: grid;
place-items: center;
font-size: 1.2rem;
}
.drag-target.dragging {
cursor: grabbing;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.4);
}
/* โโ 4. Focus Glow โโ */
.focus-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-family: inherit;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
color: #e4e4e7;
outline: none;
transition: border-color 0.2s;
}
.focus-input::placeholder {
color: #52525b;
}
.focus-wrapper {
width: 100%;
position: relative;
border-radius: 0.75rem;
}
.focus-glow {
position: absolute;
inset: -2px;
border-radius: 0.875rem;
opacity: 0;
pointer-events: none;
background: conic-gradient(from 0deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899);
filter: blur(8px);
will-change: opacity;
}
@media (max-width: 400px) {
.gesture-grid {
grid-template-columns: 1fr;
}
}(function () {
"use strict";
// โโ Utility: smooth lerp animation via rAF โโ
function animateValue(getCurrentValue, getTargetValue, applyValue, ease) {
let current = getCurrentValue();
let raf;
function tick() {
const target = getTargetValue();
current += (target - current) * ease;
// Settle
if (Math.abs(current - target) < 0.001) {
current = target;
applyValue(current);
return;
}
applyValue(current);
raf = requestAnimationFrame(tick);
}
function start() {
cancelAnimationFrame(raf);
tick();
}
return { start, stop: () => cancelAnimationFrame(raf) };
}
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 1. HOVER SCALE
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const hoverEl = document.getElementById("hover-target");
let hoverScale = 1;
let hoverTarget = 1;
const hoverAnim = animateValue(
() => hoverScale,
() => hoverTarget,
(v) => {
hoverScale = v;
hoverEl.style.transform = `scale(${v})`;
},
0.12
);
hoverEl.addEventListener("mouseenter", () => {
hoverTarget = 1.15;
hoverAnim.start();
});
hoverEl.addEventListener("mouseleave", () => {
hoverTarget = 1;
hoverAnim.start();
});
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 2. TAP SHRINK
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const tapEl = document.getElementById("tap-target");
let tapScale = 1;
let tapTarget = 1;
const tapAnim = animateValue(
() => tapScale,
() => tapTarget,
(v) => {
tapScale = v;
tapEl.style.transform = `scale(${v})`;
},
0.15
);
tapEl.addEventListener("pointerdown", () => {
tapTarget = 0.85;
tapAnim.start();
});
tapEl.addEventListener("pointerup", () => {
tapTarget = 1;
tapAnim.start();
});
tapEl.addEventListener("pointerleave", () => {
tapTarget = 1;
tapAnim.start();
});
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 3. DRAG CONSTRAINED
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const dragArea = document.getElementById("drag-area");
const dragEl = document.getElementById("drag-constrained");
let dragX = 0,
dragY = 0;
let dragTargetX = 0,
dragTargetY = 0;
let isDragging = false;
let dragOffX = 0,
dragOffY = 0;
let originX = 0,
originY = 0;
// Lerp for smooth return
let dragLerpX = 0,
dragLerpY = 0;
let dragRaf;
function dragTick() {
dragLerpX += (dragTargetX - dragLerpX) * 0.15;
dragLerpY += (dragTargetY - dragLerpY) * 0.15;
if (Math.abs(dragLerpX - dragTargetX) < 0.1 && Math.abs(dragLerpY - dragTargetY) < 0.1) {
dragLerpX = dragTargetX;
dragLerpY = dragTargetY;
dragEl.style.transform = `translate(calc(-50% + ${dragLerpX}px), calc(-50% + ${dragLerpY}px))`;
return;
}
dragEl.style.transform = `translate(calc(-50% + ${dragLerpX}px), calc(-50% + ${dragLerpY}px))`;
dragRaf = requestAnimationFrame(dragTick);
}
dragEl.addEventListener("pointerdown", (e) => {
isDragging = true;
dragEl.classList.add("dragging");
dragEl.setPointerCapture(e.pointerId);
const areaRect = dragArea.getBoundingClientRect();
originX = areaRect.left + areaRect.width / 2;
originY = areaRect.top + areaRect.height / 2;
dragOffX = e.clientX - originX - dragLerpX;
dragOffY = e.clientY - originY - dragLerpY;
cancelAnimationFrame(dragRaf);
});
window.addEventListener("pointermove", (e) => {
if (!isDragging) return;
const areaRect = dragArea.getBoundingClientRect();
const halfW = areaRect.width / 2 - 28;
const halfH = areaRect.height / 2 - 28;
let nx = e.clientX - originX - dragOffX;
let ny = e.clientY - originY - dragOffY;
// Clamp to area
nx = Math.max(-halfW, Math.min(halfW, nx));
ny = Math.max(-halfH, Math.min(halfH, ny));
dragLerpX = nx;
dragLerpY = ny;
dragTargetX = nx;
dragTargetY = ny;
dragEl.style.transform = `translate(calc(-50% + ${nx}px), calc(-50% + ${ny}px))`;
});
window.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
dragEl.classList.remove("dragging");
// Spring back to center
dragTargetX = 0;
dragTargetY = 0;
cancelAnimationFrame(dragRaf);
dragRaf = requestAnimationFrame(dragTick);
});
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 4. FOCUS GLOW
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const focusInput = document.getElementById("focus-input");
const focusGlow = document.getElementById("focus-glow");
let glowOpacity = 0;
let glowTarget = 0;
let glowRotation = 0;
let glowRaf;
function glowTick() {
glowOpacity += (glowTarget - glowOpacity) * 0.08;
glowRotation += 1.5;
if (Math.abs(glowOpacity - glowTarget) < 0.005 && glowTarget === 0) {
glowOpacity = 0;
focusGlow.style.opacity = "0";
return;
}
focusGlow.style.opacity = String(glowOpacity);
focusGlow.style.background = `conic-gradient(from ${glowRotation}deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899)`;
glowRaf = requestAnimationFrame(glowTick);
}
focusInput.addEventListener("focus", () => {
glowTarget = 0.6;
cancelAnimationFrame(glowRaf);
glowRaf = requestAnimationFrame(glowTick);
});
focusInput.addEventListener("blur", () => {
glowTarget = 0;
cancelAnimationFrame(glowRaf);
glowRaf = requestAnimationFrame(glowTick);
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gesture Animations</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div>
<h2 class="demo-title">Gesture Animations</h2>
<p class="demo-subtitle">Four gesture types with smooth, interruptible animations</p>
</div>
<div class="gesture-grid">
<!-- 1. Hover Scale -->
<div class="gesture-card">
<span class="gesture-label">Hover Scale</span>
<div class="hover-target" id="hover-target">โฆ</div>
<span class="gesture-hint">Hover over the card</span>
</div>
<!-- 2. Tap Shrink -->
<div class="gesture-card">
<span class="gesture-label">Tap Shrink</span>
<div class="tap-target" id="tap-target">โ</div>
<span class="gesture-hint">Press and hold</span>
</div>
<!-- 3. Drag Constrained -->
<div class="gesture-card">
<span class="gesture-label">Drag Constrained</span>
<div class="drag-area" id="drag-area">
<div class="drag-target" id="drag-constrained">โฌก</div>
</div>
<span class="gesture-hint">Drag within the box</span>
</div>
<!-- 4. Focus Glow -->
<div class="gesture-card">
<span class="gesture-label">Focus Glow</span>
<div class="focus-wrapper">
<div class="focus-glow" id="focus-glow"></div>
<input class="focus-input" id="focus-input" type="text" placeholder="Click to focus..." />
</div>
<span class="gesture-hint">Focus the input field</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
/* โโ Hover Scale Card โโ */
function HoverScale() {
const ref = useRef<HTMLDivElement>(null);
const scaleRef = useRef(1);
const targetRef = useRef(1);
const rafRef = useRef(0);
const tick = useCallback(() => {
scaleRef.current += (targetRef.current - scaleRef.current) * 0.12;
if (Math.abs(scaleRef.current - targetRef.current) < 0.001) {
scaleRef.current = targetRef.current;
}
if (ref.current) ref.current.style.transform = `scale(${scaleRef.current})`;
if (scaleRef.current !== targetRef.current) rafRef.current = requestAnimationFrame(tick);
}, []);
const start = (t: number) => {
targetRef.current = t;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
};
return (
<div style={cardStyle}>
<span style={labelStyle}>Hover Scale</span>
<div
ref={ref}
onMouseEnter={() => start(1.15)}
onMouseLeave={() => start(1)}
style={{
width: 100,
height: 100,
borderRadius: "1rem",
background: "linear-gradient(135deg, #6d28d9, #a78bfa)",
display: "grid",
placeItems: "center",
fontSize: "1.75rem",
cursor: "pointer",
willChange: "transform",
}}
>
โฆ
</div>
<span style={hintStyle}>Hover over the card</span>
</div>
);
}
/* โโ Tap Shrink Card โโ */
function TapShrink() {
const ref = useRef<HTMLDivElement>(null);
const scaleRef = useRef(1);
const targetRef = useRef(1);
const rafRef = useRef(0);
const tick = useCallback(() => {
scaleRef.current += (targetRef.current - scaleRef.current) * 0.15;
if (Math.abs(scaleRef.current - targetRef.current) < 0.001)
scaleRef.current = targetRef.current;
if (ref.current) ref.current.style.transform = `scale(${scaleRef.current})`;
if (scaleRef.current !== targetRef.current) rafRef.current = requestAnimationFrame(tick);
}, []);
const start = (t: number) => {
targetRef.current = t;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
};
return (
<div style={cardStyle}>
<span style={labelStyle}>Tap Shrink</span>
<div
ref={ref}
onPointerDown={() => start(0.85)}
onPointerUp={() => start(1)}
onPointerLeave={() => start(1)}
style={{
width: 100,
height: 100,
borderRadius: "1rem",
background: "linear-gradient(135deg, #0ea5e9, #38bdf8)",
display: "grid",
placeItems: "center",
fontSize: "1.75rem",
cursor: "pointer",
userSelect: "none",
willChange: "transform",
}}
>
โ
</div>
<span style={hintStyle}>Press and hold</span>
</div>
);
}
/* โโ Drag Constrained Card โโ */
function DragConstrained() {
const areaRef = useRef<HTMLDivElement>(null);
const elRef = useRef<HTMLDivElement>(null);
const stateRef = useRef({
isDragging: false,
offX: 0,
offY: 0,
originX: 0,
originY: 0,
lerpX: 0,
lerpY: 0,
targetX: 0,
targetY: 0,
});
const rafRef = useRef(0);
const returnTick = useCallback(() => {
const s = stateRef.current;
s.lerpX += (s.targetX - s.lerpX) * 0.15;
s.lerpY += (s.targetY - s.lerpY) * 0.15;
if (Math.abs(s.lerpX - s.targetX) < 0.1 && Math.abs(s.lerpY - s.targetY) < 0.1) {
s.lerpX = s.targetX;
s.lerpY = s.targetY;
}
if (elRef.current)
elRef.current.style.transform = `translate(calc(-50% + ${s.lerpX}px), calc(-50% + ${s.lerpY}px))`;
if (s.lerpX !== s.targetX || s.lerpY !== s.targetY)
rafRef.current = requestAnimationFrame(returnTick);
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const s = stateRef.current;
if (!s.isDragging || !areaRef.current) return;
const rect = areaRef.current.getBoundingClientRect();
const halfW = rect.width / 2 - 28;
const halfH = rect.height / 2 - 28;
let nx = e.clientX - s.originX - s.offX;
let ny = e.clientY - s.originY - s.offY;
nx = Math.max(-halfW, Math.min(halfW, nx));
ny = Math.max(-halfH, Math.min(halfH, ny));
s.lerpX = nx;
s.lerpY = ny;
s.targetX = nx;
s.targetY = ny;
if (elRef.current)
elRef.current.style.transform = `translate(calc(-50% + ${nx}px), calc(-50% + ${ny}px))`;
};
const onUp = () => {
const s = stateRef.current;
if (!s.isDragging) return;
s.isDragging = false;
s.targetX = 0;
s.targetY = 0;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(returnTick);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
}, [returnTick]);
const onDown = (e: React.PointerEvent) => {
const s = stateRef.current;
s.isDragging = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
if (areaRef.current) {
const rect = areaRef.current.getBoundingClientRect();
s.originX = rect.left + rect.width / 2;
s.originY = rect.top + rect.height / 2;
}
s.offX = e.clientX - s.originX - s.lerpX;
s.offY = e.clientY - s.originY - s.lerpY;
cancelAnimationFrame(rafRef.current);
};
return (
<div style={cardStyle}>
<span style={labelStyle}>Drag Constrained</span>
<div
ref={areaRef}
style={{
width: "100%",
height: 120,
background: "rgba(255,255,255,0.02)",
border: "1px dashed rgba(255,255,255,0.1)",
borderRadius: "0.75rem",
position: "relative",
overflow: "hidden",
}}
>
<div
ref={elRef}
onPointerDown={onDown}
style={{
width: 48,
height: 48,
borderRadius: "0.75rem",
background: "linear-gradient(135deg, #f59e0b, #fbbf24)",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
cursor: "grab",
willChange: "transform",
touchAction: "none",
display: "grid",
placeItems: "center",
fontSize: "1.2rem",
}}
>
โฌก
</div>
</div>
<span style={hintStyle}>Drag within the box</span>
</div>
);
}
/* โโ Focus Glow Card โโ */
function FocusGlow() {
const glowRef = useRef<HTMLDivElement>(null);
const opRef = useRef(0);
const targetRef = useRef(0);
const rotRef = useRef(0);
const rafRef = useRef(0);
const tick = useCallback(() => {
opRef.current += (targetRef.current - opRef.current) * 0.08;
rotRef.current += 1.5;
if (Math.abs(opRef.current - targetRef.current) < 0.005 && targetRef.current === 0) {
opRef.current = 0;
if (glowRef.current) glowRef.current.style.opacity = "0";
return;
}
if (glowRef.current) {
glowRef.current.style.opacity = String(opRef.current);
glowRef.current.style.background = `conic-gradient(from ${rotRef.current}deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899)`;
}
rafRef.current = requestAnimationFrame(tick);
}, []);
const start = (t: number) => {
targetRef.current = t;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
};
return (
<div style={cardStyle}>
<span style={labelStyle}>Focus Glow</span>
<div style={{ width: "100%", position: "relative", borderRadius: "0.75rem" }}>
<div
ref={glowRef}
style={{
position: "absolute",
inset: -2,
borderRadius: "0.875rem",
opacity: 0,
pointerEvents: "none",
background:
"conic-gradient(from 0deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899)",
filter: "blur(8px)",
willChange: "opacity",
}}
/>
<input
type="text"
placeholder="Click to focus..."
onFocus={() => start(0.6)}
onBlur={() => start(0)}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "0.85rem",
fontFamily: "inherit",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "0.75rem",
color: "#e4e4e7",
outline: "none",
position: "relative",
zIndex: 1,
}}
/>
</div>
<span style={hintStyle}>Focus the input field</span>
</div>
);
}
/* โโ Shared styles โโ */
const cardStyle: React.CSSProperties = {
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: "1rem",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "1rem",
};
const labelStyle: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#52525b",
};
const hintStyle: React.CSSProperties = {
fontSize: "0.7rem",
color: "#3f3f46",
textAlign: "center",
};
/* โโ Main export โโ */
export default function GestureAnimations() {
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e4e4e7",
}}
>
<div
style={{
width: "min(560px, 100%)",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
}}
>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "#f4f4f5" }}>
Gesture Animations
</h2>
<p style={{ fontSize: "0.8rem", color: "#52525b", marginTop: "0.25rem" }}>
Four gesture types with smooth, interruptible animations
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<HoverScale />
<TapShrink />
<DragConstrained />
<FocusGlow />
</div>
</div>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
/* Hover Scale */
const hoverEl = ref(null);
let hoverScale = 1,
hoverTarget = 1,
hoverRaf = 0;
function hoverTick() {
hoverScale += (hoverTarget - hoverScale) * 0.12;
if (Math.abs(hoverScale - hoverTarget) < 0.001) hoverScale = hoverTarget;
if (hoverEl.value) hoverEl.value.style.transform = `scale(${hoverScale})`;
if (hoverScale !== hoverTarget) hoverRaf = requestAnimationFrame(hoverTick);
}
function hoverStart(t) {
hoverTarget = t;
cancelAnimationFrame(hoverRaf);
hoverRaf = requestAnimationFrame(hoverTick);
}
/* Tap Shrink */
const tapEl = ref(null);
let tapScale = 1,
tapTarget = 1,
tapRaf = 0;
function tapTick() {
tapScale += (tapTarget - tapScale) * 0.15;
if (Math.abs(tapScale - tapTarget) < 0.001) tapScale = tapTarget;
if (tapEl.value) tapEl.value.style.transform = `scale(${tapScale})`;
if (tapScale !== tapTarget) tapRaf = requestAnimationFrame(tapTick);
}
function tapStart(t) {
tapTarget = t;
cancelAnimationFrame(tapRaf);
tapRaf = requestAnimationFrame(tapTick);
}
/* Drag Constrained */
const areaEl = ref(null);
const dragElRef = ref(null);
const dragState = {
isDragging: false,
offX: 0,
offY: 0,
originX: 0,
originY: 0,
lerpX: 0,
lerpY: 0,
targetX: 0,
targetY: 0,
};
let dragRaf = 0;
function dragReturnTick() {
dragState.lerpX += (dragState.targetX - dragState.lerpX) * 0.15;
dragState.lerpY += (dragState.targetY - dragState.lerpY) * 0.15;
if (
Math.abs(dragState.lerpX - dragState.targetX) < 0.1 &&
Math.abs(dragState.lerpY - dragState.targetY) < 0.1
) {
dragState.lerpX = dragState.targetX;
dragState.lerpY = dragState.targetY;
}
if (dragElRef.value)
dragElRef.value.style.transform = `translate(calc(-50% + ${dragState.lerpX}px), calc(-50% + ${dragState.lerpY}px))`;
if (dragState.lerpX !== dragState.targetX || dragState.lerpY !== dragState.targetY)
dragRaf = requestAnimationFrame(dragReturnTick);
}
function onDragDown(e) {
dragState.isDragging = true;
e.target.setPointerCapture(e.pointerId);
if (areaEl.value) {
const rect = areaEl.value.getBoundingClientRect();
dragState.originX = rect.left + rect.width / 2;
dragState.originY = rect.top + rect.height / 2;
}
dragState.offX = e.clientX - dragState.originX - dragState.lerpX;
dragState.offY = e.clientY - dragState.originY - dragState.lerpY;
cancelAnimationFrame(dragRaf);
}
function onWindowMove(e) {
if (!dragState.isDragging || !areaEl.value) return;
const rect = areaEl.value.getBoundingClientRect();
const halfW = rect.width / 2 - 28,
halfH = rect.height / 2 - 28;
let nx = e.clientX - dragState.originX - dragState.offX;
let ny = e.clientY - dragState.originY - dragState.offY;
nx = Math.max(-halfW, Math.min(halfW, nx));
ny = Math.max(-halfH, Math.min(halfH, ny));
dragState.lerpX = nx;
dragState.lerpY = ny;
dragState.targetX = nx;
dragState.targetY = ny;
if (dragElRef.value)
dragElRef.value.style.transform = `translate(calc(-50% + ${nx}px), calc(-50% + ${ny}px))`;
}
function onWindowUp() {
if (!dragState.isDragging) return;
dragState.isDragging = false;
dragState.targetX = 0;
dragState.targetY = 0;
cancelAnimationFrame(dragRaf);
dragRaf = requestAnimationFrame(dragReturnTick);
}
/* Focus Glow */
const glowEl = ref(null);
let glowOp = 0,
glowTarget = 0,
glowRot = 0,
glowRaf = 0;
function glowTick() {
glowOp += (glowTarget - glowOp) * 0.08;
glowRot += 1.5;
if (Math.abs(glowOp - glowTarget) < 0.005 && glowTarget === 0) {
glowOp = 0;
if (glowEl.value) glowEl.value.style.opacity = "0";
return;
}
if (glowEl.value) {
glowEl.value.style.opacity = String(glowOp);
glowEl.value.style.background = `conic-gradient(from ${glowRot}deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899)`;
}
glowRaf = requestAnimationFrame(glowTick);
}
function glowStart(t) {
glowTarget = t;
cancelAnimationFrame(glowRaf);
glowRaf = requestAnimationFrame(glowTick);
}
onMounted(() => {
window.addEventListener("pointermove", onWindowMove);
window.addEventListener("pointerup", onWindowUp);
});
onUnmounted(() => {
window.removeEventListener("pointermove", onWindowMove);
window.removeEventListener("pointerup", onWindowUp);
cancelAnimationFrame(hoverRaf);
cancelAnimationFrame(tapRaf);
cancelAnimationFrame(dragRaf);
cancelAnimationFrame(glowRaf);
});
</script>
<template>
<div class="gesture-wrapper">
<div class="gesture-panel">
<div>
<h2 class="gesture-title">Gesture Animations</h2>
<p class="gesture-desc">Four gesture types with smooth, interruptible animations</p>
</div>
<div class="gesture-grid">
<!-- Hover Scale -->
<div class="card">
<span class="label">Hover Scale</span>
<div ref="hoverEl" class="hover-box" @mouseenter="hoverStart(1.15)" @mouseleave="hoverStart(1)">✦</div>
<span class="hint">Hover over the card</span>
</div>
<!-- Tap Shrink -->
<div class="card">
<span class="label">Tap Shrink</span>
<div ref="tapEl" class="tap-box" @pointerdown="tapStart(0.85)" @pointerup="tapStart(1)" @pointerleave="tapStart(1)">◆</div>
<span class="hint">Press and hold</span>
</div>
<!-- Drag Constrained -->
<div class="card">
<span class="label">Drag Constrained</span>
<div ref="areaEl" class="drag-area">
<div ref="dragElRef" class="drag-box" @pointerdown="onDragDown">⬡</div>
</div>
<span class="hint">Drag within the box</span>
</div>
<!-- Focus Glow -->
<div class="card">
<span class="label">Focus Glow</span>
<div class="glow-wrap">
<div ref="glowEl" class="glow-ring"></div>
<input type="text" placeholder="Click to focus..." class="glow-input" @focus="glowStart(0.6)" @blur="glowStart(0)" />
</div>
<span class="hint">Focus the input field</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.gesture-wrapper {
min-height: 100vh;
background: #0a0a0a;
display: grid;
place-items: center;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
color: #e4e4e7;
}
.gesture-panel { width: min(560px, 100%); display: flex; flex-direction: column; gap: 1.5rem; }
.gesture-title { font-size: 1.25rem; font-weight: 700; color: #f4f4f5; }
.gesture-desc { font-size: 0.8rem; color: #52525b; margin-top: 0.25rem; }
.gesture-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #52525b; }
.hint { font-size: 0.7rem; color: #3f3f46; text-align: center; }
.hover-box {
width: 100px; height: 100px; border-radius: 1rem;
background: linear-gradient(135deg, #6d28d9, #a78bfa);
display: grid; place-items: center; font-size: 1.75rem;
cursor: pointer; will-change: transform;
}
.tap-box {
width: 100px; height: 100px; border-radius: 1rem;
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
display: grid; place-items: center; font-size: 1.75rem;
cursor: pointer; user-select: none; will-change: transform;
}
.drag-area {
width: 100%; height: 120px;
background: rgba(255,255,255,0.02);
border: 1px dashed rgba(255,255,255,0.1);
border-radius: 0.75rem;
position: relative; overflow: hidden;
}
.drag-box {
width: 48px; height: 48px; border-radius: 0.75rem;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
cursor: grab; will-change: transform; touch-action: none;
display: grid; place-items: center; font-size: 1.2rem;
}
.glow-wrap { width: 100%; position: relative; border-radius: 0.75rem; }
.glow-ring {
position: absolute; inset: -2px; border-radius: 0.875rem;
opacity: 0; pointer-events: none;
background: conic-gradient(from 0deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899);
filter: blur(8px); will-change: opacity;
}
.glow-input {
width: 100%; padding: 0.75rem 1rem; font-size: 0.85rem;
font-family: inherit;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.75rem; color: #e4e4e7; outline: none;
position: relative; z-index: 1;
}
</style><script>
import { onMount, onDestroy } from "svelte";
/* Hover Scale */
let hoverEl;
let hoverScale = 1;
let hoverTarget = 1;
let hoverRaf = 0;
function hoverTick() {
hoverScale += (hoverTarget - hoverScale) * 0.12;
if (Math.abs(hoverScale - hoverTarget) < 0.001) hoverScale = hoverTarget;
if (hoverEl) hoverEl.style.transform = `scale(${hoverScale})`;
if (hoverScale !== hoverTarget) hoverRaf = requestAnimationFrame(hoverTick);
}
function hoverStart(t) {
hoverTarget = t;
cancelAnimationFrame(hoverRaf);
hoverRaf = requestAnimationFrame(hoverTick);
}
/* Tap Shrink */
let tapEl;
let tapScale = 1;
let tapTarget = 1;
let tapRaf = 0;
function tapTick() {
tapScale += (tapTarget - tapScale) * 0.15;
if (Math.abs(tapScale - tapTarget) < 0.001) tapScale = tapTarget;
if (tapEl) tapEl.style.transform = `scale(${tapScale})`;
if (tapScale !== tapTarget) tapRaf = requestAnimationFrame(tapTick);
}
function tapStart(t) {
tapTarget = t;
cancelAnimationFrame(tapRaf);
tapRaf = requestAnimationFrame(tapTick);
}
/* Drag Constrained */
let areaEl;
let dragEl;
let dragState = {
isDragging: false,
offX: 0,
offY: 0,
originX: 0,
originY: 0,
lerpX: 0,
lerpY: 0,
targetX: 0,
targetY: 0,
};
let dragRaf = 0;
function dragReturnTick() {
dragState.lerpX += (dragState.targetX - dragState.lerpX) * 0.15;
dragState.lerpY += (dragState.targetY - dragState.lerpY) * 0.15;
if (
Math.abs(dragState.lerpX - dragState.targetX) < 0.1 &&
Math.abs(dragState.lerpY - dragState.targetY) < 0.1
) {
dragState.lerpX = dragState.targetX;
dragState.lerpY = dragState.targetY;
}
if (dragEl)
dragEl.style.transform = `translate(calc(-50% + ${dragState.lerpX}px), calc(-50% + ${dragState.lerpY}px))`;
if (dragState.lerpX !== dragState.targetX || dragState.lerpY !== dragState.targetY) {
dragRaf = requestAnimationFrame(dragReturnTick);
}
}
function onDragDown(e) {
dragState.isDragging = true;
e.target.setPointerCapture(e.pointerId);
if (areaEl) {
const rect = areaEl.getBoundingClientRect();
dragState.originX = rect.left + rect.width / 2;
dragState.originY = rect.top + rect.height / 2;
}
dragState.offX = e.clientX - dragState.originX - dragState.lerpX;
dragState.offY = e.clientY - dragState.originY - dragState.lerpY;
cancelAnimationFrame(dragRaf);
}
function onWindowMove(e) {
if (!dragState.isDragging || !areaEl) return;
const rect = areaEl.getBoundingClientRect();
const halfW = rect.width / 2 - 28;
const halfH = rect.height / 2 - 28;
let nx = e.clientX - dragState.originX - dragState.offX;
let ny = e.clientY - dragState.originY - dragState.offY;
nx = Math.max(-halfW, Math.min(halfW, nx));
ny = Math.max(-halfH, Math.min(halfH, ny));
dragState.lerpX = nx;
dragState.lerpY = ny;
dragState.targetX = nx;
dragState.targetY = ny;
if (dragEl) dragEl.style.transform = `translate(calc(-50% + ${nx}px), calc(-50% + ${ny}px))`;
}
function onWindowUp() {
if (!dragState.isDragging) return;
dragState.isDragging = false;
dragState.targetX = 0;
dragState.targetY = 0;
cancelAnimationFrame(dragRaf);
dragRaf = requestAnimationFrame(dragReturnTick);
}
/* Focus Glow */
let glowEl;
let glowOp = 0;
let glowTarget = 0;
let glowRot = 0;
let glowRaf = 0;
function glowTick() {
glowOp += (glowTarget - glowOp) * 0.08;
glowRot += 1.5;
if (Math.abs(glowOp - glowTarget) < 0.005 && glowTarget === 0) {
glowOp = 0;
if (glowEl) glowEl.style.opacity = "0";
return;
}
if (glowEl) {
glowEl.style.opacity = String(glowOp);
glowEl.style.background = `conic-gradient(from ${glowRot}deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899)`;
}
glowRaf = requestAnimationFrame(glowTick);
}
function glowStart(t) {
glowTarget = t;
cancelAnimationFrame(glowRaf);
glowRaf = requestAnimationFrame(glowTick);
}
onMount(() => {
window.addEventListener("pointermove", onWindowMove);
window.addEventListener("pointerup", onWindowUp);
});
onDestroy(() => {
window.removeEventListener("pointermove", onWindowMove);
window.removeEventListener("pointerup", onWindowUp);
cancelAnimationFrame(hoverRaf);
cancelAnimationFrame(tapRaf);
cancelAnimationFrame(dragRaf);
cancelAnimationFrame(glowRaf);
});
</script>
<div class="gesture-wrapper">
<div class="gesture-panel">
<div>
<h2 class="gesture-title">Gesture Animations</h2>
<p class="gesture-desc">Four gesture types with smooth, interruptible animations</p>
</div>
<div class="gesture-grid">
<!-- Hover Scale -->
<div class="card">
<span class="label">Hover Scale</span>
<div
bind:this={hoverEl}
class="hover-box"
on:mouseenter={() => hoverStart(1.15)}
on:mouseleave={() => hoverStart(1)}
>
✦
</div>
<span class="hint">Hover over the card</span>
</div>
<!-- Tap Shrink -->
<div class="card">
<span class="label">Tap Shrink</span>
<div
bind:this={tapEl}
class="tap-box"
on:pointerdown={() => tapStart(0.85)}
on:pointerup={() => tapStart(1)}
on:pointerleave={() => tapStart(1)}
>
◆
</div>
<span class="hint">Press and hold</span>
</div>
<!-- Drag Constrained -->
<div class="card">
<span class="label">Drag Constrained</span>
<div bind:this={areaEl} class="drag-area">
<div
bind:this={dragEl}
class="drag-box"
on:pointerdown={onDragDown}
>
⬡
</div>
</div>
<span class="hint">Drag within the box</span>
</div>
<!-- Focus Glow -->
<div class="card">
<span class="label">Focus Glow</span>
<div class="glow-wrap">
<div bind:this={glowEl} class="glow-ring"></div>
<input
type="text"
placeholder="Click to focus..."
class="glow-input"
on:focus={() => glowStart(0.6)}
on:blur={() => glowStart(0)}
/>
</div>
<span class="hint">Focus the input field</span>
</div>
</div>
</div>
</div>
<style>
.gesture-wrapper {
min-height: 100vh;
background: #0a0a0a;
display: grid;
place-items: center;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
color: #e4e4e7;
}
.gesture-panel { width: min(560px, 100%); display: flex; flex-direction: column; gap: 1.5rem; }
.gesture-title { font-size: 1.25rem; font-weight: 700; color: #f4f4f5; }
.gesture-desc { font-size: 0.8rem; color: #52525b; margin-top: 0.25rem; }
.gesture-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #52525b; }
.hint { font-size: 0.7rem; color: #3f3f46; text-align: center; }
.hover-box {
width: 100px; height: 100px; border-radius: 1rem;
background: linear-gradient(135deg, #6d28d9, #a78bfa);
display: grid; place-items: center; font-size: 1.75rem;
cursor: pointer; will-change: transform;
}
.tap-box {
width: 100px; height: 100px; border-radius: 1rem;
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
display: grid; place-items: center; font-size: 1.75rem;
cursor: pointer; user-select: none; will-change: transform;
}
.drag-area {
width: 100%; height: 120px;
background: rgba(255,255,255,0.02);
border: 1px dashed rgba(255,255,255,0.1);
border-radius: 0.75rem;
position: relative; overflow: hidden;
}
.drag-box {
width: 48px; height: 48px; border-radius: 0.75rem;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
cursor: grab; will-change: transform; touch-action: none;
display: grid; place-items: center; font-size: 1.2rem;
}
.glow-wrap { width: 100%; position: relative; border-radius: 0.75rem; }
.glow-ring {
position: absolute; inset: -2px; border-radius: 0.875rem;
opacity: 0; pointer-events: none;
background: conic-gradient(from 0deg, #ec4899, #8b5cf6, #6366f1, #0ea5e9, #10b981, #ec4899);
filter: blur(8px); will-change: opacity;
}
.glow-input {
width: 100%; padding: 0.75rem 1rem; font-size: 0.85rem;
font-family: inherit;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.75rem; color: #e4e4e7; outline: none;
position: relative; z-index: 1;
}
</style>Gesture Animations
A showcase of four distinct gesture-driven animations, each responding to a different user interaction.
Gestures included
- Hover Scale โ element smoothly scales up on
mouseenterand back onmouseleave - Tap Shrink โ element shrinks on
pointerdownand bounces back onpointerup - Drag with Constraints โ element follows the pointer within a bounding box, snaps back on release
- Focus Glow โ input gains an animated glow ring on
focus, fades onblur
How it works
- All animations use
requestAnimationFramewith lerp for smooth interpolation - No CSS transitions needed โ JS drives every frame for precise, interruptible control
- Drag constraints are enforced by clamping coordinates to a parent containerโs bounds
Use cases
- Interactive buttons and cards
- Micro-interactions for form elements
- Playful UI elements in portfolios / landing pages