09스프링 상수 값이 튕겨 넘겼다 되돌리는 슬라이드 패널
스프링 감쇠 상수를 슬라이드 종료 위치에 적용해 패널이 목표 지점을 한 번 지나쳤다가 그 초과분(px)만큼 다시 당겨져 멈추고, 값이 작을수록 튕기는 폭이 커진다.
쓰임게임형 랜딩 탭·인터랙티브 데모 패널
STIFFNESSDAMPINGrequestAnimationFrame()

<main class="stage">
<div class="es is-demo" id="es">
<div class="es__tabs" role="tablist" aria-label="메뉴 탭">
<button type="button" class="es__tab is-active" role="tab" id="es-tab-0" aria-selected="true" aria-controls="es-panel-0" data-idx="0">인기</button>
<button type="button" class="es__tab" role="tab" id="es-tab-1" aria-selected="false" aria-controls="es-panel-1" data-idx="1" tabindex="-1">신상</button>
<button type="button" class="es__tab" role="tab" id="es-tab-2" aria-selected="false" aria-controls="es-panel-2" data-idx="2" tabindex="-1">할인</button>
</div>
<div class="es__viewport" id="esViewport">
<div class="es__track" id="esTrack">
<div class="es__panel" id="es-panel-0" role="tabpanel" aria-labelledby="es-tab-0" tabindex="0"><span class="es__num">128</span><p class="es__line">인기 상품 128종</p></div>
<div class="es__panel" id="es-panel-1" role="tabpanel" aria-labelledby="es-tab-1" tabindex="0"><span class="es__num">32</span><p class="es__line">이번 주 신상 32종</p></div>
<div class="es__panel" id="es-panel-2" role="tabpanel" aria-labelledby="es-tab-2" tabindex="0"><span class="es__num">54</span><p class="es__line">할인 진행 54종</p></div>
</div>
</div>
<p class="es__meta">초과분 <b id="esOver">0.0</b>px</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 의 frame() 이 매 프레임 실측 dt(ts 차이)로 스프링 힘 = 변위*STIFFNESS - 속도*DAMPING 을
// 적분해 track 을 목표 위치로 당긴다. DAMPING 이 작을수록 목표를 지나쳤다가 되돌아오는 초과분(px)이 커진다.
// transition 없이 매 프레임 transform 을 직접 쓴다(진짜 물리 계산 — Bug#1 면역).
@include stage($stage-ink);
.es { width: min(100%, 300px); min-width: 0; display: flex; flex-direction: column; gap: $sp-3; }
.es__tabs { display: flex; gap: $sp-1; background: rgba($subject-cream, .12); padding: $sp-1; border-radius: $r-pill; }
.es__tab {
flex: 1 1 0; border: none; background: transparent; border-radius: $r-pill; cursor: pointer;
padding: $sp-2 $sp-2; font-size: 11px; font-weight: 700; color: rgba($subject-cream, .72);
}
.es__tab.is-active { background: $subject-blue-deep; color: $subject-cream; }
.es__tab:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.es__viewport { position: relative; min-width: 0; height: 140px; border-radius: $r-card; background: $subject-cream; overflow: hidden; }
.es__track { display: flex; min-width: 0; height: 100%; will-change: transform; }
.es__panel {
flex: none; box-sizing: border-box; padding: $sp-4; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: $sp-2;
}
.es__num { font-size: 30px; font-weight: 800; color: $subject-blue-deep; }
.es__line { margin: 0; font-size: 12px; color: rgba($subject-ink, .72); }
.es__meta { margin: 0; font-size: 11px; color: rgba($subject-cream, .78); text-align: center; }
.es__meta b { color: $subject-cream; font-weight: 700; }
@media (max-width: 340px) {
.es { width: 100%; gap: $sp-2; }
.es__tab { padding: $sp-1 $sp-2; }
.es__viewport { height: 96px; }
.es__num { font-size: 24px; }
}
// 스프링 physics 는 transition 이 아니라 rAF(frame())에서 JS 로 reduce 분기해 즉시 스냅한다(index.html select()).
// 이 블록은 will-change 힌트만 거둬 접근성 도구가 reduce 상태를 css 에서도 확인할 수 있게 한다.
@media (prefers-reduced-motion: reduce) {
.es__track { will-change: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.es {
width: min(100%, 300px);
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.es__tabs {
display: flex;
gap: 4px;
background: rgba(255, 247, 230, 0.12);
padding: 4px;
border-radius: 999px;
}
.es__tab {
flex: 1 1 0;
border: none;
background: transparent;
border-radius: 999px;
cursor: pointer;
padding: 8px 8px;
font-size: 11px;
font-weight: 700;
color: rgba(255, 247, 230, 0.72);
}
.es__tab.is-active {
background: #0a4fe8;
color: #fff7e6;
}
.es__tab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.es__viewport {
position: relative;
min-width: 0;
height: 140px;
border-radius: 16px;
background: #fff7e6;
overflow: hidden;
}
.es__track {
display: flex;
min-width: 0;
height: 100%;
will-change: transform;
}
.es__panel {
flex: none;
box-sizing: border-box;
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.es__num {
font-size: 30px;
font-weight: 800;
color: #0a4fe8;
}
.es__line {
margin: 0;
font-size: 12px;
color: rgba(23, 20, 26, 0.72);
}
.es__meta {
margin: 0;
font-size: 11px;
color: rgba(255, 247, 230, 0.78);
text-align: center;
}
.es__meta b {
color: #fff7e6;
font-weight: 700;
}
@media (max-width: 340px) {
.es {
width: 100%;
gap: 8px;
}
.es__tab {
padding: 4px 8px;
}
.es__viewport {
height: 96px;
}
.es__num {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.es__track {
will-change: auto;
}
}(function () {
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var COUNT = 3;
var HOLD_MS = 900;
var STIFFNESS = 210; // 1/s^2 스케일 — 목표 지점으로 당기는 힘의 세기
var DAMPING = 20; // 계산 겹 상수: 스프링 감쇠 — 값이 작을수록 튕기는 폭이 커진다
var root = document.getElementById('es');
var viewport = document.getElementById('esViewport');
var track = document.getElementById('esTrack');
var overEl = document.getElementById('esOver');
var tabs = [0, 1, 2].map(function (i) { return document.getElementById('es-tab-' + i); });
var panels = [0, 1, 2].map(function (i) { return document.getElementById('es-panel-' + i); });
var isDemo = true;
var active = 0;
var pos = 0, vel = 0, target = 0;
var raf = 0, lastTs = 0;
var crossed = false, overshootPeak = 0, startErr = 0;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function widths() {
var w = viewport.clientWidth;
panels.forEach(function (p) { p.style.width = w + 'px'; });
return w;
}
function settleInstant() {
var w = widths();
pos = -active * w;
target = pos;
track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
}
function frame(ts) {
if (!lastTs) lastTs = ts;
var dt = Math.min(0.032, (ts - lastTs) / 1000);
lastTs = ts;
var err = target - pos;
if (!crossed && Math.sign(err) !== Math.sign(startErr) && startErr !== 0) crossed = true; // 계산 겹: 목표를 지나친 순간부터 초과분 추적 시작
if (crossed) overshootPeak = Math.max(overshootPeak, Math.abs(pos - target));
var force = err * STIFFNESS - vel * DAMPING; // 계산 겹: 스프링 힘 = 변위*강성 - 속도*감쇠
vel += force * dt;
pos += vel * dt;
track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
overEl.textContent = overshootPeak.toFixed(1);
if (Math.abs(target - pos) < 0.4 && Math.abs(vel) < 2) {
pos = target; vel = 0; lastTs = 0;
track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
raf = 0;
return;
}
raf = requestAnimationFrame(frame);
}
function select(idx) {
if (idx === active) return;
var w = widths();
target = -idx * w;
startErr = target - pos;
crossed = false;
overshootPeak = 0;
overEl.textContent = '0.0';
tabs[active].classList.remove('is-active');
tabs[active].setAttribute('aria-selected', 'false');
tabs[active].setAttribute('tabindex', '-1');
tabs[idx].classList.add('is-active');
tabs[idx].setAttribute('aria-selected', 'true');
tabs[idx].setAttribute('tabindex', '0');
active = idx;
if (reduce) {
if (raf) { cancelAnimationFrame(raf); raf = 0; }
pos = target; vel = 0;
track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
return;
}
if (!raf) { lastTs = 0; raf = requestAnimationFrame(frame); }
}
settleInstant();
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); select(i); });
});
root.addEventListener('keydown', function (e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = null;
if (e.key === 'ArrowRight') next = (idx + 1) % COUNT;
else if (e.key === 'ArrowLeft') next = (idx - 1 + COUNT) % COUNT;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = COUNT - 1;
if (next === null) return;
e.preventDefault();
human();
tabs[next].focus({ preventScroll: true });
select(next);
});
window.addEventListener('resize', function () {
if (!raf) settleInstant();
});
function loop() {
if (!isDemo) return;
select((active + 1) % COUNT);
window.setTimeout(loop, HOLD_MS);
}
if (!reduce) {
window.setTimeout(loop, HOLD_MS);
} else {
root.classList.remove('is-demo');
}
})();// TabElasticOvershootSlide — vanilla 판(run/394/demo/09-tab-panel-elastic-overshoot-slide/index.html) 의 React 포트.
// frame() 이 매 프레임 실측 dt 로 스프링 힘 = 변위*STIFFNESS - 속도*DAMPING 을 적분해 track 을 목표 위치로
// 당긴다. DAMPING 이 작을수록 목표를 지나쳤다가 되돌아오는 초과분(px)이 커진다. transition 없이 매 프레임
// transform 을 직접 쓴다(진짜 물리 계산).
import { useEffect, useRef } from "react";
import styles from "./TabElasticOvershootSlide.module.scss";
type Box<T> = { current: T };
const HOLD_MS = 900;
const COUNT = 3;
const STIFFNESS = 210;
const DAMPING = 20; // 계산 겹 상수: 스프링 감쇠 — 값이 작을수록 튕기는 폭이 커진다
export default function TabElasticOvershootSlide() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const overEl = useRef(null) as unknown as Box<HTMLElement>;
const tabRefs = useRef([null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const panelRefs = useRef([null, null, null]) as unknown as Box<(HTMLDivElement | null)[]>;
const timerRef = useRef(0) as unknown as Box<number>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let active = 0;
let pos = 0, vel = 0, target = 0;
let lastTs = 0;
let crossed = false, overshootPeak = 0, startErr = 0;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function widths() {
const viewport = viewportRef.current;
const w = viewport ? viewport.clientWidth : 0;
panelRefs.current.forEach((p) => { if (p) p.style.width = w + "px"; });
return w;
}
function settleInstant() {
const w = widths();
pos = -active * w;
target = pos;
if (trackRef.current) trackRef.current.style.transform = "translateX(" + pos.toFixed(2) + "px)";
}
function frame(ts: number) {
if (!lastTs) lastTs = ts;
const dt = Math.min(0.032, (ts - lastTs) / 1000);
lastTs = ts;
const err = target - pos;
if (!crossed && Math.sign(err) !== Math.sign(startErr) && startErr !== 0) crossed = true; // 계산 겹
if (crossed) overshootPeak = Math.max(overshootPeak, Math.abs(pos - target));
const force = err * STIFFNESS - vel * DAMPING; // 계산 겹: 스프링 힘
vel += force * dt;
pos += vel * dt;
if (trackRef.current) trackRef.current.style.transform = "translateX(" + pos.toFixed(2) + "px)";
if (overEl.current) overEl.current.textContent = overshootPeak.toFixed(1);
if (Math.abs(target - pos) < 0.4 && Math.abs(vel) < 2) {
pos = target; vel = 0; lastTs = 0;
if (trackRef.current) trackRef.current.style.transform = "translateX(" + pos.toFixed(2) + "px)";
rafRef.current = 0;
return;
}
rafRef.current = requestAnimationFrame(frame);
}
function select(idx: number) {
if (idx === active) return;
const w = widths();
target = -idx * w;
startErr = target - pos;
crossed = false;
overshootPeak = 0;
if (overEl.current) overEl.current.textContent = "0.0";
const prevTab = tabRefs.current[active], nextTab = tabRefs.current[idx];
prevTab?.classList.remove(styles.isActive);
prevTab?.setAttribute("aria-selected", "false");
prevTab?.setAttribute("tabindex", "-1");
nextTab?.classList.add(styles.isActive);
nextTab?.setAttribute("aria-selected", "true");
nextTab?.setAttribute("tabindex", "0");
active = idx;
if (reduce) {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; }
pos = target; vel = 0;
if (trackRef.current) trackRef.current.style.transform = "translateX(" + pos.toFixed(2) + "px)";
return;
}
if (!rafRef.current) { lastTs = 0; rafRef.current = requestAnimationFrame(frame); }
}
function onKeydown(e: KeyboardEvent) {
const idx = tabRefs.current.indexOf(document.activeElement as HTMLButtonElement);
if (idx === -1) return;
let next: number | null = null;
if (e.key === "ArrowRight") next = (idx + 1) % COUNT;
else if (e.key === "ArrowLeft") next = (idx - 1 + COUNT) % COUNT;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = COUNT - 1;
if (next === null) return;
e.preventDefault();
human();
tabRefs.current[next]?.focus({ preventScroll: true });
select(next);
}
function onResize() {
if (!rafRef.current) settleInstant();
}
function onTabClick(i: number) { return () => { human(); select(i); }; }
const clickHandlers = [0, 1, 2].map(onTabClick);
tabRefs.current.forEach((t, i) => t?.addEventListener("click", clickHandlers[i]));
rootRef.current?.addEventListener("keydown", onKeydown);
window.addEventListener("resize", onResize);
settleInstant();
function loop() {
if (!isDemo) return;
select((active + 1) % COUNT);
timerRef.current = window.setTimeout(loop, HOLD_MS);
}
if (!reduce) {
timerRef.current = window.setTimeout(loop, HOLD_MS);
} else {
rootRef.current?.classList.remove(styles.isDemo);
}
return () => {
window.clearTimeout(timerRef.current);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
window.removeEventListener("resize", onResize);
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", clickHandlers[i]));
rootRef.current?.removeEventListener("keydown", onKeydown);
};
}, []);
const cats = [
{ name: "인기", num: "128", line: "인기 상품 128종" },
{ name: "신상", num: "32", line: "이번 주 신상 32종" },
{ name: "할인", num: "54", line: "할인 진행 54종" },
];
return (
<main className={styles.stage}>
<div className={styles.es + " " + styles.isDemo} ref={rootRef}>
<div className={styles.tabs} role="tablist" aria-label="메뉴 탭">
{cats.map((c, i) => (
<button
key={c.name}
type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
role="tab"
aria-selected={i === 0 ? "true" : "false"}
tabIndex={i === 0 ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i] = el; }}
>{c.name}</button>
))}
</div>
<div className={styles.viewport} ref={viewportRef}>
<div className={styles.track} ref={trackRef}>
{cats.map((c, i) => (
<div
key={c.name}
className={styles.panel}
role="tabpanel"
tabIndex={0}
ref={(el: HTMLDivElement | null) => { panelRefs.current[i] = el; }}
>
<span className={styles.num}>{c.num}</span>
<p className={styles.line}>{c.line}</p>
</div>
))}
</div>
</div>
<p className={styles.meta}>
초과분 <b ref={overEl}>0.0</b>px
</p>
</div>
</main>
);
}09 스프링 상수 값이 튕겨 넘겼다 되돌리는 슬라이드 패널 — 측정 누적면적 8.585% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 탭 전환 애니메이션 9가지 패턴 — 코드 그대로
이 데모가 속한 글
탭 전환 애니메이션 9가지 패턴 — 코드 그대로탭 전환 애니메이션 아홉 개를 나란히 놓고 보면 높이 전환은 두 패널의 실측 높이 차이(px)로 늘어나는 시간을 정하는데, 스택 전환은 활성 탭과의 인덱스 거리로 축소 비율을 정하고, 탄성 슬라이드는 스프링 상수로 이 글의 데모 전부 보기

