01Grid-Magnet Drag That Pulls an Item Into a Cell Within Range
The distance between the dragged icon and every grid cell is measured live, and once that distance crosses a threshold, the nearest cell pulls the icon into place.
Best forIcon-organizing home screen or widget layout board
GRID_MAGNET_SNAP_PXSPRING_STIFFNESSSPRING_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="An icon you drag onto a grid cell. Press Enter to grab, arrow keys to move, Enter again to drop"></button>
</div>
<p class="gm__caption" id="gmCaption">Drop it near a cell and it snaps in like a magnet</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 = 'Snapped into the cell';
root.classList.add('is-snapped');
} else {
caption.textContent = 'Drop it near a cell and it snaps in like a magnet';
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 = 'Grabbed. Move with arrow keys, press Enter to drop';
} 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 = 'Drop it near a cell and it snaps in like a magnet';
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 Grid-Magnet Drag That Pulls an Item Into a Cell Within Range — Measured area 14.062% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop Animation CSS: 9 Physics Patterns
The post this demo lives in
Drag and Drop Animation CSS: 9 Physics PatternsNine drag and drop animation css builds: A magnetic grid catches the icon inside a 24px radius, a rubber-band edge lets See all demos in this post
If you like this effect - similar demos
drag03 Grid reflow — inverting pointer coordinates into row/column indicesDrag to Reorder UI: 9 Lists, One Drop Index
drag04 A Sidebar Edge That Clicks Into Three Detent WidthsCSS Resize Handle: 9 Demos That Follow Your Grip
drag02 A Snap That Pulls Only Inside Distance and Angle ThresholdsJigsaw Puzzle JavaScript: 9 Snapping Tab Boards