02원점과 줄로 이어진 듯 늘어나는 탄성 테더 드래그
아이콘이 원래 자리에서 멀어진 거리만큼 얇은 선의 길이와 굵기를 계산해 그려, 놓는 순간 그 선이 줄어들며 아이콘을 원래 자리로 당겨 되돌린다.
쓰임임시 미리보기용 드래그 핸들
TETHER_SPRING_STIFFNESSTETHER_SPRING_DAMPINGTETHER_THICKNESS_BASE_PX

<main class="stage">
<div class="et is-demo" id="et">
<div class="et__origin" id="etOrigin"></div>
<div class="et__line" id="etLine"></div>
<button type="button" class="et__icon" id="etIcon" aria-label="탄성 테더로 당겨지는 아이콘. Enter 로 잡고 화살표로 늘이고, Enter 로 놓으면 되돌아갑니다"></button>
<p class="et__caption" id="etCaption">멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.et {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: $sp-4;
}
.et__origin {
position: absolute;
top: 46%;
left: 38%;
width: 14px;
height: 14px;
border-radius: $r-pill;
background: $subject-cream;
transform: translate(-50%, -50%);
}
.et__line {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 50%;
border-radius: $r-pill;
background: rgba($subject-cream, .55);
pointer-events: none;
}
.et__icon {
position: absolute;
top: 46%;
left: 38%;
appearance: none;
border: 0;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.et__icon.is-grabbed,
.et__icon:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.et.is-dragging .et__icon {
cursor: grabbing;
}
.et__caption {
position: relative;
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.et { height: 160px; }
.et__icon { width: 40px; height: 40px; margin: -20px 0 0 -20px; }
.et__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.et__line { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.et {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.et__origin {
position: absolute;
top: 46%;
left: 38%;
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff7e6;
transform: translate(-50%, -50%);
}
.et__line {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 50%;
border-radius: 999px;
background: rgba(255, 247, 230, 0.55);
pointer-events: none;
}
.et__icon {
position: absolute;
top: 46%;
left: 38%;
appearance: none;
border: 0;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.et__icon.is-grabbed,
.et__icon:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.et.is-dragging .et__icon {
cursor: grabbing;
}
.et__caption {
position: relative;
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.et {
height: 160px;
}
.et__icon {
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
}
.et__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.et__line {
transition: none;
}
}(function () {
// 기법: 아이콘이 원점(origin)에서 멀어진 거리로 테더 선의 길이·각도·굵기를 매 프레임
// 계산해 그리고, 손을 떼면 감쇠 스프링(오버슛 포함)으로 원점까지 되돌아가며 그동안도
// 같은 계산으로 선이 실시간으로 짧아진다.
var root = document.getElementById('et');
var origin = document.getElementById('etOrigin');
var line = document.getElementById('etLine');
var icon = document.getElementById('etIcon');
var caption = document.getElementById('etCaption');
var TETHER_SPRING_STIFFNESS = 140;
var TETHER_SPRING_DAMPING = 12;
var TETHER_THICKNESS_BASE_PX = 5;
var KEY_STEP_PX = 18;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var grabbed = false;
var pointerStart = null;
var startPos = null;
var raf = 0, lastTs = null;
var springing = false;
function applyIcon() {
icon.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px)';
}
function drawTether() {
var or = origin.getBoundingClientRect();
var ir = icon.getBoundingClientRect();
var ox = or.left + or.width / 2, oy = or.top + or.height / 2;
var ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
var dx = ix - ox, dy = iy - oy;
var length = Math.hypot(dx, dy);
var angle = Math.atan2(dy, dx) * 180 / Math.PI;
var thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
var boardRect = root.getBoundingClientRect();
line.style.left = (ox - boardRect.left) + 'px';
line.style.top = (oy - boardRect.top) + 'px';
line.style.width = length + 'px';
line.style.height = thickness + 'px';
line.style.transform = 'rotate(' + angle + 'deg)';
line.style.opacity = length < 2 ? '0' : '1';
}
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var fx = (0 - pos.x) * TETHER_SPRING_STIFFNESS - vel.x * TETHER_SPRING_DAMPING;
var fy = (0 - pos.y) * TETHER_SPRING_STIFFNESS - vel.y * TETHER_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon();
drawTether();
var rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyIcon(); drawTether();
springing = false;
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
springing = true;
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
springing = false;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root.classList.add('is-dragging');
caption.textContent = '늘어나는 줄 길이·굵기가 실시간으로 계산돼요';
}
function moveDrag(clientX, clientY) {
pos.x = startPos.x + (clientX - pointerStart.x);
pos.y = startPos.y + (clientY - pointerStart.y);
applyIcon();
drawTether();
}
function endDrag() {
root.classList.remove('is-dragging');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
caption.textContent = '멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요';
if (reduce) { pos.x = 0; pos.y = 0; applyIcon(); drawTether(); }
else { springHome(); }
}
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);
icon.classList.add('is-grabbed');
caption.textContent = '잡았어요. 화살표로 늘이고 Enter 로 놓으세요';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
endDrag();
}
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');
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon();
drawTether();
});
drawTether();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var originRect0 = origin.getBoundingClientRect();
var ox0 = originRect0.left + originRect0.width / 2;
var oy0 = originRect0.top + originRect0.height / 2;
var farX = ox0 + 92, farY = oy0 - 46;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 950) {
var p = Math.min(1, (t - 200) / 750);
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (farX - ox0) * ease;
var cy = oy0 + (farY - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1150) {
// 최대 늘어남 유지
} else if (t < 1180) {
endDrag();
} else {
// 스프링 복귀 관찰, 루프
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysElasticTether.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysElasticTether() {
const rootRef = useRef<HTMLDivElement>(null);
const originRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const origin = originRef.current;
const line = lineRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !origin || !line || !icon || !caption) return;
const TETHER_SPRING_STIFFNESS = 140;
const TETHER_SPRING_DAMPING = 12;
const TETHER_THICKNESS_BASE_PX = 5;
const KEY_STEP_PX = 18;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let grabbed = false;
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
let raf = 0, lastTs: number | null = null;
function applyIcon() {
icon!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px)";
}
function drawTether() {
const or = origin!.getBoundingClientRect();
const ir = icon!.getBoundingClientRect();
const ox = or.left + or.width / 2, oy = or.top + or.height / 2;
const ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
const dx = ix - ox, dy = iy - oy;
const length = Math.hypot(dx, dy);
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
const boardRect = root!.getBoundingClientRect();
line!.style.left = ox - boardRect.left + "px";
line!.style.top = oy - boardRect.top + "px";
line!.style.width = length + "px";
line!.style.height = thickness + "px";
line!.style.transform = "rotate(" + angle + "deg)";
line!.style.opacity = length < 2 ? "0" : "1";
}
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
const fx = (0 - pos.x) * TETHER_SPRING_STIFFNESS - vel.x * TETHER_SPRING_DAMPING;
const fy = (0 - pos.y) * TETHER_SPRING_STIFFNESS - vel.y * TETHER_SPRING_DAMPING;
vel.x += fx * dt; vel.y += fy * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;
applyIcon();
drawTether();
const rest = Math.hypot(vel.x, vel.y) < 2 && Math.hypot(pos.x, pos.y) < 0.6;
if (!rest) { raf = requestAnimationFrame(settleTick); return; }
pos.x = 0; pos.y = 0; vel.x = 0; vel.y = 0;
applyIcon(); drawTether();
lastTs = null;
}
function springHome() {
cancelAnimationFrame(raf);
lastTs = null;
raf = requestAnimationFrame(settleTick);
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
root!.classList.add(styles.isDragging);
caption!.textContent = "늘어나는 줄 길이·굵기가 실시간으로 계산돼요";
}
function moveDrag(clientX: number, clientY: number) {
pos.x = startPos.x + (clientX - pointerStart!.x);
pos.y = startPos.y + (clientY - pointerStart!.y);
applyIcon();
drawTether();
}
function endDrag() {
root!.classList.remove(styles.isDragging);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
caption!.textContent = "멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요";
if (reduce) { pos.x = 0; pos.y = 0; applyIcon(); drawTether(); } else { springHome(); }
}
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);
endDrag();
}
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);
endDrag();
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
applyIcon();
drawTether();
}
icon.addEventListener("keydown", onKeyDown);
drawTether();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const originRect0 = origin.getBoundingClientRect();
const ox0 = originRect0.left + originRect0.width / 2;
const oy0 = originRect0.top + originRect0.height / 2;
const farX = ox0 + 92, farY = oy0 - 46;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 950) {
const p = Math.min(1, (t - 200) / 750);
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (farX - ox0) * ease;
const cy = oy0 + (farY - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1150) {
// 최대 늘어남 유지
} else if (t < 1180) {
endDrag();
} else {
if (t > 1900) 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.et} is-demo`} ref={rootRef}>
<div className={styles.origin} ref={originRef}></div>
<div className={styles.line} ref={lineRef}></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="탄성 테더로 당겨지는 아이콘. Enter 로 잡고 화살표로 늘이고, Enter 로 놓으면 되돌아갑니다"
></button>
<p className={styles.caption} ref={captionRef}>멀리 끌면 줄이 늘어나요. 놓으면 되돌아가요</p>
</div>
</main>
);
}02 원점과 줄로 이어진 듯 늘어나는 탄성 테더 드래그 — 측정 누적면적 9.738% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산
이 데모가 속한 글
드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산드래그 앤 드롭 구현이라는 말은 보통 잡고 옮기는 동작 하나를 가리키지만, 여기 아홉 개는 손을 뗀 뒤까지 계산이 이어진다. 자석 격자가 24px 안쪽에서 아이콘을 붙잡고, 고무줄처럼 늘어난 경계가 그 손을 다시 놓 이 글의 데모 전부 보기


