04引っ張り距離値伸び — ポインターが離れた距離の値の分だけボタンの形の値が伸び、離すと元に戻る方式
ポインターがボタンから離れた距離の値が大きくなるほどボタンの伸びた形の値も大きくなり、離すと元の値に戻ります。
使いどころ引っ張って更新するリスト上部のボタン、スライドして確定する決済ボタン
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">引っ張ってみる</button>
</div>
<p class="brs__hint">離れた距離の値の分だけ伸び、離すと元に戻ります</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 引っ張り距離値伸び — ポインターが離れた距離の値の分だけボタンの形の値が伸び、離すと元に戻る方式 — 計測 累積面積 3.297% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り
このデモが属する記事
ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り9つのうち1つは押し込みの値が目標値を超えてから戻ることを繰り返して落ち着き、1つは落下した位置の値が床の値ではね返ることを繰り返して止まり、もう1つは離した位置の値が最も近い固定地点の値に引き寄せられて弾むように収まります。ボタン 押し込 この記事のデモをすべて見る

