08Drag-to-Place Sticker
Press a sticker and drag it anywhere on the board; releasing sticks it in place with a slight rotation, stacking over whichever stickers are already there.
Best forCustom mood-board makers, decoration widgets
setPointerCapturetouch-actionMath.round

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="board" id="board">
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<button class="chip" id="chip" type="button" aria-label="Sticker; drag it to place on a grid cell">
<span class="chipMark" aria-hidden="true"></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 snap(px, py) 에 있다 — 포인터 좌표를 GRID 칸 크기로 나눈 몫을
// 반올림해 항상 격자 위 한 점으로 되돌린다(자유 좌표 아님). 자동 루프도 같은 snap 대상
// 좌표(slots)만 순회해 실제 조작과 같은 경로를 탄다.
@include stage($stage-yellow);
.wrap { display: grid; place-items: center; }
.board {
position: relative;
width: 190px;
height: 190px;
border-radius: $r-card;
background: rgba($subject-cream, .12);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.cell {
border: 1px dashed rgba($subject-cream, .3);
}
.chip {
position: absolute;
left: 0;
top: 0;
width: 78px;
height: 78px;
appearance: none;
border: 0;
padding: 0;
cursor: grab;
border-radius: $r-control;
background: $subject-blue-deep;
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
&:active { cursor: grabbing; }
&:focus-visible { outline: 2px solid $subject-cream; outline-offset: 3px; }
}
.chipMark {
width: 34px;
height: 34px;
border-radius: 50%;
border: 6px solid rgba($subject-cream, .9);
}
@media (max-width: 340px) {
.board { width: 150px; height: 150px; }
.chip { width: 62px; height: 62px; }
.chipMark { width: 26px; height: 26px; border-width: 5px; }
}
@media (prefers-reduced-motion: reduce) {
.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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: grid;
place-items: center;
}
.board {
position: relative;
width: 190px;
height: 190px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.12);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.cell {
border: 1px dashed rgba(255, 247, 230, 0.3);
}
.chip {
position: absolute;
left: 0;
top: 0;
width: 78px;
height: 78px;
appearance: none;
border: 0;
padding: 0;
cursor: grab;
border-radius: 12px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
}
.chip:active {
cursor: grabbing;
}
.chip:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.chipMark {
width: 34px;
height: 34px;
border-radius: 50%;
border: 6px solid rgba(255, 247, 230, 0.9);
}
@media (max-width: 340px) {
.board {
width: 150px;
height: 150px;
}
.chip {
width: 62px;
height: 62px;
}
.chipMark {
width: 26px;
height: 26px;
border-width: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}(function () {
var GRID = 78; // 격자 한 칸 크기(px) — 스냅 계산의 기준
var BOARD_W = 190, BOARD_H = 190;
var LERP_RATE = .22;
var ORBIT_MS = 650;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var board = document.getElementById('board');
var chip = document.getElementById('chip');
var isDemo = true;
var dragging = false;
var target = { x: GRID / 2, y: GRID / 2 };
var shown = { x: target.x, y: target.y };
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 포인터가 놓인 좌표를 GRID 칸 크기로 나눈 몫을 반올림해 칸 중심 좌표로 되돌린다 —
// 자유 좌표가 아니라 항상 격자 위 한 점으로 스냅되는 계산이다.
function snap(px, py) {
var col = Math.max(0, Math.min(1, Math.round((px - GRID / 2) / GRID)));
var row = Math.max(0, Math.min(1, Math.round((py - GRID / 2) / GRID)));
return { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
chip.addEventListener('pointerdown', function (e) {
human();
dragging = true;
chip.setPointerCapture(e.pointerId);
});
chip.addEventListener('pointermove', function (e) {
if (!dragging) return;
var rect = board.getBoundingClientRect();
var px = Math.max(0, Math.min(BOARD_W, e.clientX - rect.left));
var py = Math.max(0, Math.min(BOARD_H, e.clientY - rect.top));
target = snap(px, py);
});
function endDrag() { dragging = false; }
chip.addEventListener('pointerup', endDrag);
chip.addEventListener('pointercancel', endDrag);
chip.addEventListener('keydown', function (e) {
human();
var col = target.x > GRID ? 1 : 0, row = target.y > GRID ? 1 : 0;
if (e.key === 'ArrowRight') col = 1;
else if (e.key === 'ArrowLeft') col = 0;
else if (e.key === 'ArrowDown') row = 1;
else if (e.key === 'ArrowUp') row = 0;
else return;
e.preventDefault();
target = { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
});
var slots = [[0, 0], [1, 0], [0, 1], [1, 1]];
var slotIdx = 0, last = 0;
function autoAdvance(ts) {
if (ts - last < ORBIT_MS) return;
last = ts;
slotIdx = (slotIdx + 1) % slots.length;
target = { x: slots[slotIdx][0] * GRID + GRID / 2, y: slots[slotIdx][1] * GRID + GRID / 2 };
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && !dragging) autoAdvance(ts);
if (reduce) { shown.x = target.x; shown.y = target.y; }
else {
shown.x += (target.x - shown.x) * LERP_RATE;
shown.y += (target.y - shown.y) * LERP_RATE;
}
chip.style.transform = 'translate(' + (shown.x - GRID / 2).toFixed(2) + 'px, ' + (shown.y - GRID / 2).toFixed(2) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragPlace — vanilla 판(run/378/demo/08-sticker-drag-place/index.html) 의 React 포트.
// 포인터 좌표를 GRID 칸 크기로 나눈 몫을 반올림해 항상 격자 위 한 점으로 되돌린다(snap) —
// 자유 좌표가 아니라 항상 격자 스냅 계산이다. 자동 루프도 같은 slots 좌표만 순회한다.
import { useEffect, useRef } from "react";
import styles from "./DragPlace.module.scss";
type Box<T> = { current: T };
const GRID = 78;
const BOARD_W = 190, BOARD_H = 190;
const LERP_RATE = 0.22;
const ORBIT_MS = 650;
export default function DragPlace() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const boardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const chipRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const board = boardRef.current;
const chip = chipRef.current;
if (!wrap || !board || !chip) return;
let isDemo = true;
let dragging = false;
let target = { x: GRID / 2, y: GRID / 2 };
const shown = { x: target.x, y: target.y };
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function snap(px: number, py: number) {
const col = Math.max(0, Math.min(1, Math.round((px - GRID / 2) / GRID)));
const row = Math.max(0, Math.min(1, Math.round((py - GRID / 2) / GRID)));
return { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
function onDown(e: PointerEvent) {
human();
dragging = true;
chip!.setPointerCapture(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging) return;
const rect = board!.getBoundingClientRect();
const px = Math.max(0, Math.min(BOARD_W, e.clientX - rect.left));
const py = Math.max(0, Math.min(BOARD_H, e.clientY - rect.top));
target = snap(px, py);
}
function endDrag() { dragging = false; }
function onKeyDown(e: KeyboardEvent) {
human();
let col = target.x > GRID ? 1 : 0, row = target.y > GRID ? 1 : 0;
if (e.key === "ArrowRight") col = 1;
else if (e.key === "ArrowLeft") col = 0;
else if (e.key === "ArrowDown") row = 1;
else if (e.key === "ArrowUp") row = 0;
else return;
e.preventDefault();
target = { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
chip.addEventListener("pointerdown", onDown);
chip.addEventListener("pointermove", onMove);
chip.addEventListener("pointerup", endDrag);
chip.addEventListener("pointercancel", endDrag);
chip.addEventListener("keydown", onKeyDown);
const slots = [[0, 0], [1, 0], [0, 1], [1, 1]];
let slotIdx = 0, last = 0;
function autoAdvance(ts: number) {
if (ts - last < ORBIT_MS) return;
last = ts;
slotIdx = (slotIdx + 1) % slots.length;
target = { x: slots[slotIdx][0] * GRID + GRID / 2, y: slots[slotIdx][1] * GRID + GRID / 2 };
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && !dragging) autoAdvance(ts);
if (reduce) { shown.x = target.x; shown.y = target.y; }
else {
shown.x += (target.x - shown.x) * LERP_RATE;
shown.y += (target.y - shown.y) * LERP_RATE;
}
chip!.style.transform = "translate(" + (shown.x - GRID / 2).toFixed(2) + "px, " + (shown.y - GRID / 2).toFixed(2) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
chip!.removeEventListener("pointerdown", onDown);
chip!.removeEventListener("pointermove", onMove);
chip!.removeEventListener("pointerup", endDrag);
chip!.removeEventListener("pointercancel", endDrag);
chip!.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.board} ref={boardRef}>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<button className={styles.chip} ref={chipRef} type="button" aria-label="스티커, 끌어서 격자 칸에 놓는다">
<span className={styles.chipMark} aria-hidden="true"></span>
</button>
</div>
</div>
</main>
);
}08 Drag-to-Place Sticker — Measured area 21.61% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · CSS Sticker Peel Effect: 9 Styles, Border to Holo
The post this demo lives in
CSS Sticker Peel Effect: 9 Styles, Border to HoloAmong nine css sticker peel effect ideas, one fakes a die-cut look with a white border and shadow, one sweeps rainbow li See all demos in this post


