09여러 물성을 한 보드에 모은 드래그 히어로 장면
01 격자 자석, 03 고무 경계, 06 관성 플링을 한 보드 위에 동시에 배치해 카드를 옮기고 던지는 완성 장면을 만든다.
쓰임칸반 보드 랜딩 섹션
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="자석 칸에 붙거나, 경계에서 눌리거나, 던지면 튕기며 날아가는 아이콘. Enter 로 잡고 화살표로 이동, Enter 로 놓습니다"></button>
</div>
<p class="hb__caption" id="hbCaption">자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요</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 = '자석 칸에 붙어요';
} 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 = '경계에서 고무처럼 눌려요';
} else {
pos.x = rawX; pos.y = rawY;
applyIcon(1, 1);
caption.textContent = '놓으면 속도대로 던져져요';
}
}
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 = '자석 칸에 붙었어요';
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 = '경계 밖이라 안으로 되돌아가요';
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 = '자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요';
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 = '잡았어요. 화살표로 옮기고 Enter 로 놓으세요';
} 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 여러 물성을 한 보드에 모은 드래그 히어로 장면 — 측정 누적면적 8.088% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산
이 데모가 속한 글
드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산드래그 앤 드롭 구현이라는 말은 보통 잡고 옮기는 동작 하나를 가리키지만, 여기 아홉 개는 손을 뗀 뒤까지 계산이 이어진다. 자석 격자가 24px 안쪽에서 아이콘을 붙잡고, 고무줄처럼 늘어난 경계가 그 손을 다시 놓 이 글의 데모 전부 보기


