09드래그해서 읽기 목록에 놓는 북마크
아이콘을 옆 목록까지 끌어다 놓으면 가까운 자리로 훅 당겨져 붙으며 목록에 더해진다.
쓰임북마크 관리 화면의 읽기 목록 정리
BOOKMARK_DRAG_SNAP_PXDRAG_INERTIA_DECAYrelease
<main class="stage">
<div class="dg is-demo" id="dg">
<p class="dg__caption">끌어서 목록에 저장</p>
<div class="dg__scene" id="dgScene">
<div class="dg__drop" id="dgDrop">
<span class="dg__dropLabel">읽기 목록에 놓기</span>
</div>
<button class="dg__chip" id="dgChip" type="button" aria-pressed="false">
<svg class="dg__icon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path class="dg__shape" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</svg>
</button>
</div>
<p class="dg__status" id="dgStatus" aria-live="polite">목록 밖</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부, 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.dg {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: $subject-cream;
border-radius: $r-pill;
padding: $sp-3;
cursor: grab;
color: $color;
box-shadow: $shadow-raised;
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed="true"] .dg__shape {
fill: $color;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: $r-card;
border: 2px dashed $subject-blue-light;
background: transparent;
transition: background $duration $easing, border-color $duration $easing, transform $duration $easing;
}
.dg__drop--near {
border-color: $subject-blue;
background: mix($subject-sky, $stage-paper, 55%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: $subject-blue-deep;
background: $subject-sky;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: $subject-blue-deep;
}
.dg__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene { width: 200px; height: 100px; }
.dg__drop { width: 150px; top: 50px; height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.dg__chip { transition: none; }
.dg__drop { 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);
}
.dg {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: #fff7e6;
border-radius: 999px;
padding: 12px;
cursor: grab;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed=true] .dg__shape {
fill: #0a4fe8;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: 16px;
border: 2px dashed #5b8cf8;
background: transparent;
transition: background 300ms ease-out, border-color 300ms ease-out, transform 300ms ease-out;
}
.dg__drop--near {
border-color: #2f6df6;
background: rgb(68.7254901961%, 88.137254902%, 100%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: #0a4fe8;
background: #6ec8ff;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: #0a4fe8;
}
.dg__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene {
width: 200px;
height: 100px;
}
.dg__drop {
width: 150px;
top: 50px;
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg__chip {
transition: none;
}
.dg__drop {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_DRAG_SNAP_PX = 24;
var DRAG_INERTIA_DECAY = 0.86;
var DRAG_SETTLE_RATE = 0.22;
var CYCLE_S = 2;
var root = document.getElementById("dg");
var scene = document.getElementById("dgScene");
var chip = document.getElementById("dgChip");
var drop = document.getElementById("dgDrop");
var status = document.getElementById("dgStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var dropX = 0, dropY = 78;
var chipX = 0, chipY = 0;
var velX = 0, velY = 0;
var targetX = 0, targetY = 0;
var dragging = false;
var saved = false;
var lastPX = 0, lastPY = 0;
function setSaved(v) {
saved = v;
chip.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "목록 밖";
drop.classList.toggle("dg__drop--filled", v);
}
// 값 하나: 놓은 지점과 드롭존 사이 거리 vs BOOKMARK_DRAG_SNAP_PX → 목록에 스냅하거나 원위치로 튕긴다.
function release() {
var dx = chipX - dropX, dy = chipY - dropY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetX = dropX; targetY = dropY;
setSaved(true);
} else {
targetX = 0; targetY = 0;
setSaved(false);
}
}
function startDrag(clientX, clientY) {
root.classList.remove("is-demo");
dragging = true;
velX = 0; velY = 0;
lastPX = clientX; lastPY = clientY;
}
function moveDrag(clientX, clientY) {
var dx = clientX - lastPX, dy = clientY - lastPY;
chipX += dx; chipY += dy;
velX = dx; velY = dy;
lastPX = clientX; lastPY = clientY;
}
chip.addEventListener("pointerdown", function (e) {
chip.setPointerCapture(e.pointerId);
startDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointermove", function (e) {
if (dragging) moveDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointerup", function () {
if (dragging) { dragging = false; release(); }
});
chip.addEventListener("click", function () {
if (dragging) return;
root.classList.remove("is-demo");
if (saved) { targetX = 0; targetY = 0; setSaved(false); }
else { targetX = dropX; targetY = dropY; setSaved(true); }
});
function settle(dt) {
if (dragging) return;
if (!reduce) {
chipX += velX; chipY += velY;
velX *= DRAG_INERTIA_DECAY; velY *= DRAG_INERTIA_DECAY;
chipX += (targetX - chipX) * DRAG_SETTLE_RATE;
chipY += (targetY - chipY) * DRAG_SETTLE_RATE;
} else {
chipX = targetX; chipY = targetY; velX = 0; velY = 0;
}
}
function render() {
chip.style.transform = "translate(" + chipX.toFixed(2) + "px, " + chipY.toFixed(2) + "px)" +
(dragging ? " scale(1.06)" : "");
var near = Math.abs(chipX - dropX) < 40 && Math.abs(chipY - dropY) < 40;
drop.classList.toggle("dg__drop--near", near && (dragging || !saved && targetX === dropX));
}
// .is-demo 자동재생: 실제 pointer 이벤트 없이 chipX/Y·release() 를 같은 경로로 호출한다.
var start = null;
var prevT = 0;
var phase = "idle";
function synth(t) {
if (t > 0.1 && t < 0.55 && phase === "idle") {
phase = "dragIn";
dragging = true;
}
if (phase === "dragIn") {
var p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipX = dropX * p; chipY = dropY * p;
if (t >= 0.55) { dragging = false; phase = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phase === "afterIn") {
phase = "dragOut";
dragging = true;
}
if (phase === "dragOut") {
var q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipX = dropX * (1 - q); chipY = dropY * (1 - q);
if (t >= 1.4) { dragging = false; phase = "done"; release(); }
}
}
function loop(now) {
if (start === null) start = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phase = "idle"; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
synth(t);
}
settle();
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragReadingListDropBookmark — vanilla 판(run/416/demo/09-bookmark-btn-drag-reading-list-drop/index.html) 의 React 포트.
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부. 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragReadingListDropBookmark.module.scss";
type Box<T> = { current: T };
const BOOKMARK_DRAG_SNAP_PX = 24;
const DRAG_INERTIA_DECAY = 0.86;
const DRAG_SETTLE_RATE = 0.22;
const CYCLE_S = 2;
const DROP_X = 0;
const DROP_Y = 78;
export default function DragReadingListDropBookmark() {
const chipRef = useRef<HTMLButtonElement | null>(null);
const dropRef = useRef<HTMLDivElement | null>(null);
const chipXRef = useRef<number>(0) as Box<number>;
const chipYRef = useRef<number>(0) as Box<number>;
const velXRef = useRef<number>(0) as Box<number>;
const velYRef = useRef<number>(0) as Box<number>;
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const lastPXRef = useRef<number>(0) as Box<number>;
const lastPYRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSavedState] = useState(false);
const [status, setStatus] = useState("목록 밖");
const [near, setNear] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setSaved(v: boolean): void {
setSavedState(v);
setStatus(v ? "저장됨" : "목록 밖");
}
function release(): void {
const dx = chipXRef.current - DROP_X;
const dy = chipYRef.current - DROP_Y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetXRef.current = DROP_X; targetYRef.current = DROP_Y;
setSaved(true);
} else {
targetXRef.current = 0; targetYRef.current = 0;
setSaved(false);
}
}
function startDrag(clientX: number, clientY: number): void {
setDemo(false);
draggingRef.current = true;
velXRef.current = 0; velYRef.current = 0;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function moveDrag(clientX: number, clientY: number): void {
const dx = clientX - lastPXRef.current;
const dy = clientY - lastPYRef.current;
chipXRef.current += dx; chipYRef.current += dy;
velXRef.current = dx; velYRef.current = dy;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function settle(): void {
if (draggingRef.current) return;
if (!reduce) {
chipXRef.current += velXRef.current;
chipYRef.current += velYRef.current;
velXRef.current *= DRAG_INERTIA_DECAY;
velYRef.current *= DRAG_INERTIA_DECAY;
chipXRef.current += (targetXRef.current - chipXRef.current) * DRAG_SETTLE_RATE;
chipYRef.current += (targetYRef.current - chipYRef.current) * DRAG_SETTLE_RATE;
} else {
chipXRef.current = targetXRef.current; chipYRef.current = targetYRef.current;
velXRef.current = 0; velYRef.current = 0;
}
}
function render(): void {
const chip = chipRef.current;
if (!chip) return;
chip.style.transform = "translate(" + chipXRef.current.toFixed(2) + "px, " + chipYRef.current.toFixed(2) + "px)" +
(draggingRef.current ? " scale(1.06)" : "");
const isNear = Math.abs(chipXRef.current - DROP_X) < 40 && Math.abs(chipYRef.current - DROP_Y) < 40;
setNear(isNear && (draggingRef.current || (!saved && targetXRef.current === DROP_X)));
}
function synth(t: number, phaseRef: Box<string>): void {
if (t > 0.1 && t < 0.55 && phaseRef.current === "idle") {
phaseRef.current = "dragIn";
draggingRef.current = true;
}
if (phaseRef.current === "dragIn") {
let p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipXRef.current = DROP_X * p; chipYRef.current = DROP_Y * p;
if (t >= 0.55) { draggingRef.current = false; phaseRef.current = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phaseRef.current === "afterIn") {
phaseRef.current = "dragOut";
draggingRef.current = true;
}
if (phaseRef.current === "dragOut") {
let q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipXRef.current = DROP_X * (1 - q); chipYRef.current = DROP_Y * (1 - q);
if (t >= 1.4) { draggingRef.current = false; phaseRef.current = "done"; release(); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
const phaseRef: Box<string> = { current: "idle" };
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phaseRef.current = "idle"; }
prevT = t;
if (!reduce && demo) {
synth(t, phaseRef);
}
settle();
render();
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo, saved]);
function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
startDrag(event.clientX, event.clientY);
}
function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>): void {
if (draggingRef.current) moveDrag(event.clientX, event.clientY);
}
function onPointerUp(): void {
if (draggingRef.current) { draggingRef.current = false; release(); }
}
function onClick(): void {
if (draggingRef.current) return;
setDemo(false);
if (saved) { targetXRef.current = 0; targetYRef.current = 0; setSaved(false); }
else { targetXRef.current = DROP_X; targetYRef.current = DROP_Y; setSaved(true); }
}
const dropCls = [styles.drop, near ? styles.dropNear : "", saved ? styles.dropFilled : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={styles.dg}>
<p className={styles.caption}>끌어서 목록에 저장</p>
<div className={styles.scene}>
<div className={dropCls} ref={dropRef}>
<span className={styles.dropLabel}>읽기 목록에 놓기</span>
</div>
<button
className={styles.chip}
ref={chipRef}
type="button"
aria-pressed={saved}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={onClick}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={26} height={26} aria-hidden="true" focusable="false">
<path className={styles.shape} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}09 드래그해서 읽기 목록에 놓는 북마크 — 측정 누적면적 14.492% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 북마크 버튼 애니메이션 9가지 — 즐겨찾기 버튼도 같은 원리다

