드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산
드래그 앤 드롭 구현이라는 말은 보통 잡고 옮기는 동작 하나를 가리키지만, 여기 아홉 개는 손을 뗀 뒤까지 계산이 이어진다. 자석 격자가 24px 안쪽에서 아이콘을 붙잡고, 고무줄처럼 늘어난 경계가 그 손을 다시 놓아주며, 손을 뗀 순간의 속도가 나머지 거리를 밀고 간다.
01 drag-phys-grid-magnet
02 drag-phys-elastic-tether
03 drag-phys-boundary-rubber
04 drag-phys-swap-neighbor
05 drag-phys-orbit-handle
06 drag-phys-momentum-fling
07 drag-phys-trash-shake
08 drag-phys-lasso-select
09 drag-phys-hero-board-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 거리 안에 들어오면 칸이 끌어당기는 격자 자석 드래그
- 02 원점과 줄로 이어진 듯 늘어나는 탄성 테더 드래그
- 03 경계 밖으로 밀면 늘어났다 되돌아오는 고무 바운더리
- 04 이웃 중간점을 지나면 자리를 맞바꾸는 스왑 드래그
- 05 중심을 기준으로 각도만 바뀌는 궤도 핸들 드래그
- 06 놓는 속도가 남은 거리를 미는 관성 플링 드래그
- 07 삭제 영역 위에서 떨리는 경고를 보이는 트래시 셰이크
- 08 끌어당긴 사각형 안쪽만 고르는 라쏘 셀렉트 드래그
- 09 여러 물성을 한 보드에 모은 드래그 히어로 장면
드래그 앤 드롭 구현 9가지 — 손을 뗀 뒤에도 계속되는 계산
아홉 항목 전부 포인터 이벤트 표준의 pointerdown·pointermove·pointerup 을 공유한다(HTML5 드래그 앤 드롭 API 는 어디에도 쓰지 않는다). 그 위에서 포인터 위치와 주변 요소를 매 프레임 다시 비교하는 쪽(01·03·04·08), 삭제 영역 위에 머문 시간을 재는 쪽(07), 손을 놓는 순간의 값으로 계산을 이어가는 쪽(02·05·06)으로 나뉘고, 09 는 이 중 세 계산을 한 보드 위에 그대로 얹었다.
패턴별 코드
항목마다 물리 계산이 걸린 함수 구간만 소스에서 그대로 떼어 왔고, 마크업·스타일 전체는 데모 아래 압축 파일 안에 들어 있다.
01거리 안에 들어오면 칸이 끌어당기는 격자 자석 드래그
아이콘을 드래그하면 매 프레임 nearestCell 이 각 격자 칸과의 거리를 계산해 GRID_MAGNET_SNAP_PX(24px) 안으로 들어온 칸에 강조 표시를 건다. 손을 놓으면 springTo 가 settleTick 을 돌려 SPRING_STIFFNESS·SPRING_DAMPING 값으로 목표 칸까지 스프링처럼 당겨 붙인다. 01 항목은 누적면적 14.1%·강도 평균 177.0·23프레임 중 20프레임에서 움직였다.

<main class="stage">
<div class="gm is-demo" id="gm">
<div class="gm__grid" id="gmGrid">
<div class="gm__cell"></div>
<div class="gm__cell"></div>
<div class="gm__cell"></div>
<div class="gm__cell"></div>
<div class="gm__cell"></div>
<div class="gm__cell"></div>
</div>
<div class="gm__tray">
<button type="button" class="gm__icon" id="gmIcon" aria-label="드래그해서 격자 칸에 놓는 아이콘. Enter 로 잡고 화살표로 이동, Enter 로 다시 놓습니다"></button>
</div>
<p class="gm__caption" id="gmCaption">칸 가까이 놓으면 자석처럼 붙어요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.gm {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.gm__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
width: 140px;
flex: 0 0 auto;
}
.gm__cell {
aspect-ratio: 1 / 1;
border-radius: $r-sm;
border: 2px dashed rgba($subject-cream, .32);
transition: border-color $dur-quick $ease-out, background-color $dur-quick $ease-out;
}
.gm__cell.is-target {
border-color: $subject-cream;
border-style: solid;
background: rgba($subject-cream, .12);
}
.gm__tray {
display: flex;
justify-content: center;
flex: 0 0 auto;
}
.gm__icon {
appearance: none;
border: 0;
width: 58px;
height: 58px;
border-radius: $r-control;
background: $subject-blue;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.gm__icon.is-grabbed {
box-shadow: $shadow-press;
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.gm__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.gm.is-dragging .gm__icon {
cursor: grabbing;
}
.gm__caption {
flex-basis: 100%;
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.gm__grid { width: 110px; }
.gm__icon { width: 44px; height: 44px; }
.gm__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.gm__icon { transition: none; }
.gm__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gm {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.gm__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
width: 140px;
flex: 0 0 auto;
}
.gm__cell {
aspect-ratio: 1/1;
border-radius: 8px;
border: 2px dashed rgba(255, 247, 230, 0.32);
transition: border-color 220ms ease-out, background-color 220ms ease-out;
}
.gm__cell.is-target {
border-color: #fff7e6;
border-style: solid;
background: rgba(255, 247, 230, 0.12);
}
.gm__tray {
display: flex;
justify-content: center;
flex: 0 0 auto;
}
.gm__icon {
appearance: none;
border: 0;
width: 58px;
height: 58px;
border-radius: 12px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.gm__icon.is-grabbed {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.gm__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.gm.is-dragging .gm__icon {
cursor: grabbing;
}
.gm__caption {
flex-basis: 100%;
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.gm__grid {
width: 110px;
}
.gm__icon {
width: 44px;
height: 44px;
}
.gm__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gm__icon {
transition: none;
}
.gm__cell {
transition: none;
}
}(function () {
// 기법: 드래그 중인 아이콘 중심과 각 격자 칸 중심 사이의 거리를 매 프레임 계산해
// GRID_MAGNET_SNAP_PX 안으로 들어오면 목표 위치를 칸 중심으로 섞어(당김) 자석처럼
// 붙이고, 손을 떼는 순간엔 감쇠 스프링으로 정확히 칸 중심(또는 원래 자리)에 정착시킨다.
var root = document.getElementById('gm');
var grid = document.getElementById('gmGrid');
var icon = document.getElementById('gmIcon');
var caption = document.getElementById('gmCaption');
var cells = Array.prototype.slice.call(grid.querySelectorAll('.gm__cell'));
var GRID_MAGNET_SNAP_PX = 24;
var KEY_STEP_PX = 16;
var SPRING_STIFFNESS = 180;
var SPRING_DAMPING = 18;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var settleTarget = null;
var settling = false;
var grabbed = false;
var pointerStart = null;
var startPos = null;
var targetCell = null;
function applyPos() {
icon.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px)';
}
function nearestCell() {
var r = icon.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var best = null, bestD = Infinity, bestOffset = null;
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;
bestOffset = { x: pos.x + (ccx - cx), y: pos.y + (ccy - cy) };
}
});
return { cell: best, dist: bestD, offset: bestOffset };
}
function updateMagnetHighlight(rawX, rawY) {
var near = nearestCell();
cells.forEach(function (c) { c.classList.remove('is-target'); });
if (near.dist < GRID_MAGNET_SNAP_PX) {
near.cell.classList.add('is-target');
targetCell = near;
var pull = Math.max(0, 1 - near.dist / GRID_MAGNET_SNAP_PX) * 0.55;
pos.x = rawX + (near.offset.x - rawX) * pull;
pos.y = rawY + (near.offset.y - rawY) * pull;
} else {
targetCell = null;
pos.x = rawX; pos.y = rawY;
}
applyPos();
}
var raf = 0, lastTs = null;
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (settleTarget.x - pos.x) * SPRING_STIFFNESS - vel.x * SPRING_DAMPING;
var fy = (settleTarget.y - pos.y) * SPRING_STIFFNESS - vel.y * SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyPos();
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(settleTarget.x - pos.x, settleTarget.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = settleTarget.x; pos.y = settleTarget.y; vel.x = 0; vel.y = 0;
applyPos();
settling = false;
lastTs = null;
}
function springTo(target) {
cancelAnimationFrame(raf);
settleTarget = target;
settling = true;
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function releaseDrag() {
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var near = nearestCell();
cells.forEach(function (c) { c.classList.remove('is-target'); });
var goal = (near.dist < GRID_MAGNET_SNAP_PX) ? near.offset : { x: 0, y: 0 };
if (near.dist < GRID_MAGNET_SNAP_PX) {
caption.textContent = '칸에 붙었어요';
root.classList.add('is-snapped');
} else {
caption.textContent = '칸 가까이 놓으면 자석처럼 붙어요';
root.classList.remove('is-snapped');
}
if (reduce) {
pos.x = goal.x; pos.y = goal.y; applyPos();
} else {
springTo(goal);
}
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
settling = false;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root.classList.add('is-dragging');
}
function moveDrag(clientX, clientY) {
var rawX = startPos.x + (clientX - pointerStart.x);
var rawY = startPos.y + (clientY - pointerStart.y);
updateMagnetHighlight(rawX, rawY);
}
function endDrag() {
root.classList.remove('is-dragging');
releaseDrag();
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!pointerStart) return;
pointerStart = null;
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);
settling = false;
icon.classList.add('is-grabbed');
caption.textContent = '잡았어요. 화살표로 옮기고 Enter 로 놓으세요';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
releaseDrag();
}
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');
springTo({ x: 0, y: 0 });
caption.textContent = '칸 가까이 놓으면 자석처럼 붙어요';
return;
} else { return; }
e.preventDefault();
updateMagnetHighlight(pos.x + dx, pos.y + dy);
});
// is-demo 자동 시연 — 실제 드래그와 같은 begin/move/end 함수를 가상 좌표로 호출한다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var iconRect0 = icon.getBoundingClientRect();
var originX = iconRect0.left + iconRect0.width / 2;
var originY = iconRect0.top + iconRect0.height / 2;
var cellRect0 = cells[1].getBoundingClientRect();
var goalX = cellRect0.left + cellRect0.width / 2;
var goalY = 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 < 900) {
var p = Math.min(1, (t - 200) / 700);
var ease = 1 - Math.pow(1 - p, 3);
var cx = originX + (goalX - originX) * ease;
var cy = originY + (goalY - originY) * ease;
if (t < 220) beginDrag(originX, originY);
moveDrag(cx, cy);
} else if (t < 950) {
// 짧게 머물러 자석 당김을 보여준다
} else if (t < 970) {
endDrag();
} else if (t < 1500) {
// 정착 스프링 관찰
} else if (t < 2200) {
var p2 = Math.min(1, (t - 1500) / 600);
var ease2 = 1 - Math.pow(1 - p2, 3);
var cx2 = goalX + (originX - goalX) * ease2 * 0.7;
var cy2 = goalY + (originY - goalY) * ease2 * 0.7;
if (t < 1520) beginDrag(goalX, goalY);
moveDrag(cx2, cy2);
} else if (t < 2260) {
endDrag();
} else {
demoStart = ts - 60; // 루프
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysGridMagnet.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysGridMagnet() {
const rootRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const grid = gridRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !grid || !icon || !caption) return;
const cells = Array.prototype.slice.call(grid.querySelectorAll("." + styles.cell)) as HTMLDivElement[];
const GRID_MAGNET_SNAP_PX = 24;
const KEY_STEP_PX = 16;
const SPRING_STIFFNESS = 180;
const SPRING_DAMPING = 18;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let settleTarget: Pt = { x: 0, y: 0 };
let grabbed = false;
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
function applyPos() {
icon!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px)";
}
type NearestCell = { cell: HTMLDivElement | null; dist: number; offset: Pt };
function nearestCell(): NearestCell {
const r = icon!.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
let best: HTMLDivElement | null = null;
let bestD = Infinity;
let bestOffset: Pt = { x: 0, y: 0 };
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;
bestOffset = { x: pos.x + (ccx - cx), y: pos.y + (ccy - cy) };
}
});
return { cell: best, dist: bestD, offset: bestOffset };
}
function updateMagnetHighlight(rawX: number, rawY: number) {
const near = nearestCell();
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
if (near.dist < GRID_MAGNET_SNAP_PX && near.cell) {
near.cell.classList.add(styles.isTarget);
const pull = Math.max(0, 1 - near.dist / GRID_MAGNET_SNAP_PX) * 0.55;
pos.x = rawX + (near.offset.x - rawX) * pull;
pos.y = rawY + (near.offset.y - rawY) * pull;
} else {
pos.x = rawX; pos.y = rawY;
}
applyPos();
}
let raf = 0, lastTs: number | null = null;
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (settleTarget.x - pos.x) * SPRING_STIFFNESS - vel.x * SPRING_DAMPING;
const fy = (settleTarget.y - pos.y) * SPRING_STIFFNESS - vel.y * SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyPos();
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(settleTarget.x - pos.x, settleTarget.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = settleTarget.x; pos.y = settleTarget.y; vel.x = 0; vel.y = 0;
applyPos();
lastTs = null;
}
function springTo(target: Pt) {
cancelAnimationFrame(raf);
settleTarget = target;
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function releaseDrag() {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const near = nearestCell();
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
const goal = near.dist < GRID_MAGNET_SNAP_PX ? near.offset : { x: 0, y: 0 };
if (near.dist < GRID_MAGNET_SNAP_PX) {
caption!.textContent = "칸에 붙었어요";
root!.classList.add(styles.isSnapped);
} else {
caption!.textContent = "칸 가까이 놓으면 자석처럼 붙어요";
root!.classList.remove(styles.isSnapped);
}
if (reduce) { pos.x = goal.x; pos.y = goal.y; applyPos(); } else { springTo(goal); }
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root!.classList.add(styles.isDragging);
}
function moveDrag(clientX: number, clientY: number) {
const rawX = startPos.x + (clientX - pointerStart!.x);
const rawY = startPos.y + (clientY - pointerStart!.y);
updateMagnetHighlight(rawX, rawY);
}
function endDrag() {
root!.classList.remove(styles.isDragging);
releaseDrag();
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!pointerStart) return;
pointerStart = null;
endDrag();
e.preventDefault();
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
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);
releaseDrag();
}
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);
springTo({ x: 0, y: 0 });
caption!.textContent = "칸 가까이 놓으면 자석처럼 붙어요";
return;
} else { return; }
e.preventDefault();
updateMagnetHighlight(pos.x + dx, pos.y + dy);
}
icon.addEventListener("keydown", onKeyDown);
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 originX = iconRect0.left + iconRect0.width / 2;
const originY = iconRect0.top + iconRect0.height / 2;
const cellRect0 = cells[1].getBoundingClientRect();
const goalX = cellRect0.left + cellRect0.width / 2;
const goalY = 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 < 900) {
const p = Math.min(1, (t - 200) / 700);
const ease = 1 - Math.pow(1 - p, 3);
const cx = originX + (goalX - originX) * ease;
const cy = originY + (goalY - originY) * ease;
if (t < 220) beginDrag(originX, originY);
moveDrag(cx, cy);
} else if (t < 950) {
// 대기
} else if (t < 970) {
endDrag();
} else if (t < 1500) {
// 대기
} else if (t < 2200) {
const p2 = Math.min(1, (t - 1500) / 600);
const ease2 = 1 - Math.pow(1 - p2, 3);
const cx2 = goalX + (originX - goalX) * ease2 * 0.7;
const cy2 = goalY + (originY - goalY) * ease2 * 0.7;
if (t < 1520) beginDrag(goalX, goalY);
moveDrag(cx2, cy2);
} else if (t < 2260) {
endDrag();
} else {
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.gm} is-demo`} ref={rootRef}>
<div className={styles.grid} ref={gridRef}>
<div className={styles.cell}></div>
<div className={styles.cell}></div>
<div className={styles.cell}></div>
<div className={styles.cell}></div>
<div className={styles.cell}></div>
<div className={styles.cell}></div>
</div>
<div className={styles.tray}>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="드래그해서 격자 칸에 놓는 아이콘. Enter 로 잡고 화살표로 이동, Enter 로 다시 놓습니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>칸 가까이 놓으면 자석처럼 붙어요</p>
</div>
</main>
);
}01 · 거리 안에 들어오면 칸이 끌어당기는 격자 자석 드래그 — 측정 누적면적 14.062% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var raf = 0, lastTs = null;
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (settleTarget.x - pos.x) * SPRING_STIFFNESS - vel.x * SPRING_DAMPING;
var fy = (settleTarget.y - pos.y) * SPRING_STIFFNESS - vel.y * SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyPos();
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(settleTarget.x - pos.x, settleTarget.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = settleTarget.x; pos.y = settleTarget.y; vel.x = 0; vel.y = 0;
applyPos();
settling = false;
lastTs = null;
}
02원점과 줄로 이어진 듯 늘어나는 탄성 테더 드래그
아이콘을 원점에서 끌어낼수록 drawTether 가 두 점 사이 거리·각도를 계산해 얇은 선의 길이·각도·TETHER_THICKNESS_BASE_PX 기준 굵기를 매 프레임 다시 그린다. 손을 놓으면 settleTick 이 TETHER_SPRING_STIFFNESS·TETHER_SPRING_DAMPING 값으로 원점을 향해 스프링처럼 당겨 되돌린다. 02 항목은 누적면적 9.7%·강도 평균 39.6·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="et is-demo" id="et">
<div class="et__origin" id="etOrigin"></div>
<div class="et__line" id="etLine"></div>
<button type="button" class="et__icon" id="etIcon" aria-label="탄성 테더로 당겨지는 아이콘. Enter 로 잡고 화살표로 늘이고, Enter 로 놓으면 되돌아갑니다"></button>
<p class="et__caption" id="etCaption">멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.et {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: $sp-4;
}
.et__origin {
position: absolute;
top: 46%;
left: 38%;
width: 14px;
height: 14px;
border-radius: $r-pill;
background: $subject-cream;
transform: translate(-50%, -50%);
}
.et__line {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 50%;
border-radius: $r-pill;
background: rgba($subject-cream, .55);
pointer-events: none;
}
.et__icon {
position: absolute;
top: 46%;
left: 38%;
appearance: none;
border: 0;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.et__icon.is-grabbed,
.et__icon:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.et.is-dragging .et__icon {
cursor: grabbing;
}
.et__caption {
position: relative;
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.et { height: 160px; }
.et__icon { width: 40px; height: 40px; margin: -20px 0 0 -20px; }
.et__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.et__line { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.et {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.et__origin {
position: absolute;
top: 46%;
left: 38%;
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
}
.et__line {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 50%;
border-radius: 999px;
background: rgba(255, 247, 230, 0.55);
pointer-events: none;
}
.et__icon {
position: absolute;
top: 46%;
left: 38%;
appearance: none;
border: 0;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.et__icon.is-grabbed,
.et__icon:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.et.is-dragging .et__icon {
cursor: grabbing;
}
.et__caption {
position: relative;
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.et {
height: 160px;
}
.et__icon {
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
}
.et__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.et__line {
transition: none;
}
}(function () {
// 기법: 아이콘이 원점(origin)에서 멀어진 거리로 테더 선의 길이·각도·굵기를 매 프레임
// 계산해 그리고, 손을 떼면 감쇠 스프링(오버슛 포함)으로 원점까지 되돌아가며 그동안도
// 같은 계산으로 선이 실시간으로 짧아진다.
var root = document.getElementById('et');
var origin = document.getElementById('etOrigin');
var line = document.getElementById('etLine');
var icon = document.getElementById('etIcon');
var caption = document.getElementById('etCaption');
var TETHER_SPRING_STIFFNESS = 140;
var TETHER_SPRING_DAMPING = 12;
var TETHER_THICKNESS_BASE_PX = 5;
var KEY_STEP_PX = 18;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var grabbed = false;
var pointerStart = null;
var startPos = null;
var raf = 0, lastTs = null;
var springing = false;
function applyIcon() {
icon.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px)';
}
function drawTether() {
var or = origin.getBoundingClientRect();
var ir = icon.getBoundingClientRect();
var ox = or.left + or.width / 2, oy = or.top + or.height / 2;
var ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
var dx = ix - ox, dy = iy - oy;
var length = Math.hypot(dx, dy);
var angle = Math.atan2(dy, dx) * 180 / Math.PI;
var thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
var boardRect = root.getBoundingClientRect();
line.style.left = (ox - boardRect.left) + 'px';
line.style.top = (oy - boardRect.top) + 'px';
line.style.width = length + 'px';
line.style.height = thickness + 'px';
line.style.transform = 'rotate(' + angle + 'deg)';
line.style.opacity = length < 2 ? '0' : '1';
}
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (0 - pos.x) * TETHER_SPRING_STIFFNESS - vel.x * TETHER_SPRING_DAMPING;
var fy = (0 - pos.y) * TETHER_SPRING_STIFFNESS - vel.y * TETHER_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon();
drawTether();
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyIcon(); drawTether();
springing = false;
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
springing = true;
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
springing = false;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root.classList.add('is-dragging');
caption.textContent = '늘어나는 줄 길이·굵기가 실시간으로 계산돼요';
}
function moveDrag(clientX, clientY) {
pos.x = startPos.x + (clientX - pointerStart.x);
pos.y = startPos.y + (clientY - pointerStart.y);
applyIcon();
drawTether();
}
function endDrag() {
root.classList.remove('is-dragging');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
caption.textContent = '멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요';
if (reduce) { pos.x = 0; pos.y = 0; applyIcon(); drawTether(); }
else { springHome(); }
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!pointerStart) return;
pointerStart = null;
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();
drawTether();
});
drawTether();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var originRect0 = origin.getBoundingClientRect();
var ox0 = originRect0.left + originRect0.width / 2;
var oy0 = originRect0.top + originRect0.height / 2;
var farX = ox0 + 92, farY = oy0 - 46;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 950) {
var p = Math.min(1, (t - 200) / 750);
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (farX - ox0) * ease;
var cy = oy0 + (farY - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1150) {
// 최대 늘어남 유지
} else if (t < 1180) {
endDrag();
} else {
// 스프링 복귀 관찰, 루프
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysElasticTether.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysElasticTether() {
const rootRef = useRef<HTMLDivElement>(null);
const originRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const origin = originRef.current;
const line = lineRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !origin || !line || !icon || !caption) return;
const TETHER_SPRING_STIFFNESS = 140;
const TETHER_SPRING_DAMPING = 12;
const TETHER_THICKNESS_BASE_PX = 5;
const KEY_STEP_PX = 18;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let grabbed = false;
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
let raf = 0, lastTs: number | null = null;
function applyIcon() {
icon!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px)";
}
function drawTether() {
const or = origin!.getBoundingClientRect();
const ir = icon!.getBoundingClientRect();
const ox = or.left + or.width / 2, oy = or.top + or.height / 2;
const ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
const dx = ix - ox, dy = iy - oy;
const length = Math.hypot(dx, dy);
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
const boardRect = root!.getBoundingClientRect();
line!.style.left = ox - boardRect.left + "px";
line!.style.top = oy - boardRect.top + "px";
line!.style.width = length + "px";
line!.style.height = thickness + "px";
line!.style.transform = "rotate(" + angle + "deg)";
line!.style.opacity = length < 2 ? "0" : "1";
}
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (0 - pos.x) * TETHER_SPRING_STIFFNESS - vel.x * TETHER_SPRING_DAMPING;
const fy = (0 - pos.y) * TETHER_SPRING_STIFFNESS - vel.y * TETHER_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon();
drawTether();
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyIcon(); drawTether();
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root!.classList.add(styles.isDragging);
caption!.textContent = "늘어나는 줄 길이·굵기가 실시간으로 계산돼요";
}
function moveDrag(clientX: number, clientY: number) {
pos.x = startPos.x + (clientX - pointerStart!.x);
pos.y = startPos.y + (clientY - pointerStart!.y);
applyIcon();
drawTether();
}
function endDrag() {
root!.classList.remove(styles.isDragging);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
caption!.textContent = "멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요";
if (reduce) { pos.x = 0; pos.y = 0; applyIcon(); drawTether(); } else { springHome(); }
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!pointerStart) return;
pointerStart = null;
endDrag();
e.preventDefault();
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
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();
drawTether();
}
icon.addEventListener("keydown", onKeyDown);
drawTether();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const originRect0 = origin.getBoundingClientRect();
const ox0 = originRect0.left + originRect0.width / 2;
const oy0 = originRect0.top + originRect0.height / 2;
const farX = ox0 + 92, farY = oy0 - 46;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 950) {
const p = Math.min(1, (t - 200) / 750);
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (farX - ox0) * ease;
const cy = oy0 + (farY - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1150) {
// 최대 늘어남 유지
} else if (t < 1180) {
endDrag();
} else {
if (t > 1900) 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.et} is-demo`} ref={rootRef}>
<div className={styles.origin} ref={originRef}></div>
<div className={styles.line} ref={lineRef}></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="탄성 테더로 당겨지는 아이콘. Enter 로 잡고 화살표로 늘이고, Enter 로 놓으면 되돌아갑니다"
></button>
<p className={styles.caption} ref={captionRef}>멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요</p>
</div>
</main>
);
}02 · 원점과 줄로 이어진 듯 늘어나는 탄성 테더 드래그 — 측정 누적면적 9.738% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawTether() {
var or = origin.getBoundingClientRect();
var ir = icon.getBoundingClientRect();
var ox = or.left + or.width / 2, oy = or.top + or.height / 2;
var ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
var dx = ix - ox, dy = iy - oy;
var length = Math.hypot(dx, dy);
var angle = Math.atan2(dy, dx) * 180 / Math.PI;
var thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
var boardRect = root.getBoundingClientRect();
line.style.left = (ox - boardRect.left) + 'px';
line.style.top = (oy - boardRect.top) + 'px';
line.style.width = length + 'px';
line.style.height = thickness + 'px';
line.style.transform = 'rotate(' + angle + 'deg)';
line.style.opacity = length < 2 ? '0' : '1';
}
03경계 밖으로 밀면 늘어났다 되돌아오는 고무 바운더리
아이콘이 컨테이너 경계를 넘으면 overflowOf 가 넘어간 거리를 계산하고 applyVisual 이 그 비율만큼 scale·skew 를 섞어 고무처럼 눌린 모양을 그린다. 그 거리는 BOUNDARY_RUBBER_MAX_PX(40px) 에서 더 늘지 않게 클램프되고, 손을 놓으면 BOUNDARY_SPRING_STIFFNESS·BOUNDARY_SPRING_DAMPING 스프링이 경계 안으로 되돌린다. 03 항목은 누적면적 10.5%·강도 평균 99.0·23프레임 중 20프레임에서 움직였다.

<main class="stage">
<div class="br is-demo" id="br">
<div class="br__box" id="brBox">
<button type="button" class="br__icon" id="brIcon" aria-label="경계 안에서만 움직이는 아이콘. 경계를 넘으면 고무처럼 늘어납니다. Enter 로 잡고 화살표로 이동, Enter 로 놓습니다"></button>
</div>
<p class="br__caption" id="brCaption">경계 밖으로 밀면 고무처럼 눌려요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.br {
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;
}
.br__box {
position: relative;
width: 100%;
height: 140px;
max-width: 300px;
border-radius: $r-card;
border: 2px dashed rgba($subject-ink, .28);
display: flex;
align-items: center;
justify-content: center;
}
.br__icon {
appearance: none;
border: 0;
width: 52px;
height: 52px;
border-radius: $r-pill;
background: $subject-blue;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.br.is-stretch .br__icon {
background: $subject-blue-deep;
}
.br__icon.is-grabbed,
.br__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.br.is-dragging .br__icon {
cursor: grabbing;
}
.br__caption {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.br__box { height: 108px; }
.br__icon { width: 40px; height: 40px; }
.br__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.br__icon { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.br {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.br__box {
position: relative;
width: 100%;
height: 140px;
max-width: 300px;
border-radius: 16px;
border: 2px dashed rgba(23, 20, 26, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.br__icon {
appearance: none;
border: 0;
width: 52px;
height: 52px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.br.is-stretch .br__icon {
background: #0a4fe8;
}
.br__icon.is-grabbed,
.br__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.br.is-dragging .br__icon {
cursor: grabbing;
}
.br__caption {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.br__box {
height: 108px;
}
.br__icon {
width: 40px;
height: 40px;
}
.br__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.br__icon {
transition: none;
}
}(function () {
// 기법: 아이콘이 경계 상자 밖으로 나간 거리(overflow)를 축별로 계산해
// BOUNDARY_RUBBER_MAX_PX 로 클램프한 뒤, 그 비율만큼 scale·skew 를 섞어
// "눌린" 모양을 만들고, 손을 떼면 감쇠 스프링으로 경계 안쪽까지 되돌린다.
var root = document.getElementById('br');
var box = document.getElementById('brBox');
var icon = document.getElementById('brIcon');
var caption = document.getElementById('brCaption');
var BOUNDARY_RUBBER_MAX_PX = 40;
var BOUNDARY_SPRING_STIFFNESS = 200;
var BOUNDARY_SPRING_DAMPING = 20;
var KEY_STEP_PX = 16;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var grabbed = false;
var pointerStart = null;
var startPos = null;
var raf = 0, lastTs = null;
function bounds() {
var br = box.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 overflowOf(clientX, clientY) {
var b = bounds();
var ox = 0, oy = 0;
if (clientX < b.minX) ox = clientX - b.minX;
else if (clientX > b.maxX) ox = clientX - b.maxX;
if (clientY < b.minY) oy = clientY - b.minY;
else if (clientY > b.maxY) oy = clientY - b.maxY;
return { x: ox, y: oy };
}
function applyVisual(clampedX, clampedY, overflow) {
var ratioX = Math.min(1, Math.abs(overflow.x) / BOUNDARY_RUBBER_MAX_PX);
var ratioY = Math.min(1, Math.abs(overflow.y) / BOUNDARY_RUBBER_MAX_PX);
var ratio = Math.max(ratioX, ratioY);
var scaleStretch = 1 + ratio * 0.28;
var scaleSquash = 1 - ratio * 0.22;
var skewX = overflow.x !== 0 ? (overflow.x > 0 ? 1 : -1) * ratioX * 6 : 0;
var skewY = overflow.y !== 0 ? (overflow.y > 0 ? 1 : -1) * ratioY * 6 : 0;
var sx = ratioX >= ratioY ? scaleStretch : scaleSquash;
var sy = ratioY >= ratioX ? scaleStretch : scaleSquash;
icon.style.transform = 'translate(' + clampedX.toFixed(1) + 'px,' + clampedY.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ') skew(' + skewX.toFixed(2) + 'deg,' + skewY.toFixed(2) + 'deg)';
root.classList.toggle('is-stretch', ratio > 0.02);
}
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (0 - pos.x) * BOUNDARY_SPRING_STIFFNESS - vel.x * BOUNDARY_SPRING_DAMPING;
var fy = (0 - pos.y) * BOUNDARY_SPRING_STIFFNESS - vel.y * BOUNDARY_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyVisual(pos.x, pos.y, { x: 0, y: 0 });
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyVisual(0, 0, { x: 0, y: 0 });
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root.classList.add('is-dragging');
}
function moveDrag(clientX, clientY) {
var rawX = startPos.x + (clientX - pointerStart.x);
var rawY = startPos.y + (clientY - pointerStart.y);
var iconRect = icon.getBoundingClientRect();
var curCx = iconRect.left + iconRect.width / 2;
var curCy = iconRect.top + iconRect.height / 2;
var moveX = clientX - pointerStart.x;
var moveY = clientY - pointerStart.y;
var wantCx = curCx - (pos.x - rawX);
var wantCy = curCy - (pos.y - rawY);
var overflow = overflowOf(wantCx, wantCy);
var clampedOverX = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.x));
var clampedOverY = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.y));
var clampX = rawX - (overflow.x - clampedOverX);
var clampY = rawY - (overflow.y - clampedOverY);
pos.x = clampX; pos.y = clampY;
applyVisual(pos.x, pos.y, { x: clampedOverX, y: clampedOverY });
if (Math.abs(clampedOverX) > 2 || Math.abs(clampedOverY) > 2) {
caption.textContent = '더 밀면 늘어남이 커져요 (한도 있음)';
} else {
caption.textContent = '경계 밖으로 밀면 고무처럼 눌려요';
}
}
function endDrag() {
root.classList.remove('is-dragging');
root.classList.remove('is-stretch');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
caption.textContent = '경계 밖으로 밀면 고무처럼 눌려요';
if (reduce) { pos.x = 0; pos.y = 0; applyVisual(0, 0, { x: 0, y: 0 }); }
else { springHome(); }
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!pointerStart) return;
pointerStart = null;
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();
var iconRect = icon.getBoundingClientRect();
var wantCx = iconRect.left + iconRect.width / 2 + dx;
var wantCy = iconRect.top + iconRect.height / 2 + dy;
var overflow = overflowOf(wantCx, wantCy);
var clampedOverX = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.x));
var clampedOverY = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.y));
pos.x += dx - (overflow.x - clampedOverX);
pos.y += dy - (overflow.y - clampedOverY);
applyVisual(pos.x, pos.y, { x: clampedOverX, y: clampedOverY });
});
applyVisual(0, 0, { x: 0, y: 0 });
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 pushX = ox0 + 130, pushY = oy0;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 900) {
var p = Math.min(1, (t - 200) / 700);
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (pushX - ox0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, pushY);
} else if (t < 1150) {
// 눌린 모양 관찰
} else if (t < 1180) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysBoundaryRubber.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysBoundaryRubber() {
const rootRef = useRef<HTMLDivElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const box = boxRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !box || !icon || !caption) return;
const BOUNDARY_RUBBER_MAX_PX = 40;
const BOUNDARY_SPRING_STIFFNESS = 200;
const BOUNDARY_SPRING_DAMPING = 20;
const KEY_STEP_PX = 16;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let grabbed = false;
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
let raf = 0, lastTs: number | null = null;
function bounds() {
const br = box!.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,
};
}
function overflowOf(clientX: number, clientY: number) {
const b = bounds();
let ox = 0, oy = 0;
if (clientX < b.minX) ox = clientX - b.minX;
else if (clientX > b.maxX) ox = clientX - b.maxX;
if (clientY < b.minY) oy = clientY - b.minY;
else if (clientY > b.maxY) oy = clientY - b.maxY;
return { x: ox, y: oy };
}
function applyVisual(clampedX: number, clampedY: number, overflow: Pt) {
const ratioX = Math.min(1, Math.abs(overflow.x) / BOUNDARY_RUBBER_MAX_PX);
const ratioY = Math.min(1, Math.abs(overflow.y) / BOUNDARY_RUBBER_MAX_PX);
const ratio = Math.max(ratioX, ratioY);
const scaleStretch = 1 + ratio * 0.28;
const scaleSquash = 1 - ratio * 0.22;
const skewX = overflow.x !== 0 ? (overflow.x > 0 ? 1 : -1) * ratioX * 6 : 0;
const skewY = overflow.y !== 0 ? (overflow.y > 0 ? 1 : -1) * ratioY * 6 : 0;
const sx = ratioX >= ratioY ? scaleStretch : scaleSquash;
const sy = ratioY >= ratioX ? scaleStretch : scaleSquash;
icon!.style.transform =
"translate(" + clampedX.toFixed(1) + "px," + clampedY.toFixed(1) + "px) scale(" +
sx.toFixed(3) + "," + sy.toFixed(3) + ") skew(" + skewX.toFixed(2) + "deg," + skewY.toFixed(2) + "deg)";
root!.classList.toggle(styles.isStretch, ratio > 0.02);
}
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (0 - pos.x) * BOUNDARY_SPRING_STIFFNESS - vel.x * BOUNDARY_SPRING_DAMPING;
const fy = (0 - pos.y) * BOUNDARY_SPRING_STIFFNESS - vel.y * BOUNDARY_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyVisual(pos.x, pos.y, { x: 0, y: 0 });
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyVisual(0, 0, { x: 0, y: 0 });
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root!.classList.add(styles.isDragging);
}
function moveDrag(clientX: number, clientY: number) {
const rawX = startPos.x + (clientX - pointerStart!.x);
const rawY = startPos.y + (clientY - pointerStart!.y);
const iconRect = icon!.getBoundingClientRect();
const curCx = iconRect.left + iconRect.width / 2;
const curCy = iconRect.top + iconRect.height / 2;
const wantCx = curCx - (pos.x - rawX);
const wantCy = curCy - (pos.y - rawY);
const overflow = overflowOf(wantCx, wantCy);
const clampedOverX = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.x));
const clampedOverY = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.y));
const clampX = rawX - (overflow.x - clampedOverX);
const clampY = rawY - (overflow.y - clampedOverY);
pos.x = clampX; pos.y = clampY;
applyVisual(pos.x, pos.y, { x: clampedOverX, y: clampedOverY });
if (Math.abs(clampedOverX) > 2 || Math.abs(clampedOverY) > 2) {
caption!.textContent = "더 밀면 늘어남이 커져요 (한도 있음)";
} else {
caption!.textContent = "경계 밖으로 밀면 고무처럼 눌려요";
}
}
function endDrag() {
root!.classList.remove(styles.isDragging);
root!.classList.remove(styles.isStretch);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
caption!.textContent = "경계 밖으로 밀면 고무처럼 눌려요";
if (reduce) { pos.x = 0; pos.y = 0; applyVisual(0, 0, { x: 0, y: 0 }); } else { springHome(); }
}
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 (!pointerStart) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!pointerStart) return;
pointerStart = null;
endDrag();
e.preventDefault();
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
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();
const iconRect = icon!.getBoundingClientRect();
const wantCx = iconRect.left + iconRect.width / 2 + dx;
const wantCy = iconRect.top + iconRect.height / 2 + dy;
const overflow = overflowOf(wantCx, wantCy);
const clampedOverX = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.x));
const clampedOverY = Math.max(-BOUNDARY_RUBBER_MAX_PX, Math.min(BOUNDARY_RUBBER_MAX_PX, overflow.y));
pos.x += dx - (overflow.x - clampedOverX);
pos.y += dy - (overflow.y - clampedOverY);
applyVisual(pos.x, pos.y, { x: clampedOverX, y: clampedOverY });
}
icon.addEventListener("keydown", onKeyDown);
applyVisual(0, 0, { x: 0, y: 0 });
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 pushX = ox0 + 130, pushY = oy0;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 900) {
const p = Math.min(1, (t - 200) / 700);
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (pushX - ox0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, pushY);
} else if (t < 1150) {
// 눌린 모양 관찰
} else if (t < 1180) {
endDrag();
} else {
if (t > 1900) 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.br} is-demo`} ref={rootRef}>
<div className={styles.box} ref={boxRef}>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="경계 안에서만 움직이는 아이콘. 경계를 넘으면 고무처럼 늘어납니다. Enter 로 잡고 화살표로 이동, Enter 로 놓습니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>경계 밖으로 밀면 고무처럼 눌려요</p>
</div>
</main>
);
}03 · 경계 밖으로 밀면 늘어났다 되돌아오는 고무 바운더리 — 측정 누적면적 10.521% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function overflowOf(clientX, clientY) {
var b = bounds();
var ox = 0, oy = 0;
if (clientX < b.minX) ox = clientX - b.minX;
else if (clientX > b.maxX) ox = clientX - b.maxX;
if (clientY < b.minY) oy = clientY - b.minY;
else if (clientY > b.maxY) oy = clientY - b.maxY;
return { x: ox, y: oy };
}
function applyVisual(clampedX, clampedY, overflow) {
var ratioX = Math.min(1, Math.abs(overflow.x) / BOUNDARY_RUBBER_MAX_PX);
var ratioY = Math.min(1, Math.abs(overflow.y) / BOUNDARY_RUBBER_MAX_PX);
var ratio = Math.max(ratioX, ratioY);
var scaleStretch = 1 + ratio * 0.28;
var scaleSquash = 1 - ratio * 0.22;
var skewX = overflow.x !== 0 ? (overflow.x > 0 ? 1 : -1) * ratioX * 6 : 0;
var skewY = overflow.y !== 0 ? (overflow.y > 0 ? 1 : -1) * ratioY * 6 : 0;
var sx = ratioX >= ratioY ? scaleStretch : scaleSquash;
var sy = ratioY >= ratioX ? scaleStretch : scaleSquash;
icon.style.transform = 'translate(' + clampedX.toFixed(1) + 'px,' + clampedY.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ') skew(' + skewX.toFixed(2) + 'deg,' + skewY.toFixed(2) + 'deg)';
root.classList.toggle('is-stretch', ratio > 0.02);
}
04이웃 중간점을 지나면 자리를 맞바꾸는 스왑 드래그
드래그 칩의 화면 중심이 옆 슬롯 중간점을 넘는 순간을 moveDrag 가 매 프레임 계산해 moveInOrder 로 두 칩의 순서를 맞바꾼다. 자리를 바꾼 두 칩은 kickSpring 이 걸어 둔 SWAP_FLIP_STIFFNESS·SWAP_FLIP_DAMPING 스프링으로 새 자리까지 미끄러지듯 이동한다. 04 항목은 누적면적 22.5%·강도 평균 103.3·23프레임 중 17프레임에서 움직였다.

<main class="stage">
<div class="sw is-demo" id="sw">
<div class="sw__track" id="swTrack">
<button type="button" class="sw__chip" id="swChip0" data-id="0" aria-label="드래그해서 순서를 바꾸는 칩. Enter 로 잡고 화살표로 옆 칸과 자리를 바꿉니다">A</button>
<div class="sw__chip sw__chip--static" data-id="1">B</div>
<div class="sw__chip sw__chip--static" data-id="2">C</div>
</div>
<p class="sw__caption" id="swCaption">중점을 넘으면 이웃과 자리를 바꿔요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.sw {
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-5;
}
.sw__track {
position: relative;
display: flex;
gap: $sp-3;
}
.sw__chip {
appearance: none;
border: 0;
width: 64px;
height: 64px;
border-radius: $r-control;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: $subject-cream;
background: $subject-blue;
box-shadow: $shadow-raised;
will-change: transform;
}
.sw__chip--static {
background: $subject-blue-deep;
cursor: default;
}
#swChip0 {
cursor: grab;
touch-action: none;
position: relative;
z-index: 2;
}
#swChip0.is-grabbed,
#swChip0:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.sw.is-dragging #swChip0 {
cursor: grabbing;
z-index: 3;
}
.sw__caption {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.sw__chip { width: 48px; height: 48px; font-size: 15px; }
.sw__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sw__chip { 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);
}
.sw {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.sw__track {
position: relative;
display: flex;
gap: 12px;
}
.sw__chip {
appearance: none;
border: 0;
width: 64px;
height: 64px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: #fff7e6;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform;
}
.sw__chip--static {
background: #0a4fe8;
cursor: default;
}
#swChip0 {
cursor: grab;
touch-action: none;
position: relative;
z-index: 2;
}
#swChip0.is-grabbed,
#swChip0:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sw.is-dragging #swChip0 {
cursor: grabbing;
z-index: 3;
}
.sw__caption {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.sw__chip {
width: 48px;
height: 48px;
font-size: 15px;
}
.sw__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sw__chip {
transition: none;
}
}(function () {
// 기법: 드래그 중인 칩의 중심이 이웃 칩과의 중점(슬롯 경계)을 넘는 순간을 감지해
// order 배열의 자리를 바꾸고, 밀려난 칩은 FLIP(새 목표 위치와의 차이를 스프링으로
// 0 까지 줄이는) 방식으로 부드럽게 새 자리로 이동한다.
var root = document.getElementById('sw');
var track = document.getElementById('swTrack');
var chips = Array.prototype.slice.call(track.querySelectorAll('.sw__chip'));
var caption = document.getElementById('swCaption');
var dragChip = document.getElementById('swChip0');
var DRAGGED_ID = 0;
var SWAP_FLIP_STIFFNESS = 210;
var SWAP_FLIP_DAMPING = 22;
var KEY_STEP_SLOTS = 1;
var order = [0, 1, 2];
var STEP = 0;
var state = {};
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id] = { x: 0, v: 0, target: 0 };
});
function measureStep() {
if (chips.length < 2) return 80;
var r0 = chips[0].getBoundingClientRect();
var r1 = chips[1].getBoundingClientRect();
return r1.left - r0.left;
}
function slotOf(id) { return order.indexOf(id); }
function retarget() {
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id].target = (slotOf(id) - id) * STEP;
});
}
function moveInOrder(fromSlot, toSlot) {
var id = order[fromSlot];
order.splice(fromSlot, 1);
order.splice(toSlot, 0, id);
}
function applyChip(id, x) {
var el = id === DRAGGED_ID ? dragChip : chips.filter(function (c) { return parseInt(c.getAttribute('data-id'), 10) === id; })[0];
el.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
}
var raf = 0, lastTs = null;
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var moving = false;
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
if (id === DRAGGED_ID && dragging) return;
var s = state[id];
var f = (s.target - s.x) * SWAP_FLIP_STIFFNESS - s.v * SWAP_FLIP_DAMPING;
s.v += f * dt;
s.x += s.v * dt;
applyChip(id, s.x);
if (Math.abs(s.v) > 1 || Math.abs(s.target - s.x) > 0.5) moving = true;
else { s.x = s.target; s.v = 0; applyChip(id, s.x); }
});
if (moving) { raf = requestAnimationFrame(settleTick); }
else { lastTs = null; raf = 0; }
}
function kickSpring() {
if (!raf) { lastTs = null; raf = requestAnimationFrame(settleTick); }
}
var dragging = false;
var pointerStartX = null;
var dragStartX = 0;
var startSlot = 0;
var grabbed = false;
function beginDrag(clientX) {
cancelAnimationFrame(raf); raf = 0; lastTs = null;
dragging = true;
pointerStartX = clientX;
dragStartX = state[DRAGGED_ID].x;
startSlot = slotOf(DRAGGED_ID);
root.classList.add('is-dragging');
}
function moveDrag(clientX) {
if (!STEP) STEP = measureStep();
var deltaX = clientX - pointerStartX;
var rawX = dragStartX + deltaX;
applyChip(DRAGGED_ID, rawX);
// 중점 판정: 드래그 칩의 화면 중심을 재서 어느 슬롯에 가장 가까운지 계산
var chipRect = dragChip.getBoundingClientRect();
var trackRect = track.getBoundingClientRect();
var centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
var targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
while (targetSlot !== slotOf(DRAGGED_ID)) {
var from = slotOf(DRAGGED_ID);
var step = targetSlot > from ? 1 : -1;
var to = from + step;
moveInOrder(from, to);
retarget();
kickSpring();
caption.textContent = '자리가 바뀌었어요';
}
state[DRAGGED_ID].x = rawX;
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
caption.textContent = '중점을 넘으면 이웃과 자리를 바꿔요';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
retarget();
if (reduce) {
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id].x = state[id].target; state[id].v = 0;
applyChip(id, state[id].x);
});
} else {
kickSpring();
}
}
dragChip.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragChip.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
e.preventDefault();
});
dragChip.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX);
e.preventDefault();
});
dragChip.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
e.preventDefault();
});
dragChip.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
grabbed = !grabbed;
dragChip.classList.toggle('is-grabbed', grabbed);
caption.textContent = grabbed ? '잡았어요. 화살표로 자리를 바꾸세요' : '중점을 넘으면 이웃과 자리를 바꿔요';
return;
}
if (!grabbed) return;
var dir = 0;
if (e.key === 'ArrowLeft') dir = -1;
else if (e.key === 'ArrowRight') dir = 1;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
dragChip.classList.remove('is-grabbed');
caption.textContent = '중점을 넘으면 이웃과 자리를 바꿔요';
return;
} else { return; }
e.preventDefault();
var from = slotOf(DRAGGED_ID);
var to = Math.max(0, Math.min(chips.length - 1, from + dir * KEY_STEP_SLOTS));
if (to !== from) {
moveInOrder(from, to);
retarget();
kickSpring();
caption.textContent = '자리가 바뀌었어요';
}
});
if (!STEP) STEP = 96;
retarget();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) { demoStart = ts; STEP = measureStep(); }
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1000) {
var p = Math.min(1, (t - 200) / 800);
var ease = 1 - Math.pow(1 - p, 2);
var travel = STEP * 2 * ease;
if (t < 220) beginDrag(0);
moveDrag(travel);
} else if (t < 1300) {
// 결과 관찰
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysSwapNeighbor.module.scss";
type Box<T> = { current: T };
export default function DragPhysSwapNeighbor() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const dragChipRef = useRef<HTMLButtonElement>(null);
const chipBRef = useRef<HTMLDivElement>(null);
const chipCRef = useRef<HTMLDivElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const dragChip = dragChipRef.current;
const caption = captionRef.current;
if (!root || !track || !dragChip || !chipBRef.current || !chipCRef.current || !caption) return;
const chips: HTMLElement[] = [dragChip, chipBRef.current, chipCRef.current];
const DRAGGED_ID = 0;
const SWAP_FLIP_STIFFNESS = 210;
const SWAP_FLIP_DAMPING = 22;
const KEY_STEP_SLOTS = 1;
let order = [0, 1, 2];
let STEP = 0;
const state: Record<number, { x: number; v: number; target: number }> = {
0: { x: 0, v: 0, target: 0 },
1: { x: 0, v: 0, target: 0 },
2: { x: 0, v: 0, target: 0 },
};
function measureStep() {
const r0 = chips[0].getBoundingClientRect();
const r1 = chips[1].getBoundingClientRect();
return r1.left - r0.left;
}
function slotOf(id: number) { return order.indexOf(id); }
function retarget() {
chips.forEach(function (c, id) {
state[id].target = (slotOf(id) - id) * STEP;
});
}
function moveInOrder(fromSlot: number, toSlot: number) {
const id = order[fromSlot];
order.splice(fromSlot, 1);
order.splice(toSlot, 0, id);
}
function applyChip(id: number, x: number) {
chips[id].style.transform = "translateX(" + x.toFixed(1) + "px)";
}
let raf = 0, lastTs: number | null = null;
let dragging = false;
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
let moving = false;
chips.forEach(function (c, id) {
if (id === DRAGGED_ID && dragging) return;
const s = state[id];
const f = (s.target - s.x) * SWAP_FLIP_STIFFNESS - s.v * SWAP_FLIP_DAMPING;
s.v += f * dt;
s.x += s.v * dt;
applyChip(id, s.x);
if (Math.abs(s.v) > 1 || Math.abs(s.target - s.x) > 0.5) moving = true;
else { s.x = s.target; s.v = 0; applyChip(id, s.x); }
});
if (moving) { raf = requestAnimationFrame(settleTick); } else { lastTs = null; raf = 0; }
}
function kickSpring() {
if (!raf) { lastTs = null; raf = requestAnimationFrame(settleTick); }
}
let pointerStartX: number | null = null;
let dragStartX = 0;
let startSlot = 0;
let grabbed = false;
function beginDrag(clientX: number) {
cancelAnimationFrame(raf); raf = 0; lastTs = null;
dragging = true;
pointerStartX = clientX;
dragStartX = state[DRAGGED_ID].x;
startSlot = slotOf(DRAGGED_ID);
root!.classList.add(styles.isDragging);
}
function moveDrag(clientX: number) {
if (!STEP) STEP = measureStep();
const deltaX = clientX - pointerStartX!;
const rawX = dragStartX + deltaX;
applyChip(DRAGGED_ID, rawX);
const chipRect = dragChip!.getBoundingClientRect();
const trackRect = track!.getBoundingClientRect();
const centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
const targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
while (targetSlot !== slotOf(DRAGGED_ID)) {
const from = slotOf(DRAGGED_ID);
const step = targetSlot > from ? 1 : -1;
const to = from + step;
moveInOrder(from, to);
retarget();
kickSpring();
caption!.textContent = "자리가 바뀌었어요";
}
state[DRAGGED_ID].x = rawX;
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
caption!.textContent = "중점을 넘으면 이웃과 자리를 바꿔요";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
retarget();
if (reduce) {
chips.forEach(function (c, id) {
state[id].x = state[id].target; state[id].v = 0;
applyChip(id, state[id].x);
});
} else {
kickSpring();
}
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
dragChip!.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
e.preventDefault();
}
dragChip.addEventListener("pointerdown", onPointerDown);
dragChip.addEventListener("pointermove", onPointerMove);
dragChip.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
grabbed = !grabbed;
dragChip!.classList.toggle(styles.isGrabbed, grabbed);
caption!.textContent = grabbed ? "잡았어요. 화살표로 자리를 바꾸세요" : "중점을 넘으면 이웃과 자리를 바꿔요";
return;
}
if (!grabbed) return;
let dir = 0;
if (e.key === "ArrowLeft") dir = -1;
else if (e.key === "ArrowRight") dir = 1;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
dragChip!.classList.remove(styles.isGrabbed);
caption!.textContent = "중점을 넘으면 이웃과 자리를 바꿔요";
return;
} else { return; }
e.preventDefault();
const from = slotOf(DRAGGED_ID);
const to = Math.max(0, Math.min(chips.length - 1, from + dir * KEY_STEP_SLOTS));
if (to !== from) {
moveInOrder(from, to);
retarget();
kickSpring();
caption!.textContent = "자리가 바뀌었어요";
}
}
dragChip.addEventListener("keydown", onKeyDown);
if (!STEP) STEP = 96;
retarget();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) { demoStart = ts; STEP = measureStep(); }
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1000) {
const p = Math.min(1, (t - 200) / 800);
const ease = 1 - Math.pow(1 - p, 2);
const travel = STEP * 2 * ease;
if (t < 220) beginDrag(0);
moveDrag(travel);
} else if (t < 1300) {
// 결과 관찰
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
dragChip!.removeEventListener("pointerdown", onPointerDown);
dragChip!.removeEventListener("pointermove", onPointerMove);
dragChip!.removeEventListener("pointerup", onPointerUp);
dragChip!.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sw} is-demo`} ref={rootRef}>
<div className={styles.track} ref={trackRef}>
<button
type="button"
className={`${styles.chip} ${styles.chipDrag}`}
ref={dragChipRef}
aria-label="드래그해서 순서를 바꾸는 칩. Enter 로 잡고 화살표로 옆 칸과 자리를 바꿉니다"
>A</button>
<div className={`${styles.chip} ${styles.chipStatic}`} ref={chipBRef}>B</div>
<div className={`${styles.chip} ${styles.chipStatic}`} ref={chipCRef}>C</div>
</div>
<p className={styles.caption} ref={captionRef}>중점을 넘으면 이웃과 자리를 바꿔요</p>
</div>
</main>
);
}04 · 이웃 중간점을 지나면 자리를 맞바꾸는 스왑 드래그 — 측정 누적면적 22.487% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function moveDrag(clientX) {
if (!STEP) STEP = measureStep();
var deltaX = clientX - pointerStartX;
var rawX = dragStartX + deltaX;
applyChip(DRAGGED_ID, rawX);
// 중점 판정: 드래그 칩의 화면 중심을 재서 어느 슬롯에 가장 가까운지 계산
var chipRect = dragChip.getBoundingClientRect();
var trackRect = track.getBoundingClientRect();
var centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
var targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
while (targetSlot !== slotOf(DRAGGED_ID)) {
var from = slotOf(DRAGGED_ID);
var step = targetSlot > from ? 1 : -1;
var to = from + step;
moveInOrder(from, to);
retarget();
kickSpring();
caption.textContent = '자리가 바뀌었어요';
}
state[DRAGGED_ID].x = rawX;
05중심을 기준으로 각도만 바뀌는 궤도 핸들 드래그
포인터 좌표를 angleFromPointer 가 중심점 기준 각도값으로 바꾸고, 핸들은 그 각도만큼만 ORBIT_RADIUS_PX(74px) 고정 반지름 원 위를 돈다. 손을 놓으면 inertiaTick 이 각속도에 ORBIT_DECAY_RATE 를 매 프레임 곱해 서서히 늦추다가 멈춘다. 05 항목은 누적면적 11.3%·강도 평균 186.6·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="ob is-demo" id="ob">
<div class="ob__ring" id="obRing"></div>
<div class="ob__hub" id="obHub"></div>
<button type="button" class="ob__handle" id="obHandle" aria-label="고정 반지름 궤도를 도는 핸들. Enter 로 잡고 화살표로 각도를 돌립니다"></button>
<p class="ob__caption" id="obCaption">끌면 각도만 바뀌며 궤도를 돌아요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.ob {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
height: 220px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.ob__ring {
position: absolute;
width: 148px;
height: 148px;
border-radius: $r-pill;
border: 1px dashed rgba($subject-cream, .28);
}
.ob__hub {
position: absolute;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $subject-cream;
}
.ob__handle {
position: absolute;
appearance: none;
border: 0;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
top: 50%;
left: 50%;
border-radius: $r-pill;
background: $subject-blue;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.ob__handle.is-grabbed,
.ob__handle:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ob.is-dragging .ob__handle {
cursor: grabbing;
}
.ob__caption {
position: absolute;
bottom: 0;
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ob { height: 150px; }
.ob__ring { width: 100px; height: 100px; }
.ob__handle { width: 30px; height: 30px; margin: -15px 0 0 -15px; }
.ob__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ob__handle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ob {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
height: 220px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.ob__ring {
position: absolute;
width: 148px;
height: 148px;
border-radius: 999px;
border: 1px dashed rgba(255, 247, 230, 0.28);
}
.ob__hub {
position: absolute;
width: 16px;
height: 16px;
border-radius: 999px;
background: #fff7e6;
}
.ob__handle {
position: absolute;
appearance: none;
border: 0;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
top: 50%;
left: 50%;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.ob__handle.is-grabbed,
.ob__handle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ob.is-dragging .ob__handle {
cursor: grabbing;
}
.ob__caption {
position: absolute;
bottom: 0;
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ob {
height: 150px;
}
.ob__ring {
width: 100px;
height: 100px;
}
.ob__handle {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
}
.ob__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ob__handle {
transition: none;
}
}(function () {
// 기법: 허브 중심에서 포인터까지의 각도를 atan2 로 매 프레임 계산해 핸들을
// 고정 반지름(ORBIT_RADIUS_PX) 위 그 각도 지점으로만 옮긴다(반지름은 절대
// 바뀌지 않음). 손을 떼면 마지막 각속도로 관성 회전하다 감쇠로 멈춘다.
var root = document.getElementById('ob');
var hub = document.getElementById('obHub');
var handle = document.getElementById('obHandle');
var caption = document.getElementById('obCaption');
var ORBIT_RADIUS_PX = 74;
var ORBIT_KEY_STEP_DEG = 12;
var ORBIT_DECAY_RATE = 0.94;
var angle = -90 * Math.PI / 180;
var angularVel = 0;
var grabbed = false;
var dragging = false;
var lastAngle = angle;
var lastTs = null;
var raf = 0;
function applyHandle() {
var x = Math.cos(angle) * ORBIT_RADIUS_PX;
var y = Math.sin(angle) * ORBIT_RADIUS_PX;
handle.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
}
function angleFromPointer(clientX, clientY) {
var hr = hub.getBoundingClientRect();
var hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
return Math.atan2(clientY - hy, clientX - hx);
}
function shortestDelta(a, b) {
var d = b - a;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function inertiaTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
angle += angularVel * dt;
angularVel *= Math.pow(ORBIT_DECAY_RATE, dt * 60);
applyHandle();
if (Math.abs(angularVel) > 0.02) { raf = requestAnimationFrame(inertiaTick); return; }
angularVel = 0;
lastTs = null;
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
dragging = true;
angularVel = 0;
angle = angleFromPointer(clientX, clientY);
lastAngle = angle;
lastTs = null;
root.classList.add('is-dragging');
applyHandle();
}
function moveDrag(clientX, clientY, ts) {
var next = angleFromPointer(clientX, clientY);
var delta = shortestDelta(lastAngle, next);
angle = lastAngle + delta;
if (ts !== undefined && lastTs !== null) {
var dt = Math.max((ts - lastTs) / 1000, 0.001);
angularVel = delta / dt;
}
lastAngle = angle;
lastTs = ts !== undefined ? ts : lastTs;
applyHandle();
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || Math.abs(angularVel) < 0.02) { angularVel = 0; }
else { lastTs = null; raf = requestAnimationFrame(inertiaTick); }
}
handle.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
handle.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption.textContent = '각도만 계산해서 궤도 위를 이동해요';
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY, e.timeStamp);
e.preventDefault();
});
handle.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
caption.textContent = '끌면 각도만 바뀌며 궤도를 돌아요';
e.preventDefault();
});
handle.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
grabbed = !grabbed;
handle.classList.toggle('is-grabbed', grabbed);
caption.textContent = grabbed ? '잡았어요. 화살표로 각도를 돌리세요' : '끌면 각도만 바뀌며 궤도를 돌아요';
return;
}
if (!grabbed) return;
var dir = 0;
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') dir = -1;
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') dir = 1;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
handle.classList.remove('is-grabbed');
caption.textContent = '끌면 각도만 바뀌며 궤도를 돌아요';
return;
} else { return; }
e.preventDefault();
cancelAnimationFrame(raf);
angularVel = 0;
angle += dir * ORBIT_KEY_STEP_DEG * Math.PI / 180;
applyHandle();
});
applyHandle();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var hubRect0 = hub.getBoundingClientRect();
var hx0 = hubRect0.left + hubRect0.width / 2;
var hy0 = hubRect0.top + hubRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1300) {
var p = (t - 200) / 1100;
var a = (-90 + p * 200) * Math.PI / 180;
var px = hx0 + Math.cos(a) * (ORBIT_RADIUS_PX + 40);
var py = hy0 + Math.sin(a) * (ORBIT_RADIUS_PX + 40);
if (t < 220) beginDrag(px, py);
moveDrag(px, py, ts);
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysOrbitHandle.module.scss";
type Box<T> = { current: T };
export default function DragPhysOrbitHandle() {
const rootRef = useRef<HTMLDivElement>(null);
const hubRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const hub = hubRef.current;
const handle = handleRef.current;
const caption = captionRef.current;
if (!root || !hub || !handle || !caption) return;
const ORBIT_RADIUS_PX = 74;
const ORBIT_KEY_STEP_DEG = 12;
const ORBIT_DECAY_RATE = 0.94;
let angle = (-90 * Math.PI) / 180;
let angularVel = 0;
let grabbed = false;
let dragging = false;
let lastAngle = angle;
let lastTs: number | null = null;
let raf = 0;
function applyHandle() {
const x = Math.cos(angle) * ORBIT_RADIUS_PX;
const y = Math.sin(angle) * ORBIT_RADIUS_PX;
handle!.style.transform = "translate(" + x.toFixed(1) + "px," + y.toFixed(1) + "px)";
}
function angleFromPointer(clientX: number, clientY: number) {
const hr = hub!.getBoundingClientRect();
const hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
return Math.atan2(clientY - hy, clientX - hx);
}
function shortestDelta(a: number, b: number) {
let d = b - a;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function inertiaTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
angle += angularVel * dt;
angularVel *= Math.pow(ORBIT_DECAY_RATE, dt * 60);
applyHandle();
if (Math.abs(angularVel) > 0.02) { raf = requestAnimationFrame(inertiaTick); return; }
angularVel = 0;
lastTs = null;
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
dragging = true;
angularVel = 0;
angle = angleFromPointer(clientX, clientY);
lastAngle = angle;
lastTs = null;
root!.classList.add(styles.isDragging);
applyHandle();
}
function moveDrag(clientX: number, clientY: number, ts?: number) {
const next = angleFromPointer(clientX, clientY);
const delta = shortestDelta(lastAngle, next);
angle = lastAngle + delta;
if (ts !== undefined && lastTs !== null) {
const dt = Math.max((ts - lastTs) / 1000, 0.001);
angularVel = delta / dt;
}
lastAngle = angle;
lastTs = ts !== undefined ? ts : lastTs;
applyHandle();
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce || Math.abs(angularVel) < 0.02) { angularVel = 0; } else { lastTs = null; raf = requestAnimationFrame(inertiaTick); }
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
handle!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption!.textContent = "각도만 계산해서 궤도 위를 이동해요";
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY, e.timeStamp);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
caption!.textContent = "끌면 각도만 바뀌며 궤도를 돌아요";
e.preventDefault();
}
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
grabbed = !grabbed;
handle!.classList.toggle(styles.isGrabbed, grabbed);
caption!.textContent = grabbed ? "잡았어요. 화살표로 각도를 돌리세요" : "끌면 각도만 바뀌며 궤도를 돌아요";
return;
}
if (!grabbed) return;
let dir = 0;
if (e.key === "ArrowLeft" || e.key === "ArrowUp") dir = -1;
else if (e.key === "ArrowRight" || e.key === "ArrowDown") dir = 1;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
handle!.classList.remove(styles.isGrabbed);
caption!.textContent = "끌면 각도만 바뀌며 궤도를 돌아요";
return;
} else { return; }
e.preventDefault();
cancelAnimationFrame(raf);
angularVel = 0;
angle += (dir * ORBIT_KEY_STEP_DEG * Math.PI) / 180;
applyHandle();
}
handle.addEventListener("keydown", onKeyDown);
applyHandle();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const hubRect0 = hub.getBoundingClientRect();
const hx0 = hubRect0.left + hubRect0.width / 2;
const hy0 = hubRect0.top + hubRect0.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 < 1300) {
const p = (t - 200) / 1100;
const a = ((-90 + p * 200) * Math.PI) / 180;
const px = hx0 + Math.cos(a) * (ORBIT_RADIUS_PX + 40);
const py = hy0 + Math.sin(a) * (ORBIT_RADIUS_PX + 40);
if (t < 220) beginDrag(px, py);
moveDrag(px, py, ts);
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
handle.removeEventListener("pointerdown", onPointerDown);
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", onPointerUp);
handle.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.ob} is-demo`} ref={rootRef}>
<div className={styles.ring}></div>
<div className={styles.hub} ref={hubRef}></div>
<button
type="button"
className={styles.handle}
ref={handleRef}
aria-label="고정 반지름 궤도를 도는 핸들. Enter 로 잡고 화살표로 각도를 돌립니다"
></button>
<p className={styles.caption} ref={captionRef}>끌면 각도만 바뀌며 궤도를 돌아요</p>
</div>
</main>
);
}05 · 중심을 기준으로 각도만 바뀌는 궤도 핸들 드래그 — 측정 누적면적 11.255% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function angleFromPointer(clientX, clientY) {
var hr = hub.getBoundingClientRect();
var hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
return Math.atan2(clientY - hy, clientX - hx);
}
function shortestDelta(a, b) {
var d = b - a;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function inertiaTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
angle += angularVel * dt;
angularVel *= Math.pow(ORBIT_DECAY_RATE, dt * 60);
applyHandle();
if (Math.abs(angularVel) > 0.02) { raf = requestAnimationFrame(inertiaTick); return; }
angularVel = 0;
lastTs = null;
06놓는 속도가 남은 거리를 미는 관성 플링 드래그
손을 떼기 직전 이동량으로 구한 속도값을 flingTick 이 매 프레임 위치에 더하고, 경계에 닿으면 부호를 뒤집어 튕겨낸다. 그 속도는 FLING_DECAY_RATE(0.92) 를 매 프레임 곱해 점점 줄어들다가 자연스럽게 멈춘다. 06 항목은 누적면적 9.3%·강도 평균 55.6·23프레임 중 17프레임에서 움직였다.

<main class="stage">
<div class="fl is-demo" id="fl">
<div class="fl__box" id="flBox">
<button type="button" class="fl__ball" id="flBall" aria-label="던지면 벽에 튕기며 서서히 멈추는 공. Enter 로 잡고 화살표로 방향에 힘을 줍니다"></button>
</div>
<p class="fl__caption" id="flCaption">빠르게 놓으면 그 속도로 날아가 벽에 튕겨요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.fl {
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;
}
.fl__box {
position: relative;
width: 100%;
height: 170px;
max-width: 320px;
border-radius: $r-card;
border: 2px dashed rgba($subject-cream, .3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fl__ball {
appearance: none;
border: 0;
width: 46px;
height: 46px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.fl.is-bounce .fl__ball {
background: $subject-cream;
}
.fl__ball.is-grabbed,
.fl__ball:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.fl.is-dragging .fl__ball {
cursor: grabbing;
}
.fl__caption {
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.fl__box { height: 130px; }
.fl__ball { width: 36px; height: 36px; }
.fl__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fl__ball { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fl {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.fl__box {
position: relative;
width: 100%;
height: 170px;
max-width: 320px;
border-radius: 16px;
border: 2px dashed rgba(255, 247, 230, 0.3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fl__ball {
appearance: none;
border: 0;
width: 46px;
height: 46px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.fl.is-bounce .fl__ball {
background: #fff7e6;
}
.fl__ball.is-grabbed,
.fl__ball:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.fl.is-dragging .fl__ball {
cursor: grabbing;
}
.fl__caption {
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.fl__box {
height: 130px;
}
.fl__ball {
width: 36px;
height: 36px;
}
.fl__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__ball {
transition: none;
}
}(function () {
// 기법: 드래그 중 마지막 몇 프레임의 이동량으로 놓는 순간의 속도(vx,vy)를 구하고,
// 놓으면 그 속도로 계속 이동시키며 매 프레임 FLING_DECAY_RATE 로 감쇠, 경계에
// 부딪히면 해당 축 속도의 부호를 반전(반사)시킨다.
var root = document.getElementById('fl');
var box = document.getElementById('flBox');
var ball = document.getElementById('flBall');
var caption = document.getElementById('flCaption');
var FLING_DECAY_RATE = 0.92;
var KEY_IMPULSE_PX_S = 260;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var pointerStart = null;
var startPos = null;
var lastSample = null;
var dragging = false;
var grabbed = false;
var raf = 0, lastTs = null;
function applyBall() {
ball.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px)';
}
function bounds() {
var br = box.getBoundingClientRect();
var brc = ball.getBoundingClientRect();
var halfW = brc.width / 2, halfH = brc.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function flingTick(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 b = bounds();
var origin = ballOrigin();
var cx = origin.x + pos.x, cy = origin.y + pos.y;
if (cx < b.minX) { pos.x += (b.minX - cx); vel.x = Math.abs(vel.x); root.classList.add('is-bounce'); }
else if (cx > b.maxX) { pos.x -= (cx - b.maxX); vel.x = -Math.abs(vel.x); root.classList.add('is-bounce'); }
if (cy < b.minY) { pos.y += (b.minY - cy); vel.y = Math.abs(vel.y); root.classList.add('is-bounce'); }
else if (cy > b.maxY) { pos.y -= (cy - b.maxY); vel.y = -Math.abs(vel.y); root.classList.add('is-bounce'); }
vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
applyBall();
var speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
vel.x = 0; vel.y = 0;
root.classList.remove('is-bounce');
lastTs = null;
}
var originCache = null;
function ballOrigin() {
if (originCache) return originCache;
var r = ball.getBoundingClientRect();
originCache = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return originCache;
}
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');
}
function moveDrag(clientX, clientY) {
pos.x = startPos.x + (clientX - pointerStart.x);
pos.y = startPos.y + (clientY - pointerStart.y);
applyBall();
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 endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { vel.x = 0; vel.y = 0; }
else { lastTs = null; raf = requestAnimationFrame(flingTick); }
}
ball.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
ball.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption.textContent = '속도를 재고 있어요. 놓으면 그대로 날아가요';
e.preventDefault();
});
ball.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
ball.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
caption.textContent = '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
e.preventDefault();
});
ball.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
grabbed = !grabbed;
ball.classList.toggle('is-grabbed', grabbed);
caption.textContent = grabbed ? '잡았어요. 화살표로 방향에 힘을 주세요' : '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
return;
}
if (!grabbed) return;
var ix = 0, iy = 0;
if (e.key === 'ArrowLeft') ix = -KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowRight') ix = KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowUp') iy = -KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowDown') iy = KEY_IMPULSE_PX_S;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
ball.classList.remove('is-grabbed');
caption.textContent = '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
return;
} else { return; }
e.preventDefault();
vel.x += ix; vel.y += iy;
grabbed = false;
ball.classList.remove('is-grabbed');
lastTs = null;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(flingTick);
});
applyBall();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var ballRect0 = ball.getBoundingClientRect();
var ox0 = ballRect0.left + ballRect0.width / 2;
var oy0 = ballRect0.top + ballRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 420) {
var p = (t - 200) / 220;
var cx = ox0 - 60 * p;
var cy = oy0 - 8 * p;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 460) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysMomentumFling.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysMomentumFling() {
const rootRef = useRef<HTMLDivElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const ballRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const box = boxRef.current;
const ball = ballRef.current;
const caption = captionRef.current;
if (!root || !box || !ball || !caption) return;
const FLING_DECAY_RATE = 0.92;
const KEY_IMPULSE_PX_S = 260;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
let lastSample: { x: number; y: number; t: number } | null = null;
let dragging = false;
let grabbed = false;
let raf = 0, lastTs: number | null = null;
let originCache: Pt | null = null;
function applyBall() {
ball!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px)";
}
function bounds() {
const br = box!.getBoundingClientRect();
const brc = ball!.getBoundingClientRect();
const halfW = brc.width / 2, halfH = brc.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function ballOrigin() {
if (originCache) return originCache;
const r = ball!.getBoundingClientRect();
originCache = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return originCache;
}
function flingTick(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 b = bounds();
const origin = ballOrigin();
const cx = origin.x + pos.x, cy = origin.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); root!.classList.add(styles.isBounce); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); root!.classList.add(styles.isBounce); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); root!.classList.add(styles.isBounce); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); root!.classList.add(styles.isBounce); }
vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
applyBall();
const speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
vel.x = 0; vel.y = 0;
root!.classList.remove(styles.isBounce);
lastTs = null;
}
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);
}
function moveDrag(clientX: number, clientY: number) {
pos.x = startPos.x + (clientX - pointerStart!.x);
pos.y = startPos.y + (clientY - pointerStart!.y);
applyBall();
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 endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { vel.x = 0; vel.y = 0; } else { lastTs = null; raf = requestAnimationFrame(flingTick); }
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
ball!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption!.textContent = "속도를 재고 있어요. 놓으면 그대로 날아가요";
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
caption!.textContent = "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
e.preventDefault();
}
ball.addEventListener("pointerdown", onPointerDown);
ball.addEventListener("pointermove", onPointerMove);
ball.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
grabbed = !grabbed;
ball!.classList.toggle(styles.isGrabbed, grabbed);
caption!.textContent = grabbed ? "잡았어요. 화살표로 방향에 힘을 주세요" : "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
return;
}
if (!grabbed) return;
let ix = 0, iy = 0;
if (e.key === "ArrowLeft") ix = -KEY_IMPULSE_PX_S;
else if (e.key === "ArrowRight") ix = KEY_IMPULSE_PX_S;
else if (e.key === "ArrowUp") iy = -KEY_IMPULSE_PX_S;
else if (e.key === "ArrowDown") iy = KEY_IMPULSE_PX_S;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
ball!.classList.remove(styles.isGrabbed);
caption!.textContent = "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
return;
} else { return; }
e.preventDefault();
vel.x += ix; vel.y += iy;
grabbed = false;
ball!.classList.remove(styles.isGrabbed);
lastTs = null;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(flingTick);
}
ball.addEventListener("keydown", onKeyDown);
applyBall();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const ballRect0 = ball.getBoundingClientRect();
const ox0 = ballRect0.left + ballRect0.width / 2;
const oy0 = ballRect0.top + ballRect0.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 < 420) {
const p = (t - 200) / 220;
const cx = ox0 - 60 * p;
const cy = oy0 - 8 * p;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 460) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
ball.removeEventListener("pointerdown", onPointerDown);
ball.removeEventListener("pointermove", onPointerMove);
ball.removeEventListener("pointerup", onPointerUp);
ball.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.fl} is-demo`} ref={rootRef}>
<div className={styles.box} ref={boxRef}>
<button
type="button"
className={styles.ball}
ref={ballRef}
aria-label="던지면 벽에 튕기며 서서히 멈추는 공. Enter 로 잡고 화살표로 방향에 힘을 줍니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>빠르게 놓으면 그 속도로 날아가 벽에 튕겨요</p>
</div>
</main>
);
}06 · 놓는 속도가 남은 거리를 미는 관성 플링 드래그 — 측정 누적면적 9.251% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function flingTick(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 b = bounds();
var origin = ballOrigin();
var cx = origin.x + pos.x, cy = origin.y + pos.y;
if (cx < b.minX) { pos.x += (b.minX - cx); vel.x = Math.abs(vel.x); root.classList.add('is-bounce'); }
else if (cx > b.maxX) { pos.x -= (cx - b.maxX); vel.x = -Math.abs(vel.x); root.classList.add('is-bounce'); }
if (cy < b.minY) { pos.y += (b.minY - cy); vel.y = Math.abs(vel.y); root.classList.add('is-bounce'); }
else if (cy > b.maxY) { pos.y -= (cy - b.maxY); vel.y = -Math.abs(vel.y); root.classList.add('is-bounce'); }
vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
applyBall();
var speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
vel.x = 0; vel.y = 0;
root.classList.remove('is-bounce');
07삭제 영역 위에서 떨리는 경고를 보이는 트래시 셰이크
아이콘이 삭제 영역 위에 머문 시간을 shakeTick 이 dwellMs 로 재고, 그 시간에 비례해 TRASH_SHAKE_MAX_PX(5px) 진폭으로 좌우로 떤다. 머문 시간이 TRASH_DELETE_DWELL_MS(550ms) 를 넘으면 문구가 '지금 놓으면 삭제돼요' 로 바뀌어 놓는 순간 삭제됨을 미리 알린다. 07 항목은 누적면적 17.5%·강도 평균 115.5·23프레임 중 17프레임에서 움직였다.

<main class="stage">
<div class="ts is-demo" id="ts">
<div class="ts__trash" id="tsTrash" aria-hidden="true"></div>
<button type="button" class="ts__icon" id="tsIcon" aria-label="휴지통 위에 놓으면 떨리다 사라지는 카드. Enter 로 잡고 화살표로 옮긴 뒤 Enter 로 놓아 삭제합니다"></button>
<p class="ts__caption" id="tsCaption">휴지통 위에 오래 있으면 떨리다 사라져요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.ts {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: $sp-4;
}
.ts__trash {
position: absolute;
top: 10%;
right: 14%;
width: 60px;
height: 60px;
border-radius: $r-card;
border: 2px dashed rgba($subject-ink, .3);
transition: border-color $dur-quick $ease-out, background-color $dur-quick $ease-out;
}
.ts__trash.is-armed {
border-color: $subject-orange-deep;
border-style: solid;
background: rgba($subject-orange-deep, .12);
}
.ts__icon {
position: absolute;
top: 62%;
left: 24%;
appearance: none;
border: 0;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: $r-control;
background: $subject-blue;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform, opacity;
}
.ts.is-armed .ts__icon {
background: $subject-orange-deep;
}
.ts__icon.is-grabbed,
.ts__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ts.is-dragging .ts__icon {
cursor: grabbing;
}
.ts__caption {
position: relative;
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ts { height: 160px; }
.ts__trash { width: 46px; height: 46px; }
.ts__icon { width: 42px; height: 42px; margin: -21px 0 0 -21px; }
.ts__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__icon { transition: none; }
.ts__trash { 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);
}
.ts {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.ts__trash {
position: absolute;
top: 10%;
right: 14%;
width: 60px;
height: 60px;
border-radius: 16px;
border: 2px dashed rgba(23, 20, 26, 0.3);
transition: border-color 220ms ease-out, background-color 220ms ease-out;
}
.ts__trash.is-armed {
border-color: #c93000;
border-style: solid;
background: rgba(201, 48, 0, 0.12);
}
.ts__icon {
position: absolute;
top: 62%;
left: 24%;
appearance: none;
border: 0;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: 12px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform, opacity;
}
.ts.is-armed .ts__icon {
background: #c93000;
}
.ts__icon.is-grabbed,
.ts__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ts.is-dragging .ts__icon {
cursor: grabbing;
}
.ts__caption {
position: relative;
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ts {
height: 160px;
}
.ts__trash {
width: 46px;
height: 46px;
}
.ts__icon {
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
}
.ts__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__icon {
transition: none;
}
.ts__trash {
transition: none;
}
}(function () {
// 기법: 드래그 중인 카드 중심이 휴지통 영역과 겹치는 동안 머문 시간(dwell)을 매
// 프레임 누적하고, 그 시간에 비례해 흔들림 진폭을 사인 파형으로 키운다. 머문
// 시간이 TRASH_DELETE_DWELL_MS 를 넘긴 채 놓으면 scale 을 0 으로 줄이며 삭제한다.
var root = document.getElementById('ts');
var trash = document.getElementById('tsTrash');
var icon = document.getElementById('tsIcon');
var caption = document.getElementById('tsCaption');
var TRASH_DWELL_SHAKE_MS = 700;
var TRASH_SHAKE_MAX_PX = 5;
var TRASH_DELETE_DWELL_MS = 550;
var KEY_STEP_PX = 16;
var pos = { x: 0, y: 0 };
var basePos = { x: 0, y: 0 };
var dragging = false;
var grabbed = false;
var dwellStart = null;
var overTrash = false;
var raf = 0;
var deleted = false;
function overlapsTrash() {
var ir = icon.getBoundingClientRect();
var tr = trash.getBoundingClientRect();
var icx = ir.left + ir.width / 2, icy = ir.top + ir.height / 2;
return icx > tr.left && icx < tr.right && icy > tr.top && icy < tr.bottom;
}
function applyIcon(shakeX, shakeY, scale) {
icon.style.transform = 'translate(' + (pos.x + shakeX).toFixed(1) + 'px,' + (pos.y + shakeY).toFixed(1) + 'px) scale(' + scale.toFixed(3) + ')';
}
function shakeTick(ts) {
if (deleted) return;
var isOver = overlapsTrash();
if (isOver && !overTrash) { dwellStart = ts; }
overTrash = isOver;
trash.classList.toggle('is-armed', overTrash);
var shakeX = 0, shakeY = 0;
var dwellMs = 0;
if (overTrash && dwellStart !== null) {
dwellMs = ts - dwellStart;
var amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
shakeX = Math.sin(ts / 40) * amp;
shakeY = Math.cos(ts / 55) * amp * 0.6;
if (dwellMs > TRASH_DELETE_DWELL_MS) {
caption.textContent = '지금 놓으면 삭제돼요';
root.classList.add('is-armed');
} else {
caption.textContent = '더 있으면 삭제 준비돼요';
root.classList.remove('is-armed');
}
} else {
caption.textContent = dragging ? '휴지통 위로 옮겨보세요' : '휴지통 위에 오래 있으면 떨리다 사라져요';
root.classList.remove('is-armed');
}
applyIcon(shakeX, shakeY, 1);
if (dragging) raf = requestAnimationFrame(shakeTick);
}
function respawn() {
deleted = false;
pos.x = 0; pos.y = 0;
icon.style.opacity = '1';
applyIcon(0, 0, 1);
caption.textContent = '휴지통 위에 오래 있으면 떨리다 사라져요';
}
function deleteIcon() {
deleted = true;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
caption.textContent = '삭제됐어요';
if (reduce) {
icon.style.opacity = '0';
requestAnimationFrame(function waitReduce(ts) {
if (waitReduce.start === undefined) waitReduce.start = ts;
if (ts - waitReduce.start < 260) { requestAnimationFrame(waitReduce); } else { respawn(); }
});
return;
}
var start = null;
function shrink(ts) {
if (start === null) start = ts;
var p = Math.min(1, (ts - start) / 260);
applyIcon(0, 0, 1 - p);
icon.style.opacity = String(1 - p);
if (p < 1) { requestAnimationFrame(shrink); return; }
requestAnimationFrame(function waitRespawn(ts2) {
if (waitRespawn.start === undefined) waitRespawn.start = ts2;
if (ts2 - waitRespawn.start < 260) { requestAnimationFrame(waitRespawn); } else { respawn(); }
});
}
requestAnimationFrame(shrink);
}
function beginDrag(clientX, clientY) {
if (deleted) return;
dragging = true;
overTrash = false;
dwellStart = null;
basePos = { x: clientX - pos.x, y: clientY - pos.y };
root.classList.add('is-dragging');
cancelAnimationFrame(raf);
raf = requestAnimationFrame(shakeTick);
}
function moveDrag(clientX, clientY) {
pos.x = clientX - basePos.x;
pos.y = clientY - basePos.y;
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var isOver = overlapsTrash();
var dwellMs = (isOver && dwellStart !== null) ? performance.now() - dwellStart : 0;
if (isOver && dwellMs > TRASH_DELETE_DWELL_MS) {
deleteIcon();
} else {
caption.textContent = '휴지통 위에 오래 있으면 떨리다 사라져요';
root.classList.remove('is-armed');
trash.classList.remove('is-armed');
applyIcon(0, 0, 1);
}
}
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 (deleted) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!grabbed) {
grabbed = true;
icon.classList.add('is-grabbed');
var r = icon.getBoundingClientRect();
beginDrag(r.left + r.width / 2, r.top + r.height / 2);
caption.textContent = '잡았어요. 화살표로 휴지통 위로 옮기세요';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
dragging = false;
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');
dragging = false;
root.classList.remove('is-dragging', 'is-armed');
trash.classList.remove('is-armed');
applyIcon(0, 0, 1);
caption.textContent = '휴지통 위에 오래 있으면 떨리다 사라져요';
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
});
applyIcon(0, 0, 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 trashRect0 = trash.getBoundingClientRect();
var tx0 = trashRect0.left + trashRect0.width / 2;
var ty0 = trashRect0.top + trashRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 700) {
var p = (t - 200) / 500;
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (tx0 - ox0) * ease;
var cy = oy0 + (ty0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1550) {
// 휴지통 위에서 대기 → 떨림 누적
moveDrag(tx0, ty0);
} else if (t < 1600) {
endDrag();
} else {
if (t > 2600) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysTrashShake.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysTrashShake() {
const rootRef = useRef<HTMLDivElement>(null);
const trashRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const trash = trashRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !trash || !icon || !caption) return;
const TRASH_DWELL_SHAKE_MS = 700;
const TRASH_SHAKE_MAX_PX = 5;
const TRASH_DELETE_DWELL_MS = 550;
const KEY_STEP_PX = 16;
const pos: Pt = { x: 0, y: 0 };
let basePos: Pt = { x: 0, y: 0 };
let dragging = false;
let grabbed = false;
let dwellStart: number | null = null;
let overTrash = false;
let raf = 0;
let deleted = false;
function overlapsTrash() {
const ir = icon!.getBoundingClientRect();
const tr = trash!.getBoundingClientRect();
const icx = ir.left + ir.width / 2, icy = ir.top + ir.height / 2;
return icx > tr.left && icx < tr.right && icy > tr.top && icy < tr.bottom;
}
function applyIcon(shakeX: number, shakeY: number, scale: number) {
icon!.style.transform =
"translate(" + (pos.x + shakeX).toFixed(1) + "px," + (pos.y + shakeY).toFixed(1) + "px) scale(" + scale.toFixed(3) + ")";
}
function shakeTick(ts: number) {
if (deleted) return;
const isOver = overlapsTrash();
if (isOver && !overTrash) { dwellStart = ts; }
overTrash = isOver;
trash!.classList.toggle(styles.isArmed, overTrash);
let shakeX = 0, shakeY = 0;
if (overTrash && dwellStart !== null) {
const dwellMs = ts - dwellStart;
const amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
shakeX = Math.sin(ts / 40) * amp;
shakeY = Math.cos(ts / 55) * amp * 0.6;
if (dwellMs > TRASH_DELETE_DWELL_MS) {
caption!.textContent = "지금 놓으면 삭제돼요";
root!.classList.add(styles.isArmed);
} else {
caption!.textContent = "더 있으면 삭제 준비돼요";
root!.classList.remove(styles.isArmed);
}
} else {
caption!.textContent = dragging ? "휴지통 위로 옮겨보세요" : "휴지통 위에 오래 있으면 떨리다 사라져요";
root!.classList.remove(styles.isArmed);
}
applyIcon(shakeX, shakeY, 1);
if (dragging) raf = requestAnimationFrame(shakeTick);
}
function respawn() {
deleted = false;
pos.x = 0; pos.y = 0;
icon!.style.opacity = "1";
applyIcon(0, 0, 1);
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
}
function deleteIcon() {
deleted = true;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
caption!.textContent = "삭제됐어요";
if (reduce) {
icon!.style.opacity = "0";
let waitStart: number | null = null;
function waitReduce(ts: number) {
if (waitStart === null) waitStart = ts;
if (ts - waitStart < 260) { requestAnimationFrame(waitReduce); } else { respawn(); }
}
requestAnimationFrame(waitReduce);
return;
}
let start: number | null = null;
function shrink(ts: number) {
if (start === null) start = ts;
const p = Math.min(1, (ts - start!) / 260);
applyIcon(0, 0, 1 - p);
icon!.style.opacity = String(1 - p);
if (p < 1) { requestAnimationFrame(shrink); return; }
let waitStart: number | null = null;
function waitRespawn(ts2: number) {
if (waitStart === null) waitStart = ts2;
if (ts2 - waitStart < 260) { requestAnimationFrame(waitRespawn); } else { respawn(); }
}
requestAnimationFrame(waitRespawn);
}
requestAnimationFrame(shrink);
}
function beginDrag(clientX: number, clientY: number) {
if (deleted) return;
dragging = true;
overTrash = false;
dwellStart = null;
basePos = { x: clientX - pos.x, y: clientY - pos.y };
root!.classList.add(styles.isDragging);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(shakeTick);
}
function moveDrag(clientX: number, clientY: number) {
pos.x = clientX - basePos.x;
pos.y = clientY - basePos.y;
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const isOver = overlapsTrash();
const dwellMs = isOver && dwellStart !== null ? performance.now() - dwellStart : 0;
if (isOver && dwellMs > TRASH_DELETE_DWELL_MS) {
deleteIcon();
} else {
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
root!.classList.remove(styles.isArmed);
trash!.classList.remove(styles.isArmed);
applyIcon(0, 0, 1);
}
}
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();
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (deleted) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!grabbed) {
grabbed = true;
icon!.classList.add(styles.isGrabbed);
const r = icon!.getBoundingClientRect();
beginDrag(r.left + r.width / 2, r.top + r.height / 2);
caption!.textContent = "잡았어요. 화살표로 휴지통 위로 옮기세요";
} else {
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
dragging = false;
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);
dragging = false;
root!.classList.remove(styles.isDragging, styles.isArmed);
trash!.classList.remove(styles.isArmed);
applyIcon(0, 0, 1);
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
}
icon.addEventListener("keydown", onKeyDown);
applyIcon(0, 0, 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 trashRect0 = trash.getBoundingClientRect();
const tx0 = trashRect0.left + trashRect0.width / 2;
const ty0 = trashRect0.top + trashRect0.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 < 700) {
const p = (t - 200) / 500;
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (tx0 - ox0) * ease;
const cy = oy0 + (ty0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1550) {
moveDrag(tx0, ty0);
} else if (t < 1600) {
endDrag();
} else {
if (t > 2600) 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.ts} is-demo`} ref={rootRef}>
<div className={styles.trash} ref={trashRef} aria-hidden="true"></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="휴지통 위에 놓으면 떨리다 사라지는 카드. Enter 로 잡고 화살표로 옮긴 뒤 Enter 로 놓아 삭제합니다"
></button>
<p className={styles.caption} ref={captionRef}>휴지통 위에 오래 있으면 떨리다 사라져요</p>
</div>
</main>
);
}07 · 삭제 영역 위에서 떨리는 경고를 보이는 트래시 셰이크 — 측정 누적면적 17.508% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shakeTick(ts) {
if (deleted) return;
var isOver = overlapsTrash();
if (isOver && !overTrash) { dwellStart = ts; }
overTrash = isOver;
trash.classList.toggle('is-armed', overTrash);
var shakeX = 0, shakeY = 0;
var dwellMs = 0;
if (overTrash && dwellStart !== null) {
dwellMs = ts - dwellStart;
var amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
shakeX = Math.sin(ts / 40) * amp;
shakeY = Math.cos(ts / 55) * amp * 0.6;
if (dwellMs > TRASH_DELETE_DWELL_MS) {
caption.textContent = '지금 놓으면 삭제돼요';
root.classList.add('is-armed');
} else {
caption.textContent = '더 있으면 삭제 준비돼요';
root.classList.remove('is-armed');
}
} else {
caption.textContent = dragging ? '휴지통 위로 옮겨보세요' : '휴지통 위에 오래 있으면 떨리다 사라져요';
root.classList.remove('is-armed');
}
applyIcon(shakeX, shakeY, 1);
08끌어당긴 사각형 안쪽만 고르는 라쏘 셀렉트 드래그
빈 자리를 끌면 drawLasso 가 시작점과 현재 좌표로 사각형을 그리고, updateLassoSelection 이 rectsIntersect 로 카드마다 겹침을 매 프레임 판정한다. LASSO_MIN_DRAG_PX(4px) 보다 짧게 움직이면 사각형을 그리지 않아 실수로 스친 클릭이 선택으로 번지지 않는다. 08 항목은 누적면적 14.5%·강도 평균 74.3·23프레임 중 17프레임에서 움직였다.

<main class="stage">
<div class="ls is-demo" id="ls">
<div class="ls__field" id="lsField" role="listbox" aria-multiselectable="true" aria-label="드래그해서 여러 카드를 한 번에 고르는 영역. 카드에 포커스한 뒤 Enter 로 하나씩 고를 수도 있습니다">
<button type="button" class="ls__item" data-idx="0" role="option" aria-selected="false">1</button>
<button type="button" class="ls__item" data-idx="1" role="option" aria-selected="false">2</button>
<button type="button" class="ls__item" data-idx="2" role="option" aria-selected="false">3</button>
<button type="button" class="ls__item" data-idx="3" role="option" aria-selected="false">4</button>
<button type="button" class="ls__item" data-idx="4" role="option" aria-selected="false">5</button>
<button type="button" class="ls__item" data-idx="5" role="option" aria-selected="false">6</button>
<div class="ls__lasso" id="lsLasso"></div>
</div>
<p class="ls__caption" id="lsCaption">빈 곳을 끌면 사각형과 겹친 카드가 골라져요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.ls {
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-3;
}
.ls__field {
touch-action: none;
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-3;
width: 100%;
max-width: 220px;
padding: $sp-3;
border-radius: $r-card;
border: 1px dashed rgba($subject-cream, .22);
}
.ls__item {
appearance: none;
border: 0;
aspect-ratio: 1 / 1;
border-radius: $r-sm;
background: rgba($subject-cream, .1);
color: $subject-cream;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
}
.ls__item.is-selected {
background: $subject-blue;
box-shadow: 0 0 0 2px $subject-blue-light inset;
}
.ls__item:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.ls__lasso {
position: absolute;
pointer-events: none;
border: 1px solid $subject-blue-light;
background: rgba($subject-blue, .16);
border-radius: $r-xs;
opacity: 0;
}
.ls__caption {
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ls__field { max-width: 180px; }
.ls__item { font-size: 12px; }
.ls__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ls__item { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ls__field {
touch-action: none;
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
max-width: 220px;
padding: 12px;
border-radius: 16px;
border: 1px dashed rgba(255, 247, 230, 0.22);
}
.ls__item {
appearance: none;
border: 0;
aspect-ratio: 1/1;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 220ms ease-out, box-shadow 220ms ease-out;
}
.ls__item.is-selected {
background: #2f6df6;
box-shadow: 0 0 0 2px #5b8cf8 inset;
}
.ls__item:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ls__lasso {
position: absolute;
pointer-events: none;
border: 1px solid #5b8cf8;
background: rgba(47, 109, 246, 0.16);
border-radius: 4px;
opacity: 0;
}
.ls__caption {
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ls__field {
max-width: 180px;
}
.ls__item {
font-size: 12px;
}
.ls__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls__item {
transition: none;
}
}(function () {
// 기법: 빈 곳에서 드래그를 시작하면 시작점과 현재 포인터로 사각형(라쏘)을 만들고,
// 매 프레임 그 사각형과 각 카드의 getBoundingClientRect 를 좌표 비교(rectsIntersect)해
// 겹치는 카드만 실시간으로 선택 표시한다.
var root = document.getElementById('ls');
var field = document.getElementById('lsField');
var lasso = document.getElementById('lsLasso');
var caption = document.getElementById('lsCaption');
var items = Array.prototype.slice.call(field.querySelectorAll('.ls__item'));
var LASSO_MIN_DRAG_PX = 4;
var selected = items.map(function () { return false; });
var lassoing = false;
var lassoStart = null;
var focusIdx = 0;
function setSelected(idx, on) {
selected[idx] = on;
items[idx].classList.toggle('is-selected', on);
items[idx].setAttribute('aria-selected', on ? 'true' : 'false');
}
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function updateLassoSelection(rect) {
items.forEach(function (el, idx) {
var r = el.getBoundingClientRect();
setSelected(idx, rectsIntersect(rect, r));
});
}
function drawLasso(x0, y0, x1, y1) {
var left = Math.min(x0, x1), top = Math.min(y0, y1);
var w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
var fieldRect = field.getBoundingClientRect();
lasso.style.left = (left - fieldRect.left) + 'px';
lasso.style.top = (top - fieldRect.top) + 'px';
lasso.style.width = w + 'px';
lasso.style.height = h + 'px';
lasso.style.opacity = '1';
return { left: left, top: top, right: left + w, bottom: top + h };
}
function beginLasso(clientX, clientY) {
lassoing = true;
lassoStart = { x: clientX, y: clientY };
root.classList.add('is-dragging');
caption.textContent = '겹치는 카드가 실시간으로 골라져요';
}
function moveLasso(clientX, clientY) {
if (!lassoing) return;
var rect = drawLasso(lassoStart.x, lassoStart.y, clientX, clientY);
if (Math.abs(clientX - lassoStart.x) > LASSO_MIN_DRAG_PX || Math.abs(clientY - lassoStart.y) > LASSO_MIN_DRAG_PX) {
updateLassoSelection(rect);
}
}
function endLasso() {
lassoing = false;
root.classList.remove('is-dragging');
lasso.style.opacity = '0';
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '개 골랐어요' : '빈 곳을 끌면 사각형과 겹친 카드가 골라져요';
}
field.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
if (e.target.classList.contains('ls__item')) return;
field.setPointerCapture(e.pointerId);
beginLasso(e.clientX, e.clientY);
e.preventDefault();
});
field.addEventListener('pointermove', function (e) {
if (!lassoing) return;
moveLasso(e.clientX, e.clientY);
e.preventDefault();
});
field.addEventListener('pointerup', function (e) {
if (!lassoing) return;
endLasso();
e.preventDefault();
});
items.forEach(function (el, idx) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
e.stopPropagation();
});
el.addEventListener('click', function () {
root.classList.remove('is-demo');
setSelected(idx, !selected[idx]);
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '개 골랐어요' : '빈 곳을 끌면 사각형과 겹친 카드가 골라져요';
});
el.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelected(idx, !selected[idx]);
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '개 골랐어요' : '빈 곳을 끌면 사각형과 겹친 카드가 골라져요';
return;
}
var dir = 0;
if (e.key === 'ArrowRight') dir = 1;
else if (e.key === 'ArrowLeft') dir = -1;
else if (e.key === 'ArrowDown') dir = 3;
else if (e.key === 'ArrowUp') dir = -3;
else if (e.key === 'Escape') {
e.preventDefault();
items.forEach(function (elx, i) { setSelected(i, false); });
caption.textContent = '빈 곳을 끌면 사각형과 겹친 카드가 골라져요';
return;
} else { return; }
e.preventDefault();
focusIdx = Math.max(0, Math.min(items.length - 1, idx + dir));
items[focusIdx].focus({ preventScroll: true });
});
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var fieldRect0 = field.getBoundingClientRect();
var startX = fieldRect0.left + fieldRect0.width * 0.08;
var startY = fieldRect0.top + fieldRect0.height * 0.14;
var endX = fieldRect0.left + fieldRect0.width * 0.62;
var endY = fieldRect0.top + fieldRect0.height * 0.68;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1100) {
var p = Math.min(1, (t - 200) / 900);
var ease = 1 - Math.pow(1 - p, 2);
var cx = startX + (endX - startX) * ease;
var cy = startY + (endY - startY) * ease;
if (t < 220) beginLasso(startX, startY);
moveLasso(cx, cy);
} else if (t < 1450) {
// 선택 결과 관찰
} else if (t < 1480) {
endLasso();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysLassoSelect.module.scss";
type Box<T> = { current: T };
type Rect = { left: number; top: number; right: number; bottom: number };
export default function DragPhysLassoSelect() {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const lassoRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<HTMLButtonElement[]>([]);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const field = fieldRef.current;
const lasso = lassoRef.current;
const caption = captionRef.current;
const items = (itemRefs.current || []).filter(Boolean);
if (!root || !field || !lasso || !caption || items.length !== 6) return;
const LASSO_MIN_DRAG_PX = 4;
const selected: boolean[] = items.map(() => false);
let lassoing = false;
let lassoStart: { x: number; y: number } | null = null;
let focusIdx = 0;
function setSelected(idx: number, on: boolean) {
selected[idx] = on;
items[idx].classList.toggle(styles.isSelected, on);
items[idx].setAttribute("aria-selected", on ? "true" : "false");
}
function rectsIntersect(a: Rect, b: Rect) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function updateLassoSelection(rect: Rect) {
items.forEach(function (el, idx) {
const r = el.getBoundingClientRect();
setSelected(idx, rectsIntersect(rect, r));
});
}
function drawLasso(x0: number, y0: number, x1: number, y1: number): Rect {
const left = Math.min(x0, x1), top = Math.min(y0, y1);
const w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
const fieldRect = field!.getBoundingClientRect();
lasso!.style.left = left - fieldRect.left + "px";
lasso!.style.top = top - fieldRect.top + "px";
lasso!.style.width = w + "px";
lasso!.style.height = h + "px";
lasso!.style.opacity = "1";
return { left: left, top: top, right: left + w, bottom: top + h };
}
function updateCountCaption() {
const count = selected.filter(Boolean).length;
caption!.textContent = count > 0 ? count + "개 골랐어요" : "빈 곳을 끌면 사각형과 겹친 카드가 골라져요";
}
function beginLasso(clientX: number, clientY: number) {
lassoing = true;
lassoStart = { x: clientX, y: clientY };
root!.classList.add(styles.isDragging);
caption!.textContent = "겹치는 카드가 실시간으로 골라져요";
}
function moveLasso(clientX: number, clientY: number) {
if (!lassoing || !lassoStart) return;
const rect = drawLasso(lassoStart.x, lassoStart.y, clientX, clientY);
if (Math.abs(clientX - lassoStart.x) > LASSO_MIN_DRAG_PX || Math.abs(clientY - lassoStart.y) > LASSO_MIN_DRAG_PX) {
updateLassoSelection(rect);
}
}
function endLasso() {
lassoing = false;
root!.classList.remove(styles.isDragging);
lasso!.style.opacity = "0";
updateCountCaption();
}
function onFieldPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
if ((e.target as HTMLElement).classList.contains(styles.item)) return;
field!.setPointerCapture(e.pointerId);
beginLasso(e.clientX, e.clientY);
e.preventDefault();
}
function onFieldPointerMove(e: PointerEvent) {
if (!lassoing) return;
moveLasso(e.clientX, e.clientY);
e.preventDefault();
}
function onFieldPointerUp(e: PointerEvent) {
if (!lassoing) return;
endLasso();
e.preventDefault();
}
field.addEventListener("pointerdown", onFieldPointerDown);
field.addEventListener("pointermove", onFieldPointerMove);
field.addEventListener("pointerup", onFieldPointerUp);
const itemHandlers: { el: HTMLButtonElement; down: (e: PointerEvent) => void; click: () => void; key: (e: KeyboardEvent) => void }[] = [];
items.forEach(function (el, idx) {
function down(e: PointerEvent) {
root!.classList.remove("is-demo");
e.stopPropagation();
}
function click() {
root!.classList.remove("is-demo");
setSelected(idx, !selected[idx]);
updateCountCaption();
}
function key(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(idx, !selected[idx]);
updateCountCaption();
return;
}
let dir = 0;
if (e.key === "ArrowRight") dir = 1;
else if (e.key === "ArrowLeft") dir = -1;
else if (e.key === "ArrowDown") dir = 3;
else if (e.key === "ArrowUp") dir = -3;
else if (e.key === "Escape") {
e.preventDefault();
items.forEach(function (elx, i) { setSelected(i, false); });
updateCountCaption();
return;
} else { return; }
e.preventDefault();
focusIdx = Math.max(0, Math.min(items.length - 1, idx + dir));
items[focusIdx].focus({ preventScroll: true });
}
el.addEventListener("pointerdown", down);
el.addEventListener("click", click);
el.addEventListener("keydown", key);
itemHandlers.push({ el, down, click, key });
});
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const fieldRect0 = field.getBoundingClientRect();
const startX = fieldRect0.left + fieldRect0.width * 0.08;
const startY = fieldRect0.top + fieldRect0.height * 0.14;
const endX = fieldRect0.left + fieldRect0.width * 0.62;
const endY = fieldRect0.top + fieldRect0.height * 0.68;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1100) {
const p = Math.min(1, (t - 200) / 900);
const ease = 1 - Math.pow(1 - p, 2);
const cx = startX + (endX - startX) * ease;
const cy = startY + (endY - startY) * ease;
if (t < 220) beginLasso(startX, startY);
moveLasso(cx, cy);
} else if (t < 1450) {
// 선택 결과 관찰
} else if (t < 1480) {
endLasso();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
field.removeEventListener("pointerdown", onFieldPointerDown);
field.removeEventListener("pointermove", onFieldPointerMove);
field.removeEventListener("pointerup", onFieldPointerUp);
itemHandlers.forEach(function (h) {
h.el.removeEventListener("pointerdown", h.down);
h.el.removeEventListener("click", h.click);
h.el.removeEventListener("keydown", h.key);
});
cancelAnimationFrame(demoRaf);
};
}, []);
const labels = ["1", "2", "3", "4", "5", "6"];
return (
<main className={styles.stage}>
<div className={`${styles.ls} is-demo`} ref={rootRef}>
<div
className={styles.field}
ref={fieldRef}
role="listbox"
aria-multiselectable="true"
aria-label="드래그해서 여러 카드를 한 번에 고르는 영역. 카드에 포커스한 뒤 Enter 로 하나씩 고를 수도 있습니다"
>
{labels.map(function (label, idx) {
return (
<button
key={label}
type="button"
className={styles.item}
role="option"
aria-selected="false"
ref={function (el: HTMLButtonElement | null) { if (el) itemRefs.current![idx] = el; }}
>{label}</button>
);
})}
<div className={styles.lasso} ref={lassoRef}></div>
</div>
<p className={styles.caption} ref={captionRef}>빈 곳을 끌면 사각형과 겹친 카드가 골라져요</p>
</div>
</main>
);
}08 · 끌어당긴 사각형 안쪽만 고르는 라쏘 셀렉트 드래그 — 측정 누적면적 14.549% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function updateLassoSelection(rect) {
items.forEach(function (el, idx) {
var r = el.getBoundingClientRect();
setSelected(idx, rectsIntersect(rect, r));
});
}
function drawLasso(x0, y0, x1, y1) {
var left = Math.min(x0, x1), top = Math.min(y0, y1);
var w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
var fieldRect = field.getBoundingClientRect();
lasso.style.left = (left - fieldRect.left) + 'px';
lasso.style.top = (top - fieldRect.top) + 'px';
lasso.style.width = w + 'px';
lasso.style.height = h + 'px';
lasso.style.opacity = '1';
return { left: left, top: top, right: left + w, bottom: top + h };
}
09여러 물성을 한 보드에 모은 드래그 히어로 장면
endDrag 가 놓은 좌표를 순서대로 검사해 01 의 격자 자석 거리 안이면 스프링으로 칸에 붙이고, 아니면 03 의 경계 판정으로 넘어간다. 경계 안쪽이고 속도가 남아 있으면 06 과 같은 방식의 관성 계산이 이어받아 튕기게 하는 식으로, 세 계산이 한 판 위에서 순서대로 이어진다. 09 항목은 누적면적 8.1%·강도 평균 125.5·23프레임 중 18프레임에서 움직였다.

<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 포함 · 비밀번호 보기
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 }); }
값이 반응을 바꾸는 곳 — 자석 스냅 거리·고무줄 최대 늘어남·감속 계수
아홉 항목 중 세 개(02·06·09)는 아이콘 하나만 움직이고 배경·격자 전체는 그대로라 누적면적이 10% 밑으로 떨어진다 — 화면이 넓게 반응해야 커지는 수치일 뿐이라 컴포넌트 하나짜리 데모에서는 정상이고, 억지로 배경을 흔들지 않았다. 이 중 02 탄성 테더처럼 원점에서 짧게 당겼다 놓는 동작은 드래그 핸들 ui 로 옮겨 쓰기에도 알맞고, 04 처럼 한 줄로 늘어선 칩은 드래그 바 ui 형태의 순서 조정에도 그대로 맞는다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 격자 자석 드래그 | 14.1% | 177.0 | 20/23 |
| 02 탄성 테더 드래그 | 9.7% | 39.6 | 22/23 |
| 03 고무 바운더리 드래그 | 10.5% | 99.0 | 20/23 |
| 04 스왑 드래그 | 22.5% | 103.3 | 17/23 |
| 05 궤도 핸들 드래그 | 11.3% | 186.6 | 23/23 |
| 06 관성 플링 드래그 | 9.3% | 55.6 | 17/23 |
| 07 트래시 셰이크 | 17.5% | 115.5 | 17/23 |
| 08 라쏘 셀렉트 드래그 | 14.5% | 74.3 | 17/23 |
| 09 드래그 히어로 장면 | 8.1% | 125.5 | 18/23 |
이 중 세 상수를 바꾸면 반응 속도가 달라진다. GRID_MAGNET_SNAP_PX 는 자석 격자가 아이콘을 끌어당기기 시작하는 거리를, BOUNDARY_RUBBER_MAX_PX 는 고무 경계가 최대로 늘어나는 거리를, FLING_DECAY_RATE 는 관성 플링이 감속하는 속도를 정한다. 이 세 상수는 바로 다음 변형 표에서 값을 직접 바꿔 비교한다.
어디서 깨지는가 — 터치 스크롤과의 충돌·경계 밖 좌표·키보드 대안 경로
터치 화면에서는 손가락으로 끄는 동작이 곧 페이지 스크롤 제스처와 같아서, touch-action: none 을 걸어 두지 않으면 사각형 선택이든 아이콘 이동이든 페이지 스크롤과 동시에 반응해 버린다 — 아홉 항목 모두 이 속성을 드래그 영역에 걸어 그 충돌을 막아 뒀다(08 라쏘 셀렉트는 선택 사각형이 그려지는 .ls__field 전체에 걸려 있다). 01·02·04·06·07 다섯 항목은 드래그 중 포인터 좌표를 그대로 옮겨 카드 밖으로 나가도 멈추지 않고, 03·09 두 항목만 그 좌표를 경계값 안쪽으로 미리 계산해(03 은 overflowOf 함수, 09 는 같은 계산을 moveDrag 안에 직접 풀어 써서) 클램프해 되돌린다. 포인터가 없어도 아홉 항목 전부 keydown 경로가 열려 있어 Enter 로 잡고 화살표로 옮긴 뒤 Enter 로 다시 놓을 수 있지만, 08 만은 이 잡기·놓기 대신 카드에 포커스를 두고 Enter 를 눌러 하나씩 고르는 다른 경로를 쓴다.
드래그 앤 드롭하는 법을 실무 화면에 그대로 붙일 때는 이 세 갈래(터치 충돌·좌표 클램프·키보드 경로)까지 같이 맞춰야 포인터 없이 쓰는 방문자도 같은 결과를 얻는다. 아홉 항목 전부를 담은 압축 파일을 열려면 dqbvrvq6 을 입력하면 된다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 01 | GRID_MAGNET_SNAP_PX | 24 → 8 | 값을 늘리면 아이콘이 칸에서 멀리 있어도 미리 끌려가 붙고, 값을 줄이면 칸 바로 위까지 가야만 붙는다 |
| 03 | BOUNDARY_RUBBER_MAX_PX | 40 → 90 | 값을 줄이면 경계 밖으로 조금만 밀어도 금방 멈춰 튕기고, 값을 늘리면 한참 더 늘어난 뒤에야 멈춰 튕긴다 |
| 06 | FLING_DECAY_RATE | 0.92 → 0.98 | 값을 줄이면 손을 뗀 뒤 몇 프레임 만에 바로 멈추고, 값을 늘리면 놓은 속도 그대로 훨씬 오래 미끄러진다 |
접근성 처리는 항목 소스에 이미 박힌 matchMedia('(prefers-reduced-motion: reduce)') 분기를 그대로 읽은 것이다. 이 분기는 아홉 항목 전부에 있어 동작 줄임 설정을 켠 방문자에게는 스프링·관성·캐스케이드 없이 목표 상태로 바로 건너뛴다. 포인터 대신 키보드로 조작하는 경로도 아홉 항목 전부 열려 있어 Enter 로 잡고 화살표로 옮긴 뒤 Enter 로 놓을 수 있고, 08 라쏘 셀렉트는 카드마다 포커스를 옮긴 뒤 focus({ preventScroll: true }) 로 화면을 흔들지 않고 Enter 로 하나씩 고르는 별도 경로를 쓴다.
이 아홉 개를 다른 드래그 부품과 묶는 자리
이웃 카드끼리 순서를 바꾸는 화면이라면 드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종 쪽 목표 인덱스 계산과 04 스왑 드래그를 나란히 두고 비교해볼 만하고, 파일을 끌어다 놓는 화면까지 같이 다뤄야 한다면 드래그 앤 드롭 파일 업로드 UI — 드롭존 9종, 파일 데이터 계산을 얹다 쪽 드롭존 판정 방식과 맞춰보는 편이 낫다.
드래그 앤 드롭 구현은 라이브러리 없이도 되는가?
아홉 항목 모두 라이브러리 없이 pointerdown·pointermove·pointerup 이벤트와 requestAnimationFrame 만으로 만들어졌고, HTML5 드래그 앤 드롭 API 인 dragstart·dataTransfer 는 어디에도 쓰지 않았다. 아이콘 하나를 옮기는 드래그 앤 드롭 아이콘 데모(01·02·03·07)든 카드 여러 장을 다루는 데모(04·08·09)든 좌표·시간을 계산하는 같은 방식 위에 얹혀 있어서, 압축 파일을 열어 index.html 그대로 페이지에 넣으면 별도 스크립트를 추가로 물릴 필요가 없다. 각도만 바꿔 도는 05 궤도 핸들처럼 회전 값을 다루는 컨트롤을 더 보고 싶다면 css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤 쪽 각도 계산과 나란히 봐도 좋다.
드래그 앤 드롭 구현을 붙이기 전에 자주 나오는 질문
드래그 앤 드롭하는 법을 라이브러리 없이 먼저 체험해볼 수 있나요?
맨 위 3×3 격자에서 아홉 항목을 전부 바로 눌러볼 수 있고, 압축 파일을 열면 항목마다 index.html 하나로 완결돼 있어 별도 설치 없이 그 자리에서 확인할 수 있다.
09 히어로 장면처럼 여러 물성을 한 화면에 합쳐도 성능에 문제가 없나요?
09 는 01 의 격자 자석·03 의 고무 경계·06 과 같은 방식의 관성 계산 세 가지를 한 보드 위에서 순서대로 검사할 뿐, 세 계산을 동시에 돌리지는 않는다 — endDrag 가 조건을 하나씩 확인해 그중 하나만 실행한다.
08 라쏘 셀렉트를 마우스 없이 키보드만으로 쓸 수 있나요?
가능하다. 사각형을 직접 그리는 대신 카드에 포커스를 옮긴 뒤 Enter 를 눌러 하나씩 고르는 방식이라, 여러 장을 고를 때는 사각형 드래그보다 느리지만 결과는 같다.









