02ばねで滑るアクティブなピル
現在のページを示すピルがばねで番号の間を移動し、目標を少し行き過ぎて戻る。速く動くほど進行方向に伸びて縮む。
使いどころ現在地を示す印
PILL_STIFFNESSPILL_DAMPINGSTRETCH_GAINSTRETCH_MAX

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="ページ移動">
<span class="pg__pill" id="pgPill" aria-hidden="true"></span>
</nav>
<div class="pg__read">
<span class="pg__k">オーバーシュート</span><b id="pgOver">0</b><span class="pg__u">%</span>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 알약 위치는 JS 가 스프링으로 적분한 값이다 (PILL_STIFFNESS · PILL_DAMPING).
// 속도에 비례해 알약이 늘어나고(scale) 도착점을 넘었다 돌아온다.
$slot-w: 36px;
$slot-gap: 4px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-4; justify-items: center; color: $stage-paper; }
.pg__nav {
position: relative;
display: flex;
gap: $slot-gap;
padding: $sp-1;
border-radius: $r-pill;
background: rgba($stage-paper, .1);
}
.pg__pill {
position: absolute;
left: $sp-1;
top: $sp-1;
width: $slot-w;
height: $slot-w;
border-radius: $r-pill;
background: $subject-lilac;
transform-origin: 50% 50%;
will-change: transform;
}
.pg__b {
position: relative;
box-sizing: border-box;
width: $slot-w;
height: $slot-w;
padding: 0;
border: 0;
border-radius: $r-pill;
background: transparent;
color: $stage-paper;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.pg__b.is-under { color: $subject-ink; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__read { display: flex; align-items: baseline; gap: $sp-1; font-size: 12px; line-height: 16px; }
.pg__k { color: rgba($stage-paper, .82); margin-right: $sp-1; }
.pg__u { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-pink; font-variant-numeric: tabular-nums; min-width: 24px; text-align: right; }
.pg__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pg__b { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__pill { transition: none; }
}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);
}
.pg {
display: grid;
gap: 16px;
justify-items: center;
color: #ffffff;
}
.pg__nav {
position: relative;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.pg__pill {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
border-radius: 999px;
background: #b9a0ff;
transform-origin: 50% 50%;
will-change: transform;
}
.pg__b {
position: relative;
box-sizing: border-box;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.pg__b.is-under {
color: #17141a;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
}
.pg__k {
color: rgba(255, 255, 255, 0.82);
margin-right: 4px;
}
.pg__u {
color: rgba(255, 255, 255, 0.82);
}
.pg__read b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
min-width: 24px;
text-align: right;
}
.pg__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pg__b {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__pill {
transition: none;
}
}(function () {
// 기법: 알약의 x 위치를 CSS 전환이 아니라 스프링 방정식으로 직접 적분한다.
// 가속도 = -강성 x (위치 - 목표) - 감쇠 x 速度. 속도가 클수록 알약이 진행 방향으로 늘어난다.
var PAGE_COUNT = 7;
var SLOT_W = 36;
var SLOT_GAP = 4;
var PILL_STIFFNESS = 320;
var PILL_DAMPING = 17;
var STRETCH_GAIN = 0.0011;
var STRETCH_MAX = 0.7;
var SUBSTEP = 1 / 240;
var STEP_MS = 400;
var START_MS = 150;
var DEMO_PAGES = [1, 7, 3, 6, 1, 4, 7];
var root = document.getElementById('pgRoot');
var nav = document.getElementById('pgNav');
var pill = document.getElementById('pgPill');
var overEl = document.getElementById('pgOver');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var btns = [];
for (var i = 0; i < PAGE_COUNT; i += 1) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pg__b';
b.textContent = String(i + 1);
nav.appendChild(b);
btns.push(b);
}
function slotX(page) { return (page - 1) * (SLOT_W + SLOT_GAP); }
var cur = 1;
var x = 0;
var v = 0;
var fromX = 0;
var peak = 0;
var lastTs = null;
var t0 = null;
var lastStep = -1;
function setCur(n, byUser) {
var next = Math.max(1, Math.min(PAGE_COUNT, n));
if (byUser) root.classList.remove('is-demo');
if (next === cur) return;
cur = next;
fromX = x;
peak = 0;
for (var s = 0; s < PAGE_COUNT; s += 1) {
if (s + 1 === cur) btns[s].setAttribute('aria-current', 'page'); else btns[s].removeAttribute('aria-current');
}
if (reduce) { x = slotX(cur); v = 0; paint(); }
if (byUser) sr.textContent = '現在 ' + cur + ' / ' + PAGE_COUNT;
}
function paint() {
var sx = 1 + Math.min(STRETCH_MAX, Math.abs(v) * STRETCH_GAIN);
var sy = 1 / Math.sqrt(sx);
pill.style.transform = 'translateX(' + x.toFixed(2) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
var near = Math.max(0, Math.min(PAGE_COUNT - 1, Math.round(x / (SLOT_W + SLOT_GAP))));
for (var s = 0; s < PAGE_COUNT; s += 1) btns[s].className = 'pg__b' + (s === near ? ' is-under' : '');
}
nav.addEventListener('click', function (e) {
var idx = btns.indexOf(e.target);
if (idx >= 0) setCur(idx + 1, true);
});
nav.addEventListener('keydown', function (e) {
var to = null;
if (e.key === 'ArrowRight') to = cur + 1;
else if (e.key === 'ArrowLeft') to = cur - 1;
else if (e.key === 'Home') to = 1;
else if (e.key === 'End') to = PAGE_COUNT;
if (to === null) return;
e.preventDefault();
setCur(to, true);
btns[Math.max(1, Math.min(PAGE_COUNT, to)) - 1].focus({ preventScroll: true });
});
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) {
var step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) { lastStep = step; setCur(DEMO_PAGES[step], false); }
}
var target = slotX(cur);
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
x += v * h;
left -= h;
}
var span = target - fromX;
if (span !== 0) {
var over = ((x - target) * Math.sign(span)) / Math.abs(span);
if (over > peak) peak = over;
overEl.textContent = String(Math.round(peak * 100));
}
paint();
requestAnimationFrame(tick);
}
btns[0].setAttribute('aria-current', 'page');
if (reduce) {
root.classList.remove('is-demo');
cur = 4;
x = slotX(cur);
for (var q = 0; q < PAGE_COUNT; q += 1) {
if (q + 1 === cur) btns[q].setAttribute('aria-current', 'page'); else btns[q].removeAttribute('aria-current');
}
paint();
} else {
paint();
requestAnimationFrame(tick);
}
})();// SpringPillPager — vanilla 판(02-pager-active-pill-spring-slide-overshoot) 의 React 포트.
// 알약의 x 위치를 CSS 전환이 아니라 스프링 방정식(강성·감쇠)으로 직접 적분하고, 속도에 비례해 늘어나게 한다.
import { useEffect, useRef } from "react";
import styles from "./SpringPillPager.module.scss";
type Box<T> = { current: T };
const PAGE_COUNT = 7;
const SLOT_W = 36;
const SLOT_GAP = 4;
const PILL_STIFFNESS = 320;
const PILL_DAMPING = 17;
const STRETCH_GAIN = 0.0011;
const STRETCH_MAX = 0.7;
const SUBSTEP = 1 / 240;
const STEP_MS = 400;
const START_MS = 150;
const DEMO_PAGES = [1, 7, 3, 6, 1, 4, 7];
export default function SpringPillPager() {
const navRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const pillRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const overRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
useEffect(() => {
const nav = navRef.current;
const pill = pillRef.current;
const overEl = overRef.current;
const sr = srRef.current;
if (!nav || !pill || !overEl || !sr) return;
const btns = btnRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const slotX = (page: number): number => (page - 1) * (SLOT_W + SLOT_GAP);
let demo = true;
let cur = 1;
let x = 0;
let v = 0;
let fromX = 0;
let peak = 0;
let lastTs: number | null = null;
let t0: number | null = null;
let lastStep = -1;
let raf = 0;
function mark(): void {
for (let s = 0; s < PAGE_COUNT; s += 1) {
const el = btns[s];
if (!el) continue;
if (s + 1 === cur) el.setAttribute("aria-current", "page"); else el.removeAttribute("aria-current");
}
}
function paint(): void {
const sx = 1 + Math.min(STRETCH_MAX, Math.abs(v) * STRETCH_GAIN);
const sy = 1 / Math.sqrt(sx);
pill!.style.transform = "translateX(" + x.toFixed(2) + "px) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
const near = Math.max(0, Math.min(PAGE_COUNT - 1, Math.round(x / (SLOT_W + SLOT_GAP))));
for (let s = 0; s < PAGE_COUNT; s += 1) {
const el = btns[s];
if (el) el.className = [styles.pg__b, s === near ? styles["is-under"] : ""].join(" ");
}
}
function setCur(n: number, byUser: boolean): void {
const next = Math.max(1, Math.min(PAGE_COUNT, n));
if (byUser) demo = false;
if (next === cur) return;
cur = next;
fromX = x;
peak = 0;
mark();
if (reduce) { x = slotX(cur); v = 0; paint(); }
if (byUser) sr!.textContent = "현재 " + cur + " / " + PAGE_COUNT;
}
const onClick = (e: MouseEvent): void => {
const idx = btns.indexOf(e.target as HTMLButtonElement);
if (idx >= 0) setCur(idx + 1, true);
};
const onKey = (e: KeyboardEvent): void => {
let to: number | null = null;
if (e.key === "ArrowRight") to = cur + 1;
else if (e.key === "ArrowLeft") to = cur - 1;
else if (e.key === "Home") to = 1;
else if (e.key === "End") to = PAGE_COUNT;
if (to === null) return;
e.preventDefault();
setCur(to, true);
const target = btns[Math.max(1, Math.min(PAGE_COUNT, to)) - 1];
if (target) target.focus({ preventScroll: true });
};
nav.addEventListener("click", onClick);
nav.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) { lastStep = step; setCur(DEMO_PAGES[step], false); }
}
const target = slotX(cur);
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
x += v * h;
left -= h;
}
const span = target - fromX;
if (span !== 0) {
const over = ((x - target) * Math.sign(span)) / Math.abs(span);
if (over > peak) peak = over;
overEl!.textContent = String(Math.round(peak * 100));
}
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) {
demo = false;
cur = 4;
x = slotX(cur);
mark();
paint();
} else {
mark();
paint();
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
nav.removeEventListener("click", onClick);
nav.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg}>
<nav className={styles.pg__nav} ref={navRef} aria-label="페이지 이동">
<span className={styles.pg__pill} ref={pillRef} aria-hidden="true"></span>
{Array.from({ length: PAGE_COUNT }, (_, i) => (
<button type="button" className={styles.pg__b} key={i} ref={(el: HTMLButtonElement | null) => { btnRefs.current[i] = el; }}>{i + 1}</button>
))}
</nav>
<div className={styles.pg__read}>
<span className={styles.pg__k}>오버슛</span><b ref={overRef}>0</b><span className={styles.pg__u}>%</span>
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}02 ばねで滑るアクティブなピル — 計測 累積面積 7.7% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · ページネーション デザイン 9パターン — 窓の計算からばねのピルまで
このデモが属する記事
ページネーション デザイン 9パターン — 窓の計算からばねのピルまでページネーションは番号を十個並べれば終わりに見えるが、40ページ付近で枠の数が揺れ現在地が見えなくなる — 01番がその二か所を塞ぐ。ページネーション デザインは、長い一覧をページに分けて現在地と行き先を示す番号列の見せ方です。 この記事のデモをすべて見る


