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 원작 · zip 에 SCSS·React 포함 · 버튼 눌림 효과 UI 9종 — 눌림 값이 되돌아오는 아홉 가지 방식
이 데모가 속한 글
버튼 눌림 효과 UI 9종 — 눌림 값이 되돌아오는 아홉 가지 방식버튼 눌림 효과는 버튼을 누르거나 놓는 순간의 깊이·위치·각도 같은 값이 물리 계산을 거쳐 원래 상태로 돌아가는 것을 말하는데, 아홉 개 중 하나는 눌린 값이 목표 값을 넘었다가 되돌아오길 반복하며 잦아들고, 하나는 이 글의 데모 전부 보기

