04Pull-distance stretch — the button's shape value stretches by an amount tied to how far the pointer's distance value has moved away, then returns when released
As the pointer's distance value from the button grows, the button's stretched shape value grows with it, then returns to its original value on release.
Best forA pull-to-refresh button at the top of a list, a slide-to-confirm payment button
PULL_RESISTANCEMAX_STRETCHstretchFromDist

<main class="stage">
<div class="brs is-demo" id="brs">
<div class="brs__field" id="brsField">
<span class="brs__anchor" aria-hidden="true"></span>
<button class="brs__btn" id="brsBtn" type="button">Try pulling</button>
</div>
<p class="brs__hint">It stretches by the distance value you pull it away, then returns when released</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(저항 곡선·복귀 스프링 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.stage { position: relative; }
.brs {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.brs__field {
position: relative;
width: 220px;
height: 60px;
display: flex;
align-items: center;
}
.brs__anchor {
position: absolute;
left: 0;
top: 50%;
width: 10px;
height: 10px;
margin-top: -5px;
border-radius: $r-pill;
background: $subject-ink;
}
.brs__btn {
position: relative;
margin-left: $sp-5;
border: 0;
border-radius: $r-control;
padding: $sp-3 $sp-6;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 14px;
font-weight: 700;
cursor: grab;
touch-action: none;
// 왼쪽 고정점(anchor)에 매인 요소라 중심이 아니라 왼쪽 변에서 늘어난다 (규칙 8 예외 — 채움 막대와 같은 계열)
transform-origin: 0% 50%;
}
.brs__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
max-width: 220px;
}
@media (max-width: 340px) {
.brs__field { width: 180px; }
.brs__btn { padding: $sp-2 $sp-5; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.brs__btn { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.brs {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.brs__field {
position: relative;
width: 220px;
height: 60px;
display: flex;
align-items: center;
}
.brs__anchor {
position: absolute;
left: 0;
top: 50%;
width: 10px;
height: 10px;
margin-top: -5px;
border-radius: 999px;
background: #17141a;
}
.brs__btn {
position: relative;
margin-left: 20px;
border: 0;
border-radius: 12px;
padding: 12px 24px;
background: #0a4fe8;
color: #fff7e6;
font-size: 14px;
font-weight: 700;
cursor: grab;
touch-action: none;
transform-origin: 0% 50%;
}
.brs__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
max-width: 220px;
}
@media (max-width: 340px) {
.brs__field {
width: 180px;
}
.brs__btn {
padding: 8px 20px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.brs__btn {
transition: none !important;
}
}(function () {
var root = document.getElementById('brs');
var field = document.getElementById('brsField');
var btn = document.getElementById('brsBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 당긴 거리 값(dist)이 커질수록 늘어난 배율(stretch)도 커지되, 저항 상수
// PULL_RESISTANCE 로 나눈 뒤 1-exp(-x) 를 씌워 거리가 커질수록 증가폭이 줄어드는
// (당길수록 뻑뻑해지는) 고무줄 저항 곡선을 만든다. 놓으면 스프링(K·C)이 1로 되돌린다.
var PULL_RESISTANCE = 130, MAX_STRETCH = 0.9, K = 260, C = 8;
var dist = 0, stretch = 1, vel = 0, target = 1, dragging = false, startX = 0, raf = null, last = 0;
function stretchFromDist(d) {
var n = Math.max(0, d) / PULL_RESISTANCE;
return 1 + MAX_STRETCH * (1 - Math.exp(-n));
}
function paint() { btn.style.transform = 'scaleX(' + stretch.toFixed(3) + ')'; }
function springTick(now) {
var dt = Math.min((now - last) / 1000, 0.032);
last = now;
var accel = -K * (stretch - target) - C * vel;
vel += accel * dt;
stretch += vel * dt;
paint();
if (Math.abs(stretch - target) < 0.002 && Math.abs(vel) < 0.01) { stretch = target; vel = 0; paint(); raf = null; return; }
raf = requestAnimationFrame(springTick);
}
function startSpring() { if (raf === null) { last = performance.now(); raf = requestAnimationFrame(springTick); } }
function onDown(clientX) {
dragging = true; startX = clientX;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(clientX) {
if (!dragging) { return; }
dist = clientX - startX;
stretch = stretchFromDist(dist);
vel = 0;
paint();
}
function onUp() {
if (!dragging) { return; }
dragging = false;
target = 1;
if (reduce) { stretch = 1; paint(); return; }
startSpring();
}
function wireReal() {
btn.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientX); });
window.addEventListener('pointermove', function (e) { onMove(e.clientX); });
window.addEventListener('pointerup', onUp);
}
wireReal();
paint();
// 자동 루프: 실제 드래그 대신 dist 값을 단계적으로 키웠다 놓아 같은 저항·스프링 경로를 탄다.
var autoTimer = null, autoDist = 0, growing = true;
function autoTick() {
if (growing) {
autoDist += 26;
stretch = stretchFromDist(autoDist);
paint();
if (autoDist >= 170) { growing = false; target = 1; startSpring(); }
} else {
growing = true; autoDist = 0;
}
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 200); }
})();import { useEffect, useRef } from "react";
import styles from "./ButtonPhysicsRubberStretchPull.module.scss";
export type ButtonPhysicsRubberStretchPullProps = {
color?: string;
};
export default function ButtonPhysicsRubberStretchPull({
color = "#0a4fe8",
}: ButtonPhysicsRubberStretchPullProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
if (!btn) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 당긴 거리 값(dist)이 커질수록 늘어난 배율(stretch)도 커지되, 저항 상수
// PULL_RESISTANCE 로 나눈 뒤 1-exp(-x) 를 씌워 거리가 커질수록 증가폭이 줄어드는
// (당길수록 뻑뻑해지는) 고무줄 저항 곡선을 만든다. 놓으면 스프링(K·C)이 1로 되돌린다.
const PULL_RESISTANCE = 130, MAX_STRETCH = 0.9, K = 260, C = 8;
let dist = 0, stretch = 1, vel = 0, target = 1, dragging = false, startX = 0, raf: number | null = null, last = 0;
function stretchFromDist(d: number) {
const n = Math.max(0, d) / PULL_RESISTANCE;
return 1 + MAX_STRETCH * (1 - Math.exp(-n));
}
function paint() { btn!.style.transform = "scaleX(" + stretch.toFixed(3) + ")"; }
function springTick(now: number) {
const dt = Math.min((now - last) / 1000, 0.032);
last = now;
const accel = -K * (stretch - target) - C * vel;
vel += accel * dt;
stretch += vel * dt;
paint();
if (Math.abs(stretch - target) < 0.002 && Math.abs(vel) < 0.01) { stretch = target; vel = 0; paint(); raf = null; return; }
raf = requestAnimationFrame(springTick);
}
function startSpring() { if (raf === null) { last = performance.now(); raf = requestAnimationFrame(springTick); } }
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; startX = e.clientX;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
dist = e.clientX - startX;
stretch = stretchFromDist(dist);
vel = 0;
paint();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
target = 1;
if (reduce) { stretch = 1; paint(); return; }
startSpring();
}
btn.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
paint();
let autoTimer: number | null = null, autoDist = 0, growing = true;
function autoTick() {
if (growing) {
autoDist += 26;
stretch = stretchFromDist(autoDist);
paint();
if (autoDist >= 170) { growing = false; target = 1; startSpring(); }
} else {
growing = true; autoDist = 0;
}
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 200); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.brs}>
<div className={styles.field}>
<span className={styles.anchor} aria-hidden="true" />
<button className={styles.btn} type="button" style={{ background: color }}>당겨보기</button>
</div>
<p className={styles.hint}>멀어진 거리 값만큼 늘어났다가 놓으면 되돌아갑니다</p>
</div>
</div>
);
}04 Pull-distance stretch — the button's shape value stretches by an amount tied to how far the pointer's distance value has moved away, then returns when released — Measured area 3.297% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Button Press Animation UI: 9 Calculation Patterns
The post this demo lives in
Button Press Animation UI: 9 Calculation PatternsOne value overshoots its target and swings back before settling, one falls and bounces off a floor value before resting, See all demos in this post
If you like this effect - similar demos
Buttons09 Elastic snap-back value toggle — when a collapsed value turns on, a shape value shrinks, stretches slightly the other way, then returns to formExpanding Button UI: 9 Shape-Morph Patterns
Buttons02 Active-indicator slide — when the active-button value changes, the highlight background's position value glides to the new button along measured coordinatesButton Group CSS: 9 Adjoined Patterns
Buttons04 Button a Depth Value Pushes Only the Icon Apart9 CSS 3D Button Effects — Copy-paste Ready