02Elastic-Tether Drag That Stretches as If Roped to Its Origin
A thin line's length and thickness are calculated from how far the icon has moved from its start, and on release the line shortens, pulling the icon back to origin.
Best forDrag handle for a temporary preview action
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="An icon pulled by an elastic tether. Press Enter to grab, arrow keys to stretch, Enter to release and spring back"></button>
<p class="et__caption" id="etCaption">Drag it far and the tether stretches. Release and it snaps back</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 = 'The stretching line\'s length and thickness are calculated in real time';
}
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 = 'Drag it far and the tether stretches. Release and it snaps back';
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 = 'Grabbed. Stretch with arrow keys, press Enter to release';
} 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 Elastic-Tether Drag That Stretches as If Roped to Its Origin — Measured area 9.738% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop Animation CSS: 9 Physics Patterns
The post this demo lives in
Drag and Drop Animation CSS: 9 Physics PatternsNine drag and drop animation css builds: A magnetic grid catches the icon inside a 24px radius, a rubber-band edge lets See all demos in this post
If you like this effect - similar demos
drag08 Ghost preview — a trailing clone that turns move speed into tiltDrag to Reorder UI: 9 Lists, One Drop Index
drag02 Video drop — a panel that reads metadata first and computes duration and resolutionDrag and Drop File Upload UI: 9 Computed Dropzones
drag01 A Box That Follows the Corner Grip Like a SpringCSS Resize Handle: 9 Demos That Follow Your Grip