05끝에서 고무줄처럼 튕기는 이전·다음
첫 쪽과 마지막 쪽에서 이전·다음 버튼은 사라지는 대신 흐려지고, 그 방향으로 누르려는 힘만큼 고무줄처럼 밀렸다 돌아온다.
쓰임더 갈 곳이 없을 때
PUSH_IMPULSERUBBER_LIMITEDGE_STIFFNESSEDGE_DAMPING

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__card is-mint" id="pgCard" aria-hidden="true"><b class="pg__no" id="pgNo">1</b><i class="pg__row"></i><i class="pg__row pg__row--s"></i></div>
<nav class="pg__nav" aria-label="페이지 이동">
<button type="button" class="pg__b" id="pgPrev" aria-label="이전 쪽">‹</button>
<span class="pg__count" id="pgCount"><b id="pgCur">1</b><span class="pg__sep">/</span><span id="pgTotal">6</span></span>
<button type="button" class="pg__b" id="pgNext" aria-label="다음 쪽">›</button>
</nav>
<div class="pg__tension" aria-hidden="true"><i class="pg__tl" id="pgTl"></i><i class="pg__tr" id="pgTr"></i></div>
<div class="pg__read"><span class="pg__k">장력</span><b id="pgForce">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 *;
// 핵심: 끝 쪽 이전·다음은 disabled 가 아니라 aria-disabled — 눌림이 스프링 충격량으로 들어가 고무줄처럼 밀렸다 돌아온다.
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; width: min(100%, 264px); justify-items: center; color: $stage-paper; }
.pg__card { display: grid; align-content: center; gap: $sp-2; height: 64px; box-sizing: border-box; padding: $sp-2 $sp-4; border-radius: $r-card; will-change: transform; }
.pg__card.is-mint { background: rgba($subject-mint, .4); }
.pg__card.is-lilac { background: rgba($subject-lilac, .44); }
.pg__card.is-pink { background: rgba($subject-pink, .42); }
.pg__card.is-sky { background: rgba($subject-sky, .42); }
.pg__no { font-size: 20px; line-height: 24px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__row { display: block; height: 6px; width: 70%; border-radius: $r-xs; background: rgba($stage-paper, .5); }
.pg__row--s { width: 44%; }
.pg__nav { display: flex; align-items: center; gap: $sp-4; }
.pg__b {
box-sizing: border-box;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-size: 22px;
line-height: 24px;
font-weight: 700;
cursor: pointer;
will-change: transform;
}
.pg__b.is-edge { opacity: .55; cursor: default; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__count { display: flex; align-items: baseline; justify-content: center; gap: $sp-1; min-width: 64px; font-size: 14px; line-height: 20px; color: rgba($stage-paper, .82); font-variant-numeric: tabular-nums; will-change: transform; }
.pg__count b { font-size: 24px; line-height: 28px; color: $subject-mint; }
.pg__tension { position: relative; width: 160px; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .14); overflow: hidden; }
.pg__tl, .pg__tr { position: absolute; top: 0; width: 50%; height: 4px; background: $subject-pink; }
.pg__tl { right: 50%; transform-origin: 100% 50%; transform: scaleX(0); }
.pg__tr { left: 50%; transform-origin: 0 50%; transform: scaleX(0); }
.pg__read { display: flex; align-items: baseline; gap: $sp-1; font-size: 12px; line-height: 16px; }
.pg__k, .pg__u { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-pink; font-variant-numeric: tabular-nums; min-width: 24px; text-align: right; margin-left: $sp-1; }
.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__nav { gap: $sp-3; }
.pg__b { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__b, .pg__count, .pg__card { transform: 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: 12px;
width: min(100%, 264px);
justify-items: center;
color: #ffffff;
}
.pg__card {
display: grid;
align-content: center;
gap: 8px;
height: 64px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
will-change: transform;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.4);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.44);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.42);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.42);
}
.pg__no {
font-size: 20px;
line-height: 24px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__row {
display: block;
height: 6px;
width: 70%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.pg__row--s {
width: 44%;
}
.pg__nav {
display: flex;
align-items: center;
gap: 16px;
}
.pg__b {
box-sizing: border-box;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-size: 22px;
line-height: 24px;
font-weight: 700;
cursor: pointer;
will-change: transform;
}
.pg__b.is-edge {
opacity: 0.55;
cursor: default;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__count {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
min-width: 64px;
font-size: 14px;
line-height: 20px;
color: rgba(255, 255, 255, 0.82);
font-variant-numeric: tabular-nums;
will-change: transform;
}
.pg__count b {
font-size: 24px;
line-height: 28px;
color: #4cd4a6;
}
.pg__tension {
position: relative;
width: 160px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.pg__tl, .pg__tr {
position: absolute;
top: 0;
width: 50%;
height: 4px;
background: #ff9ebb;
}
.pg__tl {
right: 50%;
transform-origin: 100% 50%;
transform: scaleX(0);
}
.pg__tr {
left: 50%;
transform-origin: 0 50%;
transform: scaleX(0);
}
.pg__read {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
}
.pg__k, .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;
margin-left: 4px;
}
.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__nav {
gap: 12px;
}
.pg__b {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__b, .pg__count, .pg__card {
transform: none;
}
}(function () {
// 기법: 끝 쪽에서 누르면 페이지는 안 바뀌고, 눌림이 스프링 속도(충격량)로 들어간다.
// 이미 많이 밀려 있을수록 충격량이 줄어(1 - 밀림/한계) 고무줄처럼 점점 뻑뻑해진다.
var TOTAL_PAGES = 6;
var PUSH_IMPULSE = 560;
var RUBBER_LIMIT = 26;
var EDGE_STIFFNESS = 260;
var EDGE_DAMPING = 15;
var LABEL_DRAG = 0.3;
var SUBSTEP = 1 / 240;
var CYCLE_MS = 2300;
// [시각 ms, 방향] 방향 -1 이전 · +1 다음
var DEMO_PRESSES = [[100, -1], [290, -1], [520, 1], [660, 1], [800, 1], [940, 1], [1080, 1], [1300, 1], [1460, 1], [1700, -1]];
var root = document.getElementById('pgRoot');
var prev = document.getElementById('pgPrev');
var next = document.getElementById('pgNext');
var count = document.getElementById('pgCount');
var curEl = document.getElementById('pgCur');
var forceEl = document.getElementById('pgForce');
var tl = document.getElementById('pgTl');
var tr = document.getElementById('pgTr');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.getElementById('pgTotal').textContent = String(TOTAL_PAGES);
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var CARD_DRAG = 0.7;
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var cur = 1;
var x = [0, 0];
var v = [0, 0];
var lastTs = null;
var t0 = null;
var cycleBase = 0;
var evt = 0;
function syncEdges() {
prev.setAttribute('aria-disabled', cur === 1 ? 'true' : 'false');
next.setAttribute('aria-disabled', cur === TOTAL_PAGES ? 'true' : 'false');
prev.className = 'pg__b' + (cur === 1 ? ' is-edge' : '');
next.className = 'pg__b' + (cur === TOTAL_PAGES ? ' is-edge' : '');
curEl.textContent = String(cur);
noEl.textContent = String(cur);
card.className = 'pg__card ' + TONES[cur % TONES.length];
}
function press(dir, byUser) {
if (byUser) root.classList.remove('is-demo');
var side = dir < 0 ? 0 : 1;
var target = cur + dir;
if (target < 1 || target > TOTAL_PAGES) {
if (!reduce) {
var slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
v[side] += dir * PUSH_IMPULSE * slack;
}
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
return;
}
cur = target;
syncEdges();
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
}
prev.addEventListener('click', function () { press(-1, true); });
next.addEventListener('click', function () { press(1, true); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { e.preventDefault(); press(-1, true); }
else if (e.key === 'ArrowRight') { e.preventDefault(); press(1, true); }
});
function step(side, dt) {
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
v[side] += (-EDGE_STIFFNESS * x[side] - EDGE_DAMPING * v[side]) * h;
x[side] += v[side] * h;
left -= h;
}
}
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')) {
while (evt < DEMO_PRESSES.length && now - cycleBase >= DEMO_PRESSES[evt][0]) {
press(DEMO_PRESSES[evt][1], false);
evt += 1;
}
if (now - cycleBase >= CYCLE_MS) { cycleBase = now; evt = 0; cur = 1; syncEdges(); }
}
step(0, dt);
step(1, dt);
prev.style.transform = 'translateX(' + x[0].toFixed(2) + 'px)';
next.style.transform = 'translateX(' + x[1].toFixed(2) + 'px)';
card.style.transform = 'translateX(' + ((x[0] + x[1]) * CARD_DRAG).toFixed(2) + 'px)';
count.style.transform = 'translateX(' + ((x[0] + x[1]) * LABEL_DRAG).toFixed(2) + 'px)';
tl.style.transform = 'scaleX(' + Math.min(1, Math.abs(x[0]) / RUBBER_LIMIT).toFixed(3) + ')';
tr.style.transform = 'scaleX(' + Math.min(1, Math.abs(x[1]) / RUBBER_LIMIT).toFixed(3) + ')';
forceEl.textContent = String(Math.round(Math.min(1, Math.max(Math.abs(x[0]), Math.abs(x[1])) / RUBBER_LIMIT) * 100));
requestAnimationFrame(tick);
}
syncEdges();
if (reduce) {
root.classList.remove('is-demo');
} else {
requestAnimationFrame(tick);
}
})();// RubberEdgePager — vanilla 판(05-pager-edge-rubber-band-nudge-disable) 의 React 포트.
// 끝 쪽에서 누르면 페이지는 안 바뀌고 눌림이 스프링 충격량으로 들어간다. 이미 밀려 있을수록 충격이 줄어든다.
import { useEffect, useRef } from "react";
import styles from "./RubberEdgePager.module.scss";
type Box<T> = { current: T };
const TOTAL_PAGES = 6;
const PUSH_IMPULSE = 560;
const RUBBER_LIMIT = 26;
const EDGE_STIFFNESS = 260;
const EDGE_DAMPING = 15;
const LABEL_DRAG = 0.3;
const CARD_DRAG = 0.7;
const SUBSTEP = 1 / 240;
const CYCLE_MS = 2300;
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
// [시각 ms, 방향] 방향 -1 이전 · +1 다음
const DEMO_PRESSES: Array<[number, number]> = [[100, -1], [290, -1], [520, 1], [660, 1], [800, 1], [940, 1], [1080, 1], [1300, 1], [1460, 1], [1700, -1]];
export default function RubberEdgePager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const prevRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const nextRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const curRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const forceRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const tlRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const trRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const root = rootRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const prev = prevRef.current;
const next = nextRef.current;
const count = countRef.current;
const curEl = curRef.current;
const forceEl = forceRef.current;
const tl = tlRef.current;
const tr = trRef.current;
const sr = srRef.current;
if (!root || !card || !noEl || !prev || !next || !count || !curEl || !forceEl || !tl || !tr || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let cur = 1;
const x = [0, 0];
const v = [0, 0];
let lastTs: number | null = null;
let t0: number | null = null;
let cycleBase = 0;
let evt = 0;
let raf = 0;
function syncEdges(): void {
prev!.setAttribute("aria-disabled", cur === 1 ? "true" : "false");
next!.setAttribute("aria-disabled", cur === TOTAL_PAGES ? "true" : "false");
prev!.className = [styles.pg__b, cur === 1 ? styles["is-edge"] : ""].join(" ");
next!.className = [styles.pg__b, cur === TOTAL_PAGES ? styles["is-edge"] : ""].join(" ");
curEl!.textContent = String(cur);
noEl!.textContent = String(cur);
card!.className = [styles.pg__card, styles[TONES[cur % TONES.length]]].join(" ");
}
function press(dir: number, byUser: boolean): void {
if (byUser) demo = false;
const side = dir < 0 ? 0 : 1;
const target = cur + dir;
if (target < 1 || target > TOTAL_PAGES) {
if (!reduce) {
const slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
v[side] += dir * PUSH_IMPULSE * slack;
}
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
return;
}
cur = target;
syncEdges();
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
}
const onPrev = (): void => press(-1, true);
const onNext = (): void => press(1, true);
const onKey = (e: KeyboardEvent): void => {
if (e.key === "ArrowLeft") { e.preventDefault(); press(-1, true); }
else if (e.key === "ArrowRight") { e.preventDefault(); press(1, true); }
};
prev.addEventListener("click", onPrev);
next.addEventListener("click", onNext);
root.addEventListener("keydown", onKey);
function step(side: number, dt: number): void {
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
v[side] += (-EDGE_STIFFNESS * x[side] - EDGE_DAMPING * v[side]) * h;
x[side] += v[side] * h;
left -= h;
}
}
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) {
while (evt < DEMO_PRESSES.length && now - cycleBase >= DEMO_PRESSES[evt][0]) {
press(DEMO_PRESSES[evt][1], false);
evt += 1;
}
if (now - cycleBase >= CYCLE_MS) { cycleBase = now; evt = 0; cur = 1; syncEdges(); }
}
step(0, dt);
step(1, dt);
prev!.style.transform = "translateX(" + x[0].toFixed(2) + "px)";
next!.style.transform = "translateX(" + x[1].toFixed(2) + "px)";
card!.style.transform = "translateX(" + ((x[0] + x[1]) * CARD_DRAG).toFixed(2) + "px)";
count!.style.transform = "translateX(" + ((x[0] + x[1]) * LABEL_DRAG).toFixed(2) + "px)";
tl!.style.transform = "scaleX(" + Math.min(1, Math.abs(x[0]) / RUBBER_LIMIT).toFixed(3) + ")";
tr!.style.transform = "scaleX(" + Math.min(1, Math.abs(x[1]) / RUBBER_LIMIT).toFixed(3) + ")";
forceEl!.textContent = String(Math.round(Math.min(1, Math.max(Math.abs(x[0]), Math.abs(x[1])) / RUBBER_LIMIT) * 100));
raf = requestAnimationFrame(tick);
}
syncEdges();
if (reduce) demo = false; else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
prev.removeEventListener("click", onPrev);
next.removeEventListener("click", onNext);
root.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={[styles.pg__card, styles["is-mint"]].join(" ")} ref={cardRef} aria-hidden="true">
<b className={styles.pg__no} ref={noRef}>1</b>
<i className={styles.pg__row}></i>
<i className={[styles.pg__row, styles["pg__row--s"]].join(" ")}></i>
</div>
<nav className={styles.pg__nav} aria-label="페이지 이동">
<button type="button" className={styles.pg__b} ref={prevRef} aria-label="이전 쪽">‹</button>
<span className={styles.pg__count} ref={countRef}><b ref={curRef}>1</b><span className={styles.pg__sep}>/</span><span>{TOTAL_PAGES}</span></span>
<button type="button" className={styles.pg__b} ref={nextRef} aria-label="다음 쪽">›</button>
</nav>
<div className={styles.pg__tension} aria-hidden="true"><i className={styles.pg__tl} ref={tlRef}></i><i className={styles.pg__tr} ref={trRef}></i></div>
<div className={styles.pg__read}><span className={styles.pg__k}>장력</span><b ref={forceRef}>0</b><span className={styles.pg__u}>%</span></div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}05 끝에서 고무줄처럼 튕기는 이전·다음 — 측정 누적면적 8.199% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동
이 데모가 속한 글
페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어 이 글의 데모 전부 보기


