05이동 중 배경 격자에 달라붙는 스냅
오브젝트를 드래그하는 동안 화면에 옅게 깔린 배경 격자의 교차점에 오브젝트 모서리가 일정 거리 안으로 들어오면 손을 놓지 않아도 그 교차점 위치로 살짝 끌려가 붙는다 (250의 panel-snap-guides — 다른 패널 가장자리에 맞춰 나타나는 동적 정렬선 — 과 다른 시연: 여긴 다른 오브젝트가 아니라 고정된 배경 격자 한 가지에만 붙는다).
쓰임여러 아이콘을 균일한 간격으로 배치
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="드래그하면 배경 격자 교차점에 달라붙는 오브젝트"></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 이동 중 배경 격자에 달라붙는 스냅 — 측정 누적면적 6.406% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 리사이즈 핸들 9종 — 오브젝트 자유 변형 캔버스 툴킷
이 데모가 속한 글
리사이즈 핸들 9종 — 오브젝트 자유 변형 캔버스 툴킷리사이즈 핸들 9종 가운데 하나는 회전 핸들을 원으로 끌면 선택 박스 중심을 축으로 오브젝트가 함께 돌고, 하나는 흩어진 아이콘이 버튼 한 번에 격자 칸으로 동시에 미끄러져 붙고, 하나는 방향키 넛지를 계속 붙잡을수 이 글의 데모 전부 보기


