09Hero Scene Combining Several Physics Patterns on One Board
Item 01's grid magnet, item 03's rubber boundary, and item 06's momentum fling all run together on one board where cards are moved and flung.
Best forKanban-board landing section
HERO_GRID_MAGNET_SNAP_PXHERO_BOUNDARY_RUBBER_MAX_PXHERO_FLING_DECAY_RATE

<main class="stage">
<div class="hb is-demo" id="hb">
<div class="hb__board" id="hbBoard">
<div class="hb__cell" id="hbCellA" style="left:10%;top:12%;"></div>
<div class="hb__cell" id="hbCellB" style="right:10%;bottom:12%;"></div>
<button type="button" class="hb__icon" id="hbIcon" aria-label="An icon that snaps to a magnet cell, squishes at the boundary, or flies off bouncing when thrown. Press Enter to grab, arrow keys to move, Enter to drop"></button>
</div>
<p class="hb__caption" id="hbCaption">Try the magnet cell, rubber boundary, and throw inertia all on one board</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hb {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.hb__board {
position: relative;
width: 100%;
max-width: 300px;
height: 200px;
border-radius: $r-card;
border: 1px dashed rgba($subject-ink, .22);
background: rgba($subject-ink, .04);
overflow: hidden;
}
.hb__cell {
position: absolute;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: $r-sm;
border: 2px dashed rgba($subject-blue, .5);
transition: background-color .15s ease-out, border-color .15s ease-out;
}
.hb__cell.is-target {
background: rgba($subject-blue, .18);
border-color: $subject-blue;
border-style: solid;
}
.hb__icon {
appearance: none;
border: 0;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: 0 6px 14px rgba($subject-ink, .18);
touch-action: none;
cursor: grab;
transform-origin: center;
will-change: transform;
}
.hb.is-dragging .hb__icon { cursor: grabbing; }
.hb__icon.is-grabbed {
box-shadow: 0 0 0 3px $subject-blue-light, 0 6px 14px rgba($subject-ink, .18);
}
.hb__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.hb__caption {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
min-height: 2.8em;
}
@media (max-width: 340px) {
.hb { gap: $sp-2; }
.hb__board { max-width: 200px; height: 110px; }
.hb__cell { width: 30px; height: 30px; margin: -15px 0 0 -15px; }
.hb__icon { width: 30px; height: 30px; margin: -15px 0 0 -15px; }
.hb__caption { font-size: 10px; min-height: 1.8em; }
}
@media (prefers-reduced-motion: reduce) {
.hb__cell { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hb {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.hb__board {
position: relative;
width: 100%;
max-width: 300px;
height: 200px;
border-radius: 16px;
border: 1px dashed rgba(23, 20, 26, 0.22);
background: rgba(23, 20, 26, 0.04);
overflow: hidden;
}
.hb__cell {
position: absolute;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: 8px;
border: 2px dashed rgba(47, 109, 246, 0.5);
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
}
.hb__cell.is-target {
background: rgba(47, 109, 246, 0.18);
border-color: #2f6df6;
border-style: solid;
}
.hb__icon {
appearance: none;
border: 0;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 6px 14px rgba(23, 20, 26, 0.18);
touch-action: none;
cursor: grab;
transform-origin: center;
will-change: transform;
}
.hb.is-dragging .hb__icon {
cursor: grabbing;
}
.hb__icon.is-grabbed {
box-shadow: 0 0 0 3px #5b8cf8, 0 6px 14px rgba(23, 20, 26, 0.18);
}
.hb__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.hb__caption {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
min-height: 2.8em;
}
@media (max-width: 340px) {
.hb {
gap: 8px;
}
.hb__board {
max-width: 200px;
height: 110px;
}
.hb__cell {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
}
.hb__icon {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
}
.hb__caption {
font-size: 10px;
min-height: 1.8em;
}
}
@media (prefers-reduced-motion: reduce) {
.hb__cell {
transition: none;
}
}(function () {
// 기법: 01(격자 자석)·03(경계 고무)·06(관성 플링)의 계산을 한 아이콘에 합쳐,
// 자석 칸 근처면 스냅, 보드 경계 밖이면 클램프+스퀴시, 그 외에는 마지막 속도로
// 관성 플링+벽 반사를 적용한다. 우선순위: 자석 > 경계 > 플링.
var root = document.getElementById('hb');
var board = document.getElementById('hbBoard');
var icon = document.getElementById('hbIcon');
var caption = document.getElementById('hbCaption');
var cells = [document.getElementById('hbCellA'), document.getElementById('hbCellB')];
var HERO_GRID_MAGNET_SNAP_PX = 26;
var HERO_BOUNDARY_RUBBER_MAX_PX = 36;
var HERO_FLING_DECAY_RATE = 0.93;
var HERO_SPRING_STIFFNESS = 190;
var HERO_SPRING_DAMPING = 19;
var KEY_STEP_PX = 16;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var origin = null;
var pointerStart = null;
var startPos = null;
var lastSample = null;
var dragging = false;
var grabbed = false;
var mode = 'idle';
var raf = 0, lastTs = null;
function iconOrigin() {
if (origin) return origin;
var r = icon.getBoundingClientRect();
origin = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return origin;
}
function boardBounds() {
var br = board.getBoundingClientRect();
var ir = icon.getBoundingClientRect();
var halfW = ir.width / 2, halfH = ir.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function nearestCell(cx, cy) {
var best = null, bestD = Infinity;
cells.forEach(function (cell) {
var cr = cell.getBoundingClientRect();
var ccx = cr.left + cr.width / 2, ccy = cr.top + cr.height / 2;
var d = Math.hypot(cx - ccx, cy - ccy);
if (d < bestD) { bestD = d; best = { cell: cell, x: ccx, y: ccy, dist: d }; }
});
return best;
}
function applyIcon(scaleX, scaleY) {
var sx = scaleX === undefined ? 1 : scaleX;
var sy = scaleY === undefined ? 1 : scaleY;
icon.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root.classList.add('is-dragging');
cells.forEach(function (c) { c.classList.remove('is-target'); });
}
function moveDrag(clientX, clientY) {
var rawX = startPos.x + (clientX - pointerStart.x);
var rawY = startPos.y + (clientY - pointerStart.y);
var o = iconOrigin();
var cx = o.x + rawX, cy = o.y + rawY;
var near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove('is-target'); });
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
near.cell.classList.add('is-target');
var pull = Math.max(0, 1 - near.dist / HERO_GRID_MAGNET_SNAP_PX) * 0.6;
pos.x = rawX + ((near.x - o.x) - rawX) * pull;
pos.y = rawY + ((near.y - o.y) - rawY) * pull;
applyIcon(1, 1);
caption.textContent = 'Snapping to the magnet cell';
} else {
var b = boardBounds();
var overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
var clampedX = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overX));
var clampedY = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overY));
pos.x = rawX - (overX - clampedX);
pos.y = rawY - (overY - clampedY);
var ratio = Math.max(Math.abs(clampedX), Math.abs(clampedY)) / HERO_BOUNDARY_RUBBER_MAX_PX;
applyIcon(1 + ratio * 0.22, 1 - ratio * 0.18);
caption.textContent = 'Squishing like rubber at the boundary';
} else {
pos.x = rawX; pos.y = rawY;
applyIcon(1, 1);
caption.textContent = 'Release it and it flies off at that speed';
}
}
var now = performance.now();
var dt = Math.max((now - lastSample.t) / 1000, 0.001);
vel.x = (clientX - lastSample.x) / dt;
vel.y = (clientY - lastSample.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function settleSpring(target) {
mode = 'spring';
lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (target.x - pos.x) * HERO_SPRING_STIFFNESS - vel.x * HERO_SPRING_DAMPING;
var fy = (target.y - pos.y) * HERO_SPRING_STIFFNESS - vel.y * HERO_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon(1, 1);
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(target.x - pos.x, target.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(tick); return; }
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
applyIcon(1, 1);
mode = 'idle';
}
raf = requestAnimationFrame(tick);
}
function flingSettle() {
mode = 'fling';
lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt; pos.y += vel.y * dt;
var o = iconOrigin();
var b = boardBounds();
var cx = o.x + pos.x, cy = o.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); }
vel.x *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
applyIcon(1, 1);
var speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(tick); return; }
vel.x = 0; vel.y = 0;
mode = 'idle';
}
raf = requestAnimationFrame(tick);
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var o = iconOrigin();
var cx = o.x + pos.x, cy = o.y + pos.y;
var near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove('is-target'); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
caption.textContent = 'Snapped to the magnet cell';
var target = { x: near.x - o.x, y: near.y - o.y };
if (reduce) { pos.x = target.x; pos.y = target.y; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring(target); }
return;
}
var b = boardBounds();
var overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
caption.textContent = 'It\'s outside the boundary, so it springs back in';
var clampX = Math.max(b.minX, Math.min(b.maxX, cx)) - o.x;
var clampY = Math.max(b.minY, Math.min(b.maxY, cy)) - o.y;
if (reduce) { pos.x = clampX; pos.y = clampY; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring({ x: clampX, y: clampY }); }
return;
}
caption.textContent = 'Try the magnet cell, rubber boundary, and throw inertia all on one board';
if (reduce || Math.hypot(vel.x, vel.y) < 30) { vel.x = 0; vel.y = 0; }
else { cancelAnimationFrame(raf); flingSettle(); }
}
icon.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
icon.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
e.preventDefault();
});
icon.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!grabbed) {
grabbed = true;
cancelAnimationFrame(raf);
icon.classList.add('is-grabbed');
caption.textContent = 'Grabbed. Move with arrow keys, press Enter to drop';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
endDrag();
}
return;
}
if (!grabbed) return;
var dx = 0, dy = 0;
if (e.key === 'ArrowLeft') dx = -KEY_STEP_PX;
else if (e.key === 'ArrowRight') dx = KEY_STEP_PX;
else if (e.key === 'ArrowUp') dy = -KEY_STEP_PX;
else if (e.key === 'ArrowDown') dy = KEY_STEP_PX;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
icon.classList.remove('is-grabbed');
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon(1, 1);
});
applyIcon(1, 1);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var iconRect0 = icon.getBoundingClientRect();
var ox0 = iconRect0.left + iconRect0.width / 2;
var oy0 = iconRect0.top + iconRect0.height / 2;
var cellRect0 = cells[0].getBoundingClientRect();
var gx0 = cellRect0.left + cellRect0.width / 2;
var gy0 = cellRect0.top + cellRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 800) {
var p = Math.min(1, (t - 200) / 600);
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (gx0 - ox0) * ease;
var cy = oy0 + (gy0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 950) {
// 대기 (자석 확인)
} else if (t < 980) {
endDrag();
} else if (t < 1400) {
// 정착 관찰
} else if (t < 1420) {
beginDrag(gx0, gy0);
} else if (t < 1650) {
var p2 = (t - 1420) / 230;
moveDrag(gx0 + p2 * 70, gy0 - p2 * 20);
} else if (t < 1680) {
endDrag();
} else {
if (t > 2200) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysHeroBoard.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysHeroBoard() {
const rootRef = useRef<HTMLDivElement>(null);
const boardRef = useRef<HTMLDivElement>(null);
const cellARef = useRef<HTMLDivElement>(null);
const cellBRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const board = boardRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
const cells = [cellARef.current, cellBRef.current].filter(Boolean) as HTMLDivElement[];
if (!root || !board || !icon || !caption || cells.length !== 2) return;
const HERO_GRID_MAGNET_SNAP_PX = 26;
const HERO_BOUNDARY_RUBBER_MAX_PX = 36;
const HERO_FLING_DECAY_RATE = 0.93;
const HERO_SPRING_STIFFNESS = 190;
const HERO_SPRING_DAMPING = 19;
const KEY_STEP_PX = 16;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let origin: Pt | null = null;
let pointerStart: Pt | null = null;
let startPos: Pt | null = null;
let lastSample: { x: number; y: number; t: number } | null = null;
let dragging = false;
let grabbed = false;
let raf = 0;
let lastTs: number | null = null;
function iconOrigin(): Pt {
if (origin) return origin;
const r = icon!.getBoundingClientRect();
origin = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return origin;
}
function boardBounds() {
const br = board!.getBoundingClientRect();
const ir = icon!.getBoundingClientRect();
const halfW = ir.width / 2, halfH = ir.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
type NearestCell = { cell: HTMLDivElement; x: number; y: number; dist: number };
function nearestCell(cx: number, cy: number): NearestCell | null {
let best: NearestCell | null = null;
let bestD = Infinity;
cells.forEach(function (cell) {
const cr = cell.getBoundingClientRect();
const ccx = cr.left + cr.width / 2, ccy = cr.top + cr.height / 2;
const d = Math.hypot(cx - ccx, cy - ccy);
if (d < bestD) { bestD = d; best = { cell: cell, x: ccx, y: ccy, dist: d }; }
});
return best;
}
function applyIcon(scaleX?: number, scaleY?: number) {
const sx = scaleX === undefined ? 1 : scaleX;
const sy = scaleY === undefined ? 1 : scaleY;
icon!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root!.classList.add(styles.isDragging);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
}
function moveDrag(clientX: number, clientY: number) {
const rawX = startPos!.x + (clientX - pointerStart!.x);
const rawY = startPos!.y + (clientY - pointerStart!.y);
const o = iconOrigin();
const cx = o.x + rawX, cy = o.y + rawY;
const near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
near.cell.classList.add(styles.isTarget);
const pull = Math.max(0, 1 - near.dist / HERO_GRID_MAGNET_SNAP_PX) * 0.6;
pos.x = rawX + ((near.x - o.x) - rawX) * pull;
pos.y = rawY + ((near.y - o.y) - rawY) * pull;
applyIcon(1, 1);
caption!.textContent = "자석 칸에 붙어요";
} else {
const b = boardBounds();
let overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
const clampedX = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overX));
const clampedY = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overY));
pos.x = rawX - (overX - clampedX);
pos.y = rawY - (overY - clampedY);
const ratio = Math.max(Math.abs(clampedX), Math.abs(clampedY)) / HERO_BOUNDARY_RUBBER_MAX_PX;
applyIcon(1 + ratio * 0.22, 1 - ratio * 0.18);
caption!.textContent = "경계에서 고무처럼 눌려요";
} else {
pos.x = rawX; pos.y = rawY;
applyIcon(1, 1);
caption!.textContent = "놓으면 속도대로 던져져요";
}
}
const now = performance.now();
const dt = Math.max((now - lastSample!.t) / 1000, 0.001);
vel.x = (clientX - lastSample!.x) / dt;
vel.y = (clientY - lastSample!.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function settleSpring(target: Pt) {
lastTs = null;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (target.x - pos.x) * HERO_SPRING_STIFFNESS - vel.x * HERO_SPRING_DAMPING;
const fy = (target.y - pos.y) * HERO_SPRING_STIFFNESS - vel.y * HERO_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon(1, 1);
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(target.x - pos.x, target.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(tick); return; }
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
applyIcon(1, 1);
}
raf = requestAnimationFrame(tick);
}
function flingSettle() {
lastTs = null;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt; pos.y += vel.y * dt;
const o = iconOrigin();
const b = boardBounds();
const cx = o.x + pos.x, cy = o.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); }
vel.x *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
applyIcon(1, 1);
const speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(tick); return; }
vel.x = 0; vel.y = 0;
}
raf = requestAnimationFrame(tick);
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const o = iconOrigin();
const cx = o.x + pos.x, cy = o.y + pos.y;
const near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
caption!.textContent = "자석 칸에 붙었어요";
const target = { x: near.x - o.x, y: near.y - o.y };
if (reduce) { pos.x = target.x; pos.y = target.y; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring(target); }
return;
}
const b = boardBounds();
let overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
caption!.textContent = "경계 밖이라 안으로 되돌아가요";
const clampX = Math.max(b.minX, Math.min(b.maxX, cx)) - o.x;
const clampY = Math.max(b.minY, Math.min(b.maxY, cy)) - o.y;
if (reduce) { pos.x = clampX; pos.y = clampY; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring({ x: clampX, y: clampY }); }
return;
}
caption!.textContent = "자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요";
if (reduce || Math.hypot(vel.x, vel.y) < 30) { vel.x = 0; vel.y = 0; }
else { cancelAnimationFrame(raf); flingSettle(); }
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
icon!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
e.preventDefault();
}
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!grabbed) {
grabbed = true;
cancelAnimationFrame(raf);
icon!.classList.add(styles.isGrabbed);
caption!.textContent = "잡았어요. 화살표로 옮기고 Enter 로 놓으세요";
} else {
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
endDrag();
}
return;
}
if (!grabbed) return;
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") dx = -KEY_STEP_PX;
else if (e.key === "ArrowRight") dx = KEY_STEP_PX;
else if (e.key === "ArrowUp") dy = -KEY_STEP_PX;
else if (e.key === "ArrowDown") dy = KEY_STEP_PX;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon(1, 1);
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
icon.addEventListener("keydown", onKeyDown);
applyIcon(1, 1);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const iconRect0 = icon.getBoundingClientRect();
const ox0 = iconRect0.left + iconRect0.width / 2;
const oy0 = iconRect0.top + iconRect0.height / 2;
const cellRect0 = cells[0].getBoundingClientRect();
const gx0 = cellRect0.left + cellRect0.width / 2;
const gy0 = cellRect0.top + cellRect0.height / 2;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 800) {
const p = Math.min(1, (t - 200) / 600);
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (gx0 - ox0) * ease;
const cy = oy0 + (gy0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 950) {
// 대기
} else if (t < 980) {
endDrag();
} else if (t < 1400) {
// 정착 관찰
} else if (t < 1420) {
beginDrag(gx0, gy0);
} else if (t < 1650) {
const p2 = (t - 1420) / 230;
moveDrag(gx0 + p2 * 70, gy0 - p2 * 20);
} else if (t < 1680) {
endDrag();
} else {
if (t > 2200) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
icon.removeEventListener("pointerdown", onPointerDown);
icon.removeEventListener("pointermove", onPointerMove);
icon.removeEventListener("pointerup", onPointerUp);
icon.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.hb} is-demo`} ref={rootRef}>
<div className={styles.board} ref={boardRef}>
<div className={styles.cell} ref={cellARef} style={{ left: "10%", top: "12%" }}></div>
<div className={styles.cell} ref={cellBRef} style={{ right: "10%", bottom: "12%" }}></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="자석 칸에 붙거나, 경계에서 눌리거나, 던지면 튕기며 날아가는 아이콘. Enter 로 잡고 화살표로 이동, Enter 로 놓습니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요</p>
</div>
</main>
);
}09 Hero Scene Combining Several Physics Patterns on One Board — Measured area 8.088% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop Animation CSS: 9 Physics Patterns
The post this demo lives in
Drag and Drop Animation CSS: 9 Physics PatternsNine drag and drop animation css builds: A magnetic grid catches the icon inside a 24px radius, a rubber-band edge lets See all demos in this post
If you like this effect - similar demos
drag09 A Hero Scene Where the Puzzle Assembles ItselfJigsaw Puzzle JavaScript: 9 Snapping Tab Boards
drag01 FLIP list — computing the before/after offset and releasing it into a transitionDrag to Reorder UI: 9 Lists, One Drop Index
drag03 A Table Whose Neighbor Column Absorbs the Width You DragCSS Resize Handle: 9 Demos That Follow Your Grip