05Snap-to-Background-Grid While Dragging
While an object is being dragged, once its edge comes within a set distance of an intersection on the faint background grid, it gets pulled the rest of the way onto that intersection before the pointer is even released (a different demo from 250's panel-snap-guides, which are dynamic alignment lines that appear against other panels' edges — this one snaps only to a fixed background grid, not to other objects).
Best forPlacing multiple icons at even spacing
nearestGridapplyPositionhopTo

<main class="stage">
<div class="scene is-demo" id="scene">
<div class="gridBg" aria-hidden="true"></div>
<div class="frame" id="frame">
<div class="guideDot" id="guideDot" aria-hidden="true"></div>
<div class="draggable" id="draggable" tabindex="0" aria-label="Object that snaps to a background grid intersection while dragged"></div>
</div>
<div class="readout" id="readout" aria-hidden="true"><span id="rs">free</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 의 nearestGrid()/applyPosition() 이 오브젝트 모서리 좌표를 CELL_PX 배수로
// 반올림해 거리를 재고, CORNER_SNAP_RADIUS_PX 안이면 스냅 좌표를, 아니면 원 좌표를 matrix() 로 굳힌다.
@include stage($stage-ink);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #201c2c, $stage-ink 70%);
}
.gridBg {
position: absolute;
inset: 0;
background-image: radial-gradient(rgba($subject-cream, .16) 1.2px, transparent 1.2px);
background-size: 20px 20px;
}
.frame {
position: absolute;
left: 50%;
top: 50%;
width: 184px;
height: 108px;
margin-left: -92px;
margin-top: -54px;
}
.draggable {
position: absolute;
width: 44px;
height: 32px;
border-radius: $r-sm;
background: linear-gradient(135deg, $subject-mint, $subject-sky);
box-shadow: $shadow-press;
cursor: grab;
touch-action: none;
will-change: transform;
&:active, &.isSnapped { cursor: grabbing; }
&.isSnapped { box-shadow: 0 0 0 2px $color, $shadow-press; }
&:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
}
.guideDot {
position: absolute;
width: 8px;
height: 8px;
margin: -4px;
border-radius: $r-pill;
background: $color;
opacity: 0;
will-change: transform;
&.isActive { opacity: .85; }
}
.readout {
position: absolute;
left: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(#050409, .5);
color: rgba($subject-cream, .82);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .04em;
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.frame { width: 128px; height: 76px; margin-left: -64px; margin-top: -38px; }
.draggable { width: 34px; height: 24px; }
.readout { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 드래그·키보드는 index.html 이 트윈 없이 즉시 반영한다.
// scss 만: 스냅 표시(box-shadow)·아웃라인 전환만 꺼서 잔여 트랜지션을 없앤다.
.draggable, .guideDot { 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);
}
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: linear-gradient(160deg, #201c2c, #17141a 70%);
}
.gridBg {
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(255, 247, 230, 0.16) 1.2px, transparent 1.2px);
background-size: 20px 20px;
}
.frame {
position: absolute;
left: 50%;
top: 50%;
width: 184px;
height: 108px;
margin-left: -92px;
margin-top: -54px;
}
.draggable {
position: absolute;
width: 44px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.draggable:active, .draggable.isSnapped {
cursor: grabbing;
}
.draggable.isSnapped {
box-shadow: 0 0 0 2px #4cd4a6, 0 6px 12px rgba(23, 20, 26, 0.32);
}
.draggable:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 3px;
}
.guideDot {
position: absolute;
width: 8px;
height: 8px;
margin: -4px;
border-radius: 999px;
background: #4cd4a6;
opacity: 0;
will-change: transform;
}
.guideDot.isActive {
opacity: 0.85;
}
.readout {
position: absolute;
left: 12px;
bottom: 12px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(5, 4, 9, 0.5);
color: rgba(255, 247, 230, 0.82);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.frame {
width: 128px;
height: 76px;
margin-left: -64px;
margin-top: -38px;
}
.draggable {
width: 34px;
height: 24px;
}
.readout {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.draggable, .guideDot {
transition: none;
}
}(function () {
var CELL_PX = 20;
var CORNER_SNAP_RADIUS_PX = 6; // 변형 05 가 16 으로 넓힌다 — 오브젝트 모서리가 격자 교차점에 붙는 자석 반경
var WAYPOINTS = [
{ x: 20, y: 20 }, { x: 140, y: 60 }, { x: 63, y: 96 }, { x: 20, y: 20 }
];
var HOP_MS = 560;
var HOLD_MS = 320;
var scene = document.getElementById('scene');
var frame = document.getElementById('frame');
var draggable = document.getElementById('draggable');
var guideDot = document.getElementById('guideDot');
var rs = document.getElementById('rs');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var frameW = 184, frameH = 108;
var tx = 20, ty = 20;
var dragging = false;
var startX = 0, startY = 0, originTx = 0, originTy = 0;
var timers = [];
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 오브젝트 모서리(좌상단)를 가장 가까운 격자 교차점(CELL_PX 배수)과 비교해, 그 거리가
// CORNER_SNAP_RADIUS_PX 안이면 손을 놓기 전에도 교차점으로 스냅한다 — 다른 오브젝트가 아니라
// 고정된 배경 격자 한 가지에만 붙는다(250 의 panel-snap-guides 와 다른 시연).
function nearestGrid(v) { return Math.round(v / CELL_PX) * CELL_PX; }
function applyPosition(rawX, rawY) {
var nx = nearestGrid(rawX);
var ny = nearestGrid(rawY);
var d = Math.hypot(rawX - nx, rawY - ny);
var snapped = d <= CORNER_SNAP_RADIUS_PX;
tx = clamp(snapped ? nx : rawX, 0, frameW - 44);
ty = clamp(snapped ? ny : rawY, 0, frameH - 32);
draggable.style.transform = 'matrix(1,0,0,1,' + tx.toFixed(1) + ',' + ty.toFixed(1) + ')';
if (snapped) {
guideDot.style.transform = 'matrix(1,0,0,1,' + nx.toFixed(1) + ',' + ny.toFixed(1) + ')';
guideDot.classList.add('isActive');
} else {
guideDot.classList.remove('isActive');
}
rs.textContent = snapped ? 'snap' : 'free';
draggable.classList.toggle('isSnapped', snapped);
}
function human() {
if (scene.classList.contains('is-demo')) {
scene.classList.remove('is-demo');
timers.forEach(function (t) { clearTimeout(t); clearInterval(t); });
timers = [];
}
}
draggable.addEventListener('pointerdown', function (e) {
human();
dragging = true;
draggable.setPointerCapture(e.pointerId);
startX = e.clientX; startY = e.clientY;
originTx = tx; originTy = ty;
});
draggable.addEventListener('pointermove', function (e) {
if (!dragging) return;
applyPosition(originTx + (e.clientX - startX), originTy + (e.clientY - startY));
});
function end(e) {
if (!dragging) return;
dragging = false;
try { draggable.releasePointerCapture(e.pointerId); } catch (err) {}
}
draggable.addEventListener('pointerup', end);
draggable.addEventListener('pointercancel', end);
draggable.addEventListener('keydown', function (e) {
var step = CELL_PX;
if (e.key === 'ArrowLeft') { human(); applyPosition(tx - step, ty); e.preventDefault(); }
else if (e.key === 'ArrowRight') { human(); applyPosition(tx + step, ty); e.preventDefault(); }
else if (e.key === 'ArrowUp') { human(); applyPosition(tx, ty - step); e.preventDefault(); }
else if (e.key === 'ArrowDown') { human(); applyPosition(tx, ty + step); e.preventDefault(); }
});
// reduce: 실사용 경로 적용 — 드래그·키보드는 위에서 같은 applyPosition() 으로 즉시 반영한다.
// 아래 hopTo()·cycle() 은 시연 게이트만 — reduce 에서는 돌지 않는다.
function hopTo(target, onDone) {
var fromX = tx, fromY = ty;
var start = performance.now();
function frame2(now) {
if (!scene.classList.contains('is-demo')) return;
var p = clamp((now - start) / HOP_MS, 0, 1);
var e = easeOutCubic(p);
applyPosition(fromX + (target.x - fromX) * e, fromY + (target.y - fromY) * e);
if (p < 1) requestAnimationFrame(frame2);
else if (onDone) onDone();
}
requestAnimationFrame(frame2);
}
function cycle(i) {
if (!scene.classList.contains('is-demo')) return;
var target = WAYPOINTS[i % WAYPOINTS.length];
hopTo(target, function () {
timers.push(setTimeout(function () { cycle(i + 1); }, HOLD_MS));
});
}
applyPosition(tx, ty);
if (!reduce) {
cycle(1);
}
})();import { useEffect, useRef } from 'react';
import styles from './XfGridSnap.module.scss';
const CELL_PX = 20;
const CORNER_SNAP_RADIUS_PX = 6; // 변형 05 가 16 으로 넓힌다 — 오브젝트 모서리가 격자 교차점에 붙는 자석 반경
const WAYPOINTS = [
{ x: 20, y: 20 }, { x: 140, y: 60 }, { x: 63, y: 96 }, { x: 20, y: 20 }
];
const HOP_MS = 560;
const HOLD_MS = 320;
export default function XfGridSnap() {
const sceneRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const scene0 = sceneRef.current;
if (!scene0) return undefined;
const scene = scene0;
const draggable0 = scene.querySelector<HTMLDivElement>('.' + styles.draggable);
const guideDot0 = scene.querySelector<HTMLDivElement>('.' + styles.guideDot);
const rs0 = scene.querySelector<HTMLSpanElement>('#rs');
if (!draggable0 || !guideDot0 || !rs0) return undefined;
const draggable = draggable0;
const guideDot = guideDot0;
const rs = rs0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const frameW = 184, frameH = 108;
let tx = 20, ty = 20;
let dragging = false;
let startX = 0, startY = 0, originTx = 0, originTy = 0;
let rafId = 0;
let timers: number[] = [];
function clamp(v: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
// 오브젝트 모서리(좌상단)를 가장 가까운 격자 교차점(CELL_PX 배수)과 비교해, 그 거리가
// CORNER_SNAP_RADIUS_PX 안이면 손을 놓기 전에도 교차점으로 스냅한다 — 다른 오브젝트가 아니라
// 고정된 배경 격자 한 가지에만 붙는다(250 의 panel-snap-guides 와 다른 시연).
function nearestGrid(v: number) { return Math.round(v / CELL_PX) * CELL_PX; }
function applyPosition(rawX: number, rawY: number) {
const nx = nearestGrid(rawX);
const ny = nearestGrid(rawY);
const d = Math.hypot(rawX - nx, rawY - ny);
const snapped = d <= CORNER_SNAP_RADIUS_PX;
tx = clamp(snapped ? nx : rawX, 0, frameW - 44);
ty = clamp(snapped ? ny : rawY, 0, frameH - 32);
draggable.style.transform = 'matrix(1,0,0,1,' + tx.toFixed(1) + ',' + ty.toFixed(1) + ')';
if (snapped) {
guideDot.style.transform = 'matrix(1,0,0,1,' + nx.toFixed(1) + ',' + ny.toFixed(1) + ')';
guideDot.classList.add(styles.isActive);
} else {
guideDot.classList.remove(styles.isActive);
}
rs.textContent = snapped ? 'snap' : 'free';
draggable.classList.toggle(styles.isSnapped, snapped);
}
function human() {
if (scene.classList.contains(styles['is-demo'])) {
scene.classList.remove(styles['is-demo']);
timers.forEach((t) => window.clearTimeout(t));
timers = [];
}
}
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
draggable.setPointerCapture(e.pointerId);
startX = e.clientX; startY = e.clientY;
originTx = tx; originTy = ty;
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
applyPosition(originTx + (e.clientX - startX), originTy + (e.clientY - startY));
}
function endDrag(e: PointerEvent) {
if (!dragging) return;
dragging = false;
try { draggable.releasePointerCapture(e.pointerId); } catch { /* 캡처가 이미 풀린 경우 */ }
}
function onKeyDown(e: KeyboardEvent) {
const step = CELL_PX;
if (e.key === 'ArrowLeft') { human(); applyPosition(tx - step, ty); e.preventDefault(); }
else if (e.key === 'ArrowRight') { human(); applyPosition(tx + step, ty); e.preventDefault(); }
else if (e.key === 'ArrowUp') { human(); applyPosition(tx, ty - step); e.preventDefault(); }
else if (e.key === 'ArrowDown') { human(); applyPosition(tx, ty + step); e.preventDefault(); }
}
draggable.addEventListener('pointerdown', onPointerDown);
draggable.addEventListener('pointermove', onPointerMove);
draggable.addEventListener('pointerup', endDrag);
draggable.addEventListener('pointercancel', endDrag);
draggable.addEventListener('keydown', onKeyDown);
// reduce: 실사용 경로 적용 — 드래그·키보드는 위에서 같은 applyPosition() 으로 즉시 반영한다.
// 아래 hopTo()·cycle() 은 시연 게이트만 — reduce 에서는 돌지 않는다.
function hopTo(target: { x: number; y: number }, onDone: () => void) {
const fromX = tx, fromY = ty;
const start = performance.now();
function frame(now: number) {
if (!scene.classList.contains(styles['is-demo'])) return;
const p = clamp((now - start) / HOP_MS, 0, 1);
const e = easeOutCubic(p);
applyPosition(fromX + (target.x - fromX) * e, fromY + (target.y - fromY) * e);
if (p < 1) rafId = requestAnimationFrame(frame);
else onDone();
}
rafId = requestAnimationFrame(frame);
}
function cycle(i: number) {
if (!scene.classList.contains(styles['is-demo'])) return;
const target = WAYPOINTS[i % WAYPOINTS.length];
hopTo(target, () => {
timers.push(window.setTimeout(() => cycle(i + 1), HOLD_MS));
});
}
applyPosition(tx, ty);
if (!reduce) {
cycle(1);
}
return () => {
draggable.removeEventListener('pointerdown', onPointerDown);
draggable.removeEventListener('pointermove', onPointerMove);
draggable.removeEventListener('pointerup', endDrag);
draggable.removeEventListener('pointercancel', endDrag);
draggable.removeEventListener('keydown', onKeyDown);
timers.forEach((t) => window.clearTimeout(t));
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.scene + ' ' + styles['is-demo']} ref={sceneRef}>
<div className={styles.gridBg} aria-hidden="true"></div>
<div className={styles.frame}>
<div className={styles.guideDot} aria-hidden="true"></div>
<div
className={styles.draggable}
tabIndex={0}
aria-label="드래그하면 배경 격자 교차점에 달라붙는 오브젝트"
></div>
</div>
<div className={styles.readout} aria-hidden="true"><span id="rs">free</span></div>
</div>
</main>
);
}05 Snap-to-Background-Grid While Dragging — Measured area 6.406% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Resize Handle UI — 9 Free-Transform Patterns
The post this demo lives in
Resize Handle UI — 9 Free-Transform PatternsAmong nine resize handle patterns, one spins an object as its rotate handle arcs, one snaps scattered icons into a grid See all demos in this post


