ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算
「ドラッグ アンド ドロップ 実装」の作り方は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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 距離内に入るとセルが引き寄せるグリッド磁力ドラッグ
- 02 原点とロープでつながったように伸びる弾性テザードラッグ
- 03 境界外に押すと伸びて戻るゴム境界
- 04 隣の中間点を越えると入れ替わるスワップドラッグ
- 05 中心を基準に角度だけ変わる軌道ハンドルドラッグ
- 06 離す速度が残りの距離を押す慣性フリングドラッグ
- 07 削除ゾーン上で震える警告を見せるトラッシュシェイク
- 08 引いた四角形の内側だけを選ぶ投げ縄セレクトドラッグ
- 09 複数の物性を一つのボードに集めたドラッグヒーロー場面
ドラッグ アンド ドロップ 実装 9パターン — 指を離した後も続く計算
以下の9パターンはすべて同じ問いに別々の答えを出しています — ポインターが動いている間と、指を離した瞬間の間で、ドラッグ対象は何をすべきかという問いです。01番はポインターが押されている間だけ反応し、距離が基準を下回った瞬間にセルへ向かって吸着します。03番になると同じ反応が上限で頭打ちになり、05番と06番は指を離した後も何かが動き続け、09番はここまでの仕組み — グリッド磁石・ゴム境界・慣性フリング — の三つを一つの盤にまとめています。全体としては、ポインターが押されている間だけ意味を持つ値から、手を離した後もしばらく動きを決め続ける値へと移っていきます。
01セルに吸着するグリッド磁石
updateMagnetHighlight() はポインターが動くたびにドラッグ中のアイコンからグリッドの各セル中心までの距離を測っていて、その距離が GRID_MAGNET_SNAP_PX を下回った瞬間に最も近いセルへ is-target が付き、アイコンの位置は残り距離から算出した引き寄せ係数ぶんだけそのセルへ寄っていきます。その半径の内側で指を離すと settleTick() が SPRING_STIFFNESS と SPRING_DAMPING でアイコンを最後までセルへ運び、瞬間移動はしません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
01番は累積面積14.1%・平均強度177.0を記録し、動いたのは20/23フレームです。
02起点まで伸びて戻る弾性テザー
drawTether() は毎フレーム、アイコンと起点それぞれの実測の境界矩形から線を引き直していて、一つのdiv要素を回転させて二点間の距離と角度に合わせています。その同じ距離が TETHER_THICKNESS_BASE_PX を通じて線の太さも決めていて、指を離すと settleTick() が TETHER_SPRING_STIFFNESS と TETHER_SPRING_DAMPING でアイコンを起点へ向けてばねのように戻し、線の長さがゼロになるまで続きます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
02番は累積面積9.7%・平均強度39.6を記録し、動いたのは22/23フレームです。
03境界でつぶれるゴムのような伸び縮み
overflowOf() はポインターの座標がコンテナの端をどれだけ越えたかを測っていて、applyVisual() がその超過分を BOUNDARY_RUBBER_MAX_PX で頭打ちにした比率へ変換し、アイコンの拡大率を伸ばしつつ引かれている方向へ傾けます。端の外側で指を離すと BOUNDARY_SPRING_STIFFNESS と BOUNDARY_SPRING_DAMPING でアイコンを内側へ引き戻していて、伸びを決めた同じ二つの数値が戻りの跳ね方も決めています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
03番は累積面積10.5%・平均強度99.0を記録し、動いたのは20/23フレームです。
04中点を越えると隣と入れ替わるスワップ
moveDrag() はポインターが動くたびにドラッグ中のチップの画面上の中心を計算し直し、最も近いスロットへ丸めていて、そのスロットが現在のスロットと違う間は moveInOrder() が二つの位置を順序配列の中で入れ替え続けます。入れ替わるたびに retarget() と kickSpring() が呼ばれ、動かしていない他のチップを SWAP_FLIP_STIFFNESS と SWAP_FLIP_DAMPING で新しいスロットへ、瞬時にではなく運んでいきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 = '中点を越えると隣と場所を入れ替えます';
04番は累積面積22.5%・平均強度103.3を記録し、動いたのは17/23フレームです。
05ハブを中心に半径固定で回る軌道ハンドル
angleFromPointer() は Math.atan2() を使ってポインターの座標をハブの固定中心を軸にした一つの角度へ変換していて、applyHandle() は常にハンドルを Math.cos(angle) * ORBIT_RADIUS_PX とそれに対応するsin項の位置へ置くため、半径そのものは変わりません。ハンドルを離すとその角度の最後の変化率が inertiaTick() へ渡され、ORBIT_DECAY_RATE を毎フレーム角速度へ掛け続けて回転が収まるまで動かします。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
05番は累積面積11.3%・平均強度186.6を記録し、動いたのは23/23フレームです。
06壁で跳ね返る慣性フリング
flingTick() は毎フレーム速度ぶんだけボールの位置を進めていて、bounds() を越えた瞬間にその軸の速度成分を反転させ、そのフレームに is-bounce クラスを付けます。同じ関数が毎フレーム両方の速度成分に FLING_DECAY_RATE を掛けていくため、ボールは速度が1フレームあたり4pxを下回るまで滑って跳ね続け、そこで flingTick() が止めます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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');
06番は累積面積9.3%・平均強度55.6を記録し、動いたのは17/23フレームです。
07ゴミ箱の上で震えてから消えるトラッシュゾーン
shakeTick() はアイコンがゴミ箱ゾーンに重なっている時間を dwellMs という一つの値にまとめていて、その時間を TRASH_DWELL_SHAKE_MS に対する比率へ変換してサインとコサインのシェイク幅を決め、上限は TRASH_SHAKE_MAX_PX です。dwellMs が TRASH_DELETE_DWELL_MS を超えると、今離せば削除されると警告するキャプションへ切り替わるため、同じタイマーが震えの大きさと「離せば削除」の判定の両方を担っています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
07番は累積面積17.5%・平均強度115.5を記録し、動いたのは17/23フレームです。
08触れたものを拾い上げるラッソ選択
drawLasso() は毎フレーム、ドラッグの開始点から現在のポインター位置までの矩形を描き直していて、そのドラッグが LASSO_MIN_DRAG_PX を越えると updateLassoSelection() が各カードの実測境界矩形へ rectsIntersect() を毎回当てて選択状態を決めます。この領域自体に role="listbox" が付いているため、キーボード操作だけでもカードを一枚ずつフォーカスしてEnterで選択でき、矩形をドラッグする必要はありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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';
08番は累積面積14.5%・平均強度74.3を記録し、動いたのは17/23フレームです。
09磁石・ゴム・フリングを一つにまとめたヒーロー盤面
同じドラッグ処理はまず nearestCell() を HERO_GRID_MAGNET_SNAP_PX と照らし合わせていて、どのセルも近くなければ盤の端を越えた超過分を HERO_BOUNDARY_RUBBER_MAX_PX で頭打ちにしてアイコンの拡大率をつぶす方へ戻ります。磁石セルの外で指を離すと06番と同じフリングのループにアイコンが渡され、ここでは FLING_DECAY_RATE の代わりに HERO_FLING_DECAY_RATE で減衰するため、一つの盤が一つのポインター処理から三つの仕組みすべてを動かしています。

<main class="stage">
<div class="hb is-demo" id="hb">
<div class="hb__board" id="hbBoard">
<div class="hb__cell" id="hbCellA" style="left:10%;top:12%;"></div>
<div class="hb__cell" id="hbCellB" style="right:10%;bottom:12%;"></div>
<button type="button" class="hb__icon" id="hbIcon" aria-label="磁石マスに吸着したり、境界で押しつぶれたり、投げると跳ねながら飛んでいくアイコン。Enterで掴み、矢印キーで移動、Enterで離します"></button>
</div>
<p class="hb__caption" id="hbCaption">磁石マス・ゴム境界・投げの慣性を一つの盤で試してみてください</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hb {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.hb__board {
position: relative;
width: 100%;
max-width: 300px;
height: 200px;
border-radius: $r-card;
border: 1px dashed rgba($subject-ink, .22);
background: rgba($subject-ink, .04);
overflow: hidden;
}
.hb__cell {
position: absolute;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: $r-sm;
border: 2px dashed rgba($subject-blue, .5);
transition: background-color .15s ease-out, border-color .15s ease-out;
}
.hb__cell.is-target {
background: rgba($subject-blue, .18);
border-color: $subject-blue;
border-style: solid;
}
.hb__icon {
appearance: none;
border: 0;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: 0 6px 14px rgba($subject-ink, .18);
touch-action: none;
cursor: grab;
transform-origin: center;
will-change: transform;
}
.hb.is-dragging .hb__icon { cursor: grabbing; }
.hb__icon.is-grabbed {
box-shadow: 0 0 0 3px $subject-blue-light, 0 6px 14px rgba($subject-ink, .18);
}
.hb__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.hb__caption {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
min-height: 2.8em;
}
@media (max-width: 340px) {
.hb { gap: $sp-2; }
.hb__board { max-width: 200px; height: 110px; }
.hb__cell { width: 30px; height: 30px; margin: -15px 0 0 -15px; }
.hb__icon { width: 30px; height: 30px; margin: -15px 0 0 -15px; }
.hb__caption { font-size: 10px; min-height: 1.8em; }
}
@media (prefers-reduced-motion: reduce) {
.hb__cell { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hb {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.hb__board {
position: relative;
width: 100%;
max-width: 300px;
height: 200px;
border-radius: 16px;
border: 1px dashed rgba(23, 20, 26, 0.22);
background: rgba(23, 20, 26, 0.04);
overflow: hidden;
}
.hb__cell {
position: absolute;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: 8px;
border: 2px dashed rgba(47, 109, 246, 0.5);
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
}
.hb__cell.is-target {
background: rgba(47, 109, 246, 0.18);
border-color: #2f6df6;
border-style: solid;
}
.hb__icon {
appearance: none;
border: 0;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 6px 14px rgba(23, 20, 26, 0.18);
touch-action: none;
cursor: grab;
transform-origin: center;
will-change: transform;
}
.hb.is-dragging .hb__icon {
cursor: grabbing;
}
.hb__icon.is-grabbed {
box-shadow: 0 0 0 3px #5b8cf8, 0 6px 14px rgba(23, 20, 26, 0.18);
}
.hb__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.hb__caption {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
min-height: 2.8em;
}
@media (max-width: 340px) {
.hb {
gap: 8px;
}
.hb__board {
max-width: 200px;
height: 110px;
}
.hb__cell {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
}
.hb__icon {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
}
.hb__caption {
font-size: 10px;
min-height: 1.8em;
}
}
@media (prefers-reduced-motion: reduce) {
.hb__cell {
transition: none;
}
}(function () {
// 기법: 01(격자 자석)·03(경계 고무)·06(관성 플링)의 계산을 한 아이콘에 합쳐,
// 자석 칸 근처면 스냅, 보드 경계 밖이면 클램프+스퀴시, 그 외에는 마지막 속도로
// 관성 플링+벽 반사를 적용한다. 우선순위: 자석 > 경계 > 플링.
var root = document.getElementById('hb');
var board = document.getElementById('hbBoard');
var icon = document.getElementById('hbIcon');
var caption = document.getElementById('hbCaption');
var cells = [document.getElementById('hbCellA'), document.getElementById('hbCellB')];
var HERO_GRID_MAGNET_SNAP_PX = 26;
var HERO_BOUNDARY_RUBBER_MAX_PX = 36;
var HERO_FLING_DECAY_RATE = 0.93;
var HERO_SPRING_STIFFNESS = 190;
var HERO_SPRING_DAMPING = 19;
var KEY_STEP_PX = 16;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var origin = null;
var pointerStart = null;
var startPos = null;
var lastSample = null;
var dragging = false;
var grabbed = false;
var mode = 'idle';
var raf = 0, lastTs = null;
function iconOrigin() {
if (origin) return origin;
var r = icon.getBoundingClientRect();
origin = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return origin;
}
function boardBounds() {
var br = board.getBoundingClientRect();
var ir = icon.getBoundingClientRect();
var halfW = ir.width / 2, halfH = ir.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function nearestCell(cx, cy) {
var best = null, bestD = Infinity;
cells.forEach(function (cell) {
var cr = cell.getBoundingClientRect();
var ccx = cr.left + cr.width / 2, ccy = cr.top + cr.height / 2;
var d = Math.hypot(cx - ccx, cy - ccy);
if (d < bestD) { bestD = d; best = { cell: cell, x: ccx, y: ccy, dist: d }; }
});
return best;
}
function applyIcon(scaleX, scaleY) {
var sx = scaleX === undefined ? 1 : scaleX;
var sy = scaleY === undefined ? 1 : scaleY;
icon.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root.classList.add('is-dragging');
cells.forEach(function (c) { c.classList.remove('is-target'); });
}
function moveDrag(clientX, clientY) {
var rawX = startPos.x + (clientX - pointerStart.x);
var rawY = startPos.y + (clientY - pointerStart.y);
var o = iconOrigin();
var cx = o.x + rawX, cy = o.y + rawY;
var near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove('is-target'); });
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
near.cell.classList.add('is-target');
var pull = Math.max(0, 1 - near.dist / HERO_GRID_MAGNET_SNAP_PX) * 0.6;
pos.x = rawX + ((near.x - o.x) - rawX) * pull;
pos.y = rawY + ((near.y - o.y) - rawY) * pull;
applyIcon(1, 1);
caption.textContent = '磁石マスに吸着します';
} else {
var b = boardBounds();
var overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
var clampedX = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overX));
var clampedY = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overY));
pos.x = rawX - (overX - clampedX);
pos.y = rawY - (overY - clampedY);
var ratio = Math.max(Math.abs(clampedX), Math.abs(clampedY)) / HERO_BOUNDARY_RUBBER_MAX_PX;
applyIcon(1 + ratio * 0.22, 1 - ratio * 0.18);
caption.textContent = '境界でゴムのように押しつぶれます';
} else {
pos.x = rawX; pos.y = rawY;
applyIcon(1, 1);
caption.textContent = '離すと速度どおりに投げ出されます';
}
}
var now = performance.now();
var dt = Math.max((now - lastSample.t) / 1000, 0.001);
vel.x = (clientX - lastSample.x) / dt;
vel.y = (clientY - lastSample.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function settleSpring(target) {
mode = 'spring';
lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (target.x - pos.x) * HERO_SPRING_STIFFNESS - vel.x * HERO_SPRING_DAMPING;
var fy = (target.y - pos.y) * HERO_SPRING_STIFFNESS - vel.y * HERO_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon(1, 1);
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(target.x - pos.x, target.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(tick); return; }
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
applyIcon(1, 1);
mode = 'idle';
}
raf = requestAnimationFrame(tick);
}
function flingSettle() {
mode = 'fling';
lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt; pos.y += vel.y * dt;
var o = iconOrigin();
var b = boardBounds();
var cx = o.x + pos.x, cy = o.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); }
vel.x *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
applyIcon(1, 1);
var speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(tick); return; }
vel.x = 0; vel.y = 0;
mode = 'idle';
}
raf = requestAnimationFrame(tick);
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var o = iconOrigin();
var cx = o.x + pos.x, cy = o.y + pos.y;
var near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove('is-target'); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
caption.textContent = '磁石マスに吸着しました';
var target = { x: near.x - o.x, y: near.y - o.y };
if (reduce) { pos.x = target.x; pos.y = target.y; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring(target); }
return;
}
var b = boardBounds();
var overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
caption.textContent = '境界の外なので内側へ戻ります';
var clampX = Math.max(b.minX, Math.min(b.maxX, cx)) - o.x;
var clampY = Math.max(b.minY, Math.min(b.maxY, cy)) - o.y;
if (reduce) { pos.x = clampX; pos.y = clampY; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring({ x: clampX, y: clampY }); }
return;
}
caption.textContent = '磁石マス・ゴム境界・投げの慣性を一つの盤で試してみてください';
if (reduce || Math.hypot(vel.x, vel.y) < 30) { vel.x = 0; vel.y = 0; }
else { cancelAnimationFrame(raf); flingSettle(); }
}
icon.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
icon.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
e.preventDefault();
});
icon.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!grabbed) {
grabbed = true;
cancelAnimationFrame(raf);
icon.classList.add('is-grabbed');
caption.textContent = '掴みました。矢印キーで動かし、Enterで離してください';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
endDrag();
}
return;
}
if (!grabbed) return;
var dx = 0, dy = 0;
if (e.key === 'ArrowLeft') dx = -KEY_STEP_PX;
else if (e.key === 'ArrowRight') dx = KEY_STEP_PX;
else if (e.key === 'ArrowUp') dy = -KEY_STEP_PX;
else if (e.key === 'ArrowDown') dy = KEY_STEP_PX;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
icon.classList.remove('is-grabbed');
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon(1, 1);
});
applyIcon(1, 1);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var iconRect0 = icon.getBoundingClientRect();
var ox0 = iconRect0.left + iconRect0.width / 2;
var oy0 = iconRect0.top + iconRect0.height / 2;
var cellRect0 = cells[0].getBoundingClientRect();
var gx0 = cellRect0.left + cellRect0.width / 2;
var gy0 = cellRect0.top + cellRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 800) {
var p = Math.min(1, (t - 200) / 600);
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (gx0 - ox0) * ease;
var cy = oy0 + (gy0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 950) {
// 대기 (자석 확인)
} else if (t < 980) {
endDrag();
} else if (t < 1400) {
// 정착 관찰
} else if (t < 1420) {
beginDrag(gx0, gy0);
} else if (t < 1650) {
var p2 = (t - 1420) / 230;
moveDrag(gx0 + p2 * 70, gy0 - p2 * 20);
} else if (t < 1680) {
endDrag();
} else {
if (t > 2200) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysHeroBoard.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysHeroBoard() {
const rootRef = useRef<HTMLDivElement>(null);
const boardRef = useRef<HTMLDivElement>(null);
const cellARef = useRef<HTMLDivElement>(null);
const cellBRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const board = boardRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
const cells = [cellARef.current, cellBRef.current].filter(Boolean) as HTMLDivElement[];
if (!root || !board || !icon || !caption || cells.length !== 2) return;
const HERO_GRID_MAGNET_SNAP_PX = 26;
const HERO_BOUNDARY_RUBBER_MAX_PX = 36;
const HERO_FLING_DECAY_RATE = 0.93;
const HERO_SPRING_STIFFNESS = 190;
const HERO_SPRING_DAMPING = 19;
const KEY_STEP_PX = 16;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let origin: Pt | null = null;
let pointerStart: Pt | null = null;
let startPos: Pt | null = null;
let lastSample: { x: number; y: number; t: number } | null = null;
let dragging = false;
let grabbed = false;
let raf = 0;
let lastTs: number | null = null;
function iconOrigin(): Pt {
if (origin) return origin;
const r = icon!.getBoundingClientRect();
origin = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return origin;
}
function boardBounds() {
const br = board!.getBoundingClientRect();
const ir = icon!.getBoundingClientRect();
const halfW = ir.width / 2, halfH = ir.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
type NearestCell = { cell: HTMLDivElement; x: number; y: number; dist: number };
function nearestCell(cx: number, cy: number): NearestCell | null {
let best: NearestCell | null = null;
let bestD = Infinity;
cells.forEach(function (cell) {
const cr = cell.getBoundingClientRect();
const ccx = cr.left + cr.width / 2, ccy = cr.top + cr.height / 2;
const d = Math.hypot(cx - ccx, cy - ccy);
if (d < bestD) { bestD = d; best = { cell: cell, x: ccx, y: ccy, dist: d }; }
});
return best;
}
function applyIcon(scaleX?: number, scaleY?: number) {
const sx = scaleX === undefined ? 1 : scaleX;
const sy = scaleY === undefined ? 1 : scaleY;
icon!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root!.classList.add(styles.isDragging);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
}
function moveDrag(clientX: number, clientY: number) {
const rawX = startPos!.x + (clientX - pointerStart!.x);
const rawY = startPos!.y + (clientY - pointerStart!.y);
const o = iconOrigin();
const cx = o.x + rawX, cy = o.y + rawY;
const near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
near.cell.classList.add(styles.isTarget);
const pull = Math.max(0, 1 - near.dist / HERO_GRID_MAGNET_SNAP_PX) * 0.6;
pos.x = rawX + ((near.x - o.x) - rawX) * pull;
pos.y = rawY + ((near.y - o.y) - rawY) * pull;
applyIcon(1, 1);
caption!.textContent = "자석 칸에 붙어요";
} else {
const b = boardBounds();
let overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
const clampedX = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overX));
const clampedY = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overY));
pos.x = rawX - (overX - clampedX);
pos.y = rawY - (overY - clampedY);
const ratio = Math.max(Math.abs(clampedX), Math.abs(clampedY)) / HERO_BOUNDARY_RUBBER_MAX_PX;
applyIcon(1 + ratio * 0.22, 1 - ratio * 0.18);
caption!.textContent = "경계에서 고무처럼 눌려요";
} else {
pos.x = rawX; pos.y = rawY;
applyIcon(1, 1);
caption!.textContent = "놓으면 속도대로 던져져요";
}
}
const now = performance.now();
const dt = Math.max((now - lastSample!.t) / 1000, 0.001);
vel.x = (clientX - lastSample!.x) / dt;
vel.y = (clientY - lastSample!.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function settleSpring(target: Pt) {
lastTs = null;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (target.x - pos.x) * HERO_SPRING_STIFFNESS - vel.x * HERO_SPRING_DAMPING;
const fy = (target.y - pos.y) * HERO_SPRING_STIFFNESS - vel.y * HERO_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon(1, 1);
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(target.x - pos.x, target.y - pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(tick); return; }
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
applyIcon(1, 1);
}
raf = requestAnimationFrame(tick);
}
function flingSettle() {
lastTs = null;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt; pos.y += vel.y * dt;
const o = iconOrigin();
const b = boardBounds();
const cx = o.x + pos.x, cy = o.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); }
vel.x *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(HERO_FLING_DECAY_RATE, dt * 60);
applyIcon(1, 1);
const speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(tick); return; }
vel.x = 0; vel.y = 0;
}
raf = requestAnimationFrame(tick);
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const o = iconOrigin();
const cx = o.x + pos.x, cy = o.y + pos.y;
const near = nearestCell(cx, cy);
cells.forEach(function (c) { c.classList.remove(styles.isTarget); });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
caption!.textContent = "자석 칸에 붙었어요";
const target = { x: near.x - o.x, y: near.y - o.y };
if (reduce) { pos.x = target.x; pos.y = target.y; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring(target); }
return;
}
const b = boardBounds();
let overX = 0, overY = 0;
if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
if (overX !== 0 || overY !== 0) {
caption!.textContent = "경계 밖이라 안으로 되돌아가요";
const clampX = Math.max(b.minX, Math.min(b.maxX, cx)) - o.x;
const clampY = Math.max(b.minY, Math.min(b.maxY, cy)) - o.y;
if (reduce) { pos.x = clampX; pos.y = clampY; applyIcon(1, 1); }
else { cancelAnimationFrame(raf); settleSpring({ x: clampX, y: clampY }); }
return;
}
caption!.textContent = "자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요";
if (reduce || Math.hypot(vel.x, vel.y) < 30) { vel.x = 0; vel.y = 0; }
else { cancelAnimationFrame(raf); flingSettle(); }
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
icon!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
e.preventDefault();
}
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!grabbed) {
grabbed = true;
cancelAnimationFrame(raf);
icon!.classList.add(styles.isGrabbed);
caption!.textContent = "잡았어요. 화살표로 옮기고 Enter 로 놓으세요";
} else {
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
endDrag();
}
return;
}
if (!grabbed) return;
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") dx = -KEY_STEP_PX;
else if (e.key === "ArrowRight") dx = KEY_STEP_PX;
else if (e.key === "ArrowUp") dy = -KEY_STEP_PX;
else if (e.key === "ArrowDown") dy = KEY_STEP_PX;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon(1, 1);
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
icon.addEventListener("keydown", onKeyDown);
applyIcon(1, 1);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const iconRect0 = icon.getBoundingClientRect();
const ox0 = iconRect0.left + iconRect0.width / 2;
const oy0 = iconRect0.top + iconRect0.height / 2;
const cellRect0 = cells[0].getBoundingClientRect();
const gx0 = cellRect0.left + cellRect0.width / 2;
const gy0 = cellRect0.top + cellRect0.height / 2;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 800) {
const p = Math.min(1, (t - 200) / 600);
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (gx0 - ox0) * ease;
const cy = oy0 + (gy0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 950) {
// 대기
} else if (t < 980) {
endDrag();
} else if (t < 1400) {
// 정착 관찰
} else if (t < 1420) {
beginDrag(gx0, gy0);
} else if (t < 1650) {
const p2 = (t - 1420) / 230;
moveDrag(gx0 + p2 * 70, gy0 - p2 * 20);
} else if (t < 1680) {
endDrag();
} else {
if (t > 2200) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
icon.removeEventListener("pointerdown", onPointerDown);
icon.removeEventListener("pointermove", onPointerMove);
icon.removeEventListener("pointerup", onPointerUp);
icon.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.hb} is-demo`} ref={rootRef}>
<div className={styles.board} ref={boardRef}>
<div className={styles.cell} ref={cellARef} style={{ left: "10%", top: "12%" }}></div>
<div className={styles.cell} ref={cellBRef} style={{ right: "10%", bottom: "12%" }}></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="자석 칸에 붙거나, 경계에서 눌리거나, 던지면 튕기며 날아가는 아이콘. Enter 로 잡고 화살표로 이동, Enter 로 놓습니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>자석 칸 · 고무 경계 · 던지기 관성을 한 판에서 써보세요</p>
</div>
</main>
);
}09 · 複数の物性を一つのボードに集めたドラッグヒーロー場面 — 計測 累積面積 8.088% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 = '境界でゴムのように押しつぶれます';
09番は累積面積8.1%・平均強度125.5を記録し、動いたのは18/23フレームです。
9パターンのうちどれがどのドラッグ操作に向くか
どんなドラッグ操作にも、掴んでから離すまでの間に何が起きるかを決める仕組みが要りますが、この9つのうちどれが向くかは実際に何を動かすかで変わります。01番のグリッド磁石は必ずどこか一つのマスに収まるべきアイコンに向いていて、ホーム画面のレイアウトのような場面に合います。03番のゴム境界は、どれだけ強く引かれても決まった枠の中にとどまるべきカードに向いています。04番の中点スワップは並び替えリストが必要とする形で、独自の挿入先インデックス計算を持つ姉妹編が ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種 にあります。07番の削除ゾーン滞在タイマーは配置ではなく削除を行うドロップ先の隣に置くべきもので、計算されたドロップゾーンのファイルアップロード版は ドラッグ アンド ドロップ ファイル アップロードUI — ドロップゾーン9種の計算 にあります。05番の固定半径の軌道は、平面ではなく円周上を動く値に最も近い形で、ダイヤル側から同じ考え方を扱っているのが css ゲージ 9選 — 計算で回る円形コントロール で、css ドラッグ 移動を探しているならこの9つのうちどれがそのまま当てはまるかを先に決めておく価値があります。
9つのドラッグ物性パターンを面積と強度で測る
累積面積は固定カメラの1フレームのうち実際に塗り直された割合を測定フレーム全体で平均したもので、強度はその塗り直しが起きた場所の平均画素差です — 以下の9つの多くが25%未満なのは、静止したカードの上で小さなアイコンやチップやボール一つだけが動いているからです。
| パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 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 フレーム |
9つのうち02・06・09番の三つは累積面積が10%を下回っていますが、これは欠陥ではなく想定どおりです — テザーの線一本、小さなボール一つ、盤の上のアイコン一つは、その下の物理計算がどう動いても固定カメラの1フレームをそう大きくは覆いません。
値が反応を変える場所 — 磁力スナップ距離・ゴムの最大伸び・減衰係数
決め手になる数値は主に三つです。GRID_MAGNET_SNAP_PX は01番のアイコンがセルの中心にどれだけ近づけば引き寄せが始まるかを決め、BOUNDARY_RUBBER_MAX_PX は03番のアイコンがコンテナの端をどこまで越えて伸びられるかの上限を決め、FLING_DECAY_RATE は06番の離した瞬間の速度が1フレームごとにどれだけ残るかを決めています。三つとも描かれる形そのもの — 磁力の吸着先・伸びたアイコン・跳ねるボール — は変えず、ポインターがどれだけ動けばその形が反応するか、そして指を離した後どれだけ長く動き続けるかだけを変えます。
どこで壊れるか — タッチスクロールとの衝突・境界外座標・キーボード代替経路
9つとも touch-action: none をどこかに指定していますが、範囲は同じではありません。8つは実際にドラッグされる要素一つ — アイコン・チップ・ハンドル・ボール — だけに絞っていて、カードのそれ以外の場所へのタッチ操作はページ自体のスクロールとして普通に働きます。08番だけは領域全体の .ls__field に指定していて、その領域内のどこからでもラッソが始まるため、どこも取りこぼさずページのスクロールへ渡さないようにしています。9つのうち6つ — 01・02・04・05・07・08番 — は bounds() や boardBounds() のような関数でポインター由来の位置を境界内へ収めておらず、指やポインターがデモカードの外へ出た後も動き続けると、離すまで何も引き戻さないままアイコンやチップやラッソの矩形が表示枠の外まで運ばれます。03・06・09番だけがその境界クランプを明示的に持っています。キーボード代替経路はそれとは無関係にどの番号でも同じ形で、ドラッグ対象の要素自身に張られた keydown がEnterで掴み、矢印キーで動かし、もう一度Enterで放すところまで担っていて、ポインターを失っても境界を越えてもインタラクション自体は失われません。9つを支える index.html・style.scss の組は、dqbvrvq6、自分の盤にも同じ境界クランプが足りているかを実際のコードで確かめる一番早い方法です。
変形3つとアクセシビリティ
| 変形 | 値の変化 | 感触 |
|---|---|---|
| 磁力グリッドのスナップ距離を調整する | 01 GRID_MAGNET_SNAP_PX 24 → 8 | 値を上げるとアイコンはセルから離れていても早めに引き寄せられて付き、値を下げるとセルの真上近くまで来て初めて吸着する。 |
| ゴム境界の最大伸びを調整する | 03 BOUNDARY_RUBBER_MAX_PX 40 → 90 | 値を下げると境界の外へ少し押しただけですぐ止まって跳ね返り、値を上げるとずっと長く伸びてから止まって跳ね返る。 |
| 慣性フリングの減衰係数を調整する | 06 FLING_DECAY_RATE 0.92 → 0.98 | 値を下げると指を離してから数フレームですぐ止まり、値を上げると離した速度のままずっと長く滑り続ける。 |
9つのうちどれも aria-live でアイコンがセルに吸着したことやカードがラッソ選択に加わったことを読み上げでは伝えません — マークアップ自体が持つ状態変化以上の案内はありません。9つとも同じ matchMedia('(prefers-reduced-motion: reduce)') で prefers-reduced-motion を確認していて、使い道は二つ共通しています。ドラッグする前に流れるアイドル状態のアトラクトループを飛ばすことと、物理演算そのものの内部でバネやフリングやシェイクを再生せずに静止位置へ直接移すことです。08番のラッソ選択領域だけが focus({ preventScroll: true }) も呼んでいて、選択リスト内のカードへTabで移動してもデモカードが画面外へスクロールされません。Pointer Events・requestAnimationFrame()・prefers-reduced-motion・transform はいずれもMDNに文書があります。
ドラッグ アンド ドロップ 実装 はライブラリなしでもできるか?
この9つはすべて、HTML5のドラッグ&ドロップAPIが使う dragstart・dragover・drop ではなく、Pointer Events API の pointerdown・pointermove・pointerup・pointercancel の上で動いています。この一つのイベント群だけでマウス・タッチ・ペンの入力がすでに揃うため、入力元を揃えるためにも、バネやフリングや磁石の計算をするためにも、9つのうちどれもcss ドラッグアンドドロップのライブラリを呼んでいません。上に書いた計算はすべて requestAnimationFrame() の中の普通の数値だけで動いていて、ドラッグ ui を新しく組むたびに、ドラッグ アンド ドロップ やり方を一から調べ直す必要はありません。
指を離す前に確認しておきたいFAQ
これはHTML5のドラッグ&ドロップAPIを使っていますか?
いいえ — 9つとも dragstart・dragover・drop ではなく、実際の要素に pointerdown・pointermove・pointerup/pointercancel を張っています。draggable="true" を持つ要素も DataTransfer を読む箇所も9つの中に一つもなく、同じコードがマウス・タッチ・ペンのどれでも動くのはそのためです。
9つとも一切ポインターなしで操作できますか?
はい — 9つとも同じドラッグ対象の要素に keydown を張っていて、Enterで掴み、矢印キーで動かし、もう一度Enterで放すという同じ手順に従っています。08番のラッソだけは role="listbox" を持つ領域がもう一つの経路を足していて、フォーカスしたカードをEnterで一枚ずつ選ぶこともできます。
9つのうちどれが最もフレームを塗り直す面積が小さいですか、その理由は?
09番のヒーロー場面が累積面積8.1%で最も小さく、06番のフリング(9.3%)・02番のテザー(9.7%)がそれに続きます。一つの小さなアイコンやボールだけを動かす方式はその周囲のピクセルしか塗り直しませんが、04番のスワップは行のチップ全部を同時に描き直すため22.5%まで上がります。









