07레일을 끌어 쪽을 고르는 관성 스냅
진행 레일의 손잡이를 끌다 놓으면 관성으로 미끄러지다 가장 가까운 쪽 눈금에 자석처럼 붙고, 손잡이 위 말풍선이 지나가는 쪽수를 알려 준다.
쓰임쪽수가 아주 많은 목록
FRICTIONSNAP_SPEEDSNAP_RATEVELOCITY_BLEND

<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>
<div class="pg__rail" id="pgRail">
<div class="pg__ticks" id="pgTicks" aria-hidden="true"></div>
<div class="pg__fill" id="pgFill" aria-hidden="true"></div>
<div class="pg__thumb" id="pgThumb" role="slider" tabindex="0" aria-label="페이지 이동" aria-valuemin="1" aria-valuemax="24" aria-valuenow="1">
<span class="pg__bubble" id="pgBubble" aria-hidden="true">1</span>
</div>
</div>
<div class="pg__read"><span class="pg__k">속도</span><b id="pgSpeed">0</b></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 놓는 순간의 속도로 관성 미끄러짐(FRICTION)을 계산하고, 느려지면 가장 가까운 눈금으로 붙는다(SNAP_RATE).
// 말풍선은 속도에 비례해 기울어진다.
$rail-w: 240px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; justify-items: center; color: $stage-paper; }
.pg__card { display: grid; align-content: center; gap: $sp-2; width: $rail-w + 20px; height: 60px; box-sizing: border-box; padding: $sp-2 $sp-4; border-radius: $r-card; }
.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__rail { position: relative; width: $rail-w; height: 20px; margin-top: $sp-6; touch-action: none; cursor: grab; }
.pg__rail::before { content: ""; position: absolute; left: 0; right: 0; top: 8px; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .18); }
.pg__fill { position: absolute; left: 0; top: 8px; width: $rail-w; height: 4px; border-radius: $r-xs; background: $subject-lilac; transform-origin: 0 50%; transform: scaleX(0); }
.pg__ticks { position: absolute; inset: 0; }
.pg__tick { position: absolute; left: 0; top: 4px; width: 2px; height: 12px; margin-left: -1px; border-radius: $r-xs; background: rgba($stage-paper, .42); }
.pg__thumb {
position: absolute;
left: -10px;
top: 0;
width: 20px;
height: 20px;
border-radius: $r-pill;
background: $subject-mint;
will-change: transform;
}
.pg__thumb:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__bubble {
position: absolute;
left: 50%;
bottom: 28px;
min-width: 28px;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $stage-paper;
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
.pg__read { display: flex; align-items: baseline; gap: $sp-2; font-size: 12px; line-height: 16px; }
.pg__k { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-pink; font-variant-numeric: tabular-nums; min-width: 32px; 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__card { width: $rail-w; }
}
@media (prefers-reduced-motion: reduce) {
.pg__thumb, .pg__fill { 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: 12px;
justify-items: center;
color: #ffffff;
}
.pg__card {
display: grid;
align-content: center;
gap: 8px;
width: 260px;
height: 60px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
}
.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__rail {
position: relative;
width: 240px;
height: 20px;
margin-top: 24px;
touch-action: none;
cursor: grab;
}
.pg__rail::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 8px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.pg__fill {
position: absolute;
left: 0;
top: 8px;
width: 240px;
height: 4px;
border-radius: 4px;
background: #b9a0ff;
transform-origin: 0 50%;
transform: scaleX(0);
}
.pg__ticks {
position: absolute;
inset: 0;
}
.pg__tick {
position: absolute;
left: 0;
top: 4px;
width: 2px;
height: 12px;
margin-left: -1px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.42);
}
.pg__thumb {
position: absolute;
left: -10px;
top: 0;
width: 20px;
height: 20px;
border-radius: 999px;
background: #4cd4a6;
will-change: transform;
}
.pg__thumb:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__bubble {
position: absolute;
left: 50%;
bottom: 28px;
min-width: 28px;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
}
.pg__k {
color: rgba(255, 255, 255, 0.82);
}
.pg__read b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
min-width: 32px;
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__card {
width: 240px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__thumb, .pg__fill {
transition: none;
}
}(function () {
// 기법: 손잡이를 놓는 순간의 속도(최근 포인터 이동의 지수 평균)로 관성 미끄러짐을 계산하고,
// 속도가 SNAP_SPEED 아래로 떨어지면 가장 가까운 쪽 눈금으로 지수 보간해 붙는다.
var TOTAL_PAGES = 24;
var RAIL_W = 240;
var FRICTION = 3.4;
var SNAP_SPEED = 150;
var SNAP_RATE = 16;
var VELOCITY_BLEND = 0.4;
var TILT_GAIN = 0.014;
var TILT_MAX = 16;
var CYCLE_MS = 2700;
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var root = document.getElementById('pgRoot');
var rail = document.getElementById('pgRail');
var ticksEl = document.getElementById('pgTicks');
var fill = document.getElementById('pgFill');
var thumb = document.getElementById('pgThumb');
var bubble = document.getElementById('pgBubble');
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var speedEl = document.getElementById('pgSpeed');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
thumb.setAttribute('aria-valuemax', String(TOTAL_PAGES));
function tickX(page) { return ((page - 1) / (TOTAL_PAGES - 1)) * RAIL_W; }
function nearestPage(px) { return Math.max(1, Math.min(TOTAL_PAGES, Math.round((px / RAIL_W) * (TOTAL_PAGES - 1)) + 1)); }
for (var i = 1; i <= TOTAL_PAGES; i += 1) {
var tk = document.createElement('i');
tk.className = 'pg__tick';
tk.style.transform = 'translateX(' + tickX(i).toFixed(2) + 'px)';
ticksEl.appendChild(tk);
}
var x = 0;
var v = 0;
var mode = 'idle';
var lastX = 0;
var lastMoveTs = 0;
var page = 1;
var snapTo = 0;
var t0 = null;
var lastTs = null;
var cycleBase = 0;
var demoStage = 0;
var dragFrom = 0;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function setPage(p, announce) {
if (p === page) return;
page = p;
noEl.textContent = String(p);
bubble.textContent = String(p);
thumb.setAttribute('aria-valuenow', String(p));
card.className = 'pg__card ' + TONES[p % TONES.length];
if (announce) sr.textContent = '현재 ' + p + ' / ' + TOTAL_PAGES;
}
function pointerMove(px, now) {
var nx = Math.max(0, Math.min(RAIL_W, px));
var dt = Math.max(0.004, (now - lastMoveTs) / 1000);
var inst = (nx - lastX) / dt;
v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
lastX = nx;
lastMoveTs = now;
x = nx;
mode = 'drag';
}
function release() {
if (mode !== 'drag') return;
if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = 'idle'; return; }
mode = 'glide';
}
function localX(e) { return e.clientX - rail.getBoundingClientRect().left; }
rail.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
try { rail.setPointerCapture(e.pointerId); } catch (err) { /* 무시 */ }
lastX = x;
lastMoveTs = perfNow();
v = 0;
pointerMove(localX(e), perfNow());
});
rail.addEventListener('pointermove', function (e) { if (mode === 'drag') pointerMove(localX(e), perfNow()); });
rail.addEventListener('pointerup', release);
rail.addEventListener('pointercancel', release);
thumb.addEventListener('keydown', function (e) {
var p = page;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') p += 1;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') p -= 1;
else if (e.key === 'PageUp') p += 5;
else if (e.key === 'PageDown') p -= 5;
else if (e.key === 'Home') p = 1;
else if (e.key === 'End') p = TOTAL_PAGES;
else return;
e.preventDefault();
root.classList.remove('is-demo');
p = Math.max(1, Math.min(TOTAL_PAGES, p));
v = 0;
snapTo = tickX(p);
mode = reduce ? 'idle' : 'snap';
if (reduce) x = snapTo;
setPage(p, true);
});
function demoDrive(now) {
var t = now - cycleBase;
if (t >= CYCLE_MS) { cycleBase = now; demoStage = 0; x = 0; v = 0; mode = 'idle'; setPage(1, false); return; }
if (t >= 100 && t < 450) {
var p1 = (t - 100) / 350;
if (mode !== 'drag') { lastX = x; lastMoveTs = now; v = 0; }
pointerMove(80 * p1 * p1, now);
demoStage = 1;
} else if (demoStage === 1 && t >= 450) {
release();
demoStage = 2;
} else if (t >= 900 && t < 1240) {
if (demoStage < 3) { demoStage = 3; lastX = x; lastMoveTs = now; v = 0; mode = 'drag'; dragFrom = x; }
var p2 = (t - 900) / 340;
pointerMove(dragFrom - 120 * p2 * p2, now);
} else if (demoStage === 3 && t >= 1240) {
release();
demoStage = 4;
}
}
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')) demoDrive(now);
if (mode === 'glide') {
x += v * dt;
v *= Math.exp(-FRICTION * dt);
if (x <= 0) { x = 0; v = 0; }
if (x >= RAIL_W) { x = RAIL_W; v = 0; }
if (Math.abs(v) < SNAP_SPEED) { snapTo = tickX(nearestPage(x)); mode = 'snap'; }
}
if (mode === 'snap') {
v *= Math.exp(-FRICTION * 3 * dt);
x += (snapTo - x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(snapTo - x) < 0.25) { x = snapTo; v = 0; mode = 'idle'; }
}
if (mode === 'drag') v *= Math.exp(-6 * dt);
setPage(nearestPage(x), mode === 'idle' && !root.classList.contains('is-demo'));
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
var tilt = Math.max(-TILT_MAX, Math.min(TILT_MAX, v * TILT_GAIN));
bubble.style.transform = 'translateX(-50%) rotate(' + tilt.toFixed(2) + 'deg)';
speedEl.textContent = String(Math.round(Math.abs(v)));
requestAnimationFrame(tick);
}
if (reduce) {
root.classList.remove('is-demo');
x = tickX(9);
setPage(9, false);
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
bubble.style.transform = 'translateX(-50%)';
// reduce 에서도 눌러 끌면 바로 따라가고 놓으면 가장 가까운 눈금에 붙는다
var follow = function () {
setPage(nearestPage(x), true);
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
};
rail.addEventListener('pointermove', follow);
rail.addEventListener('pointerup', follow);
thumb.addEventListener('keydown', follow);
rail.addEventListener('pointerdown', follow);
} else {
requestAnimationFrame(tick);
}
})();// RailScrubPager — vanilla 판(07-pager-progress-rail-scrub-inertia-snap) 의 React 포트.
// 놓는 순간의 속도로 관성 미끄러짐(FRICTION)을 계산하고, 느려지면 가장 가까운 쪽 눈금으로 지수 보간해 붙는다.
import { useEffect, useRef } from "react";
import styles from "./RailScrubPager.module.scss";
type Box<T> = { current: T };
type Mode = "idle" | "drag" | "glide" | "snap";
const TOTAL_PAGES = 24;
const RAIL_W = 240;
const FRICTION = 3.4;
const SNAP_SPEED = 150;
const SNAP_RATE = 16;
const VELOCITY_BLEND = 0.4;
const TILT_GAIN = 0.014;
const TILT_MAX = 16;
const CYCLE_MS = 2700;
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
const tickX = (page: number): number => ((page - 1) / (TOTAL_PAGES - 1)) * RAIL_W;
const nearestPage = (px: number): number => Math.max(1, Math.min(TOTAL_PAGES, Math.round((px / RAIL_W) * (TOTAL_PAGES - 1)) + 1));
export default function RailScrubPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const railRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const fillRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const thumbRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const bubbleRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const speedRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const root = rootRef.current;
const rail = railRef.current;
const fill = fillRef.current;
const thumb = thumbRef.current;
const bubble = bubbleRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const speedEl = speedRef.current;
const sr = srRef.current;
if (!root || !rail || !fill || !thumb || !bubble || !card || !noEl || !speedEl || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let x = 0;
let v = 0;
let mode: Mode = "idle";
let lastX = 0;
let lastMoveTs = 0;
let page = 1;
let snapTo = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let cycleBase = 0;
let demoStage = 0;
let dragFrom = 0;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function setPage(p: number, announce: boolean): void {
if (p === page) return;
page = p;
noEl!.textContent = String(p);
bubble!.textContent = String(p);
thumb!.setAttribute("aria-valuenow", String(p));
card!.className = [styles.pg__card, styles[TONES[p % TONES.length]]].join(" ");
if (announce) sr!.textContent = "현재 " + p + " / " + TOTAL_PAGES;
}
function pointerMove(px: number, now: number): void {
const nx = Math.max(0, Math.min(RAIL_W, px));
const dt = Math.max(0.004, (now - lastMoveTs) / 1000);
const inst = (nx - lastX) / dt;
v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
lastX = nx;
lastMoveTs = now;
x = nx;
mode = "drag";
}
function release(): void {
if (mode !== "drag") return;
if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = "idle"; return; }
mode = "glide";
}
const localX = (e: PointerEvent): number => e.clientX - rail.getBoundingClientRect().left;
const onDown = (e: PointerEvent): void => {
demo = false;
try { rail.setPointerCapture(e.pointerId); } catch (err) { /* 무시 */ }
lastX = x;
lastMoveTs = perfNow();
v = 0;
pointerMove(localX(e), perfNow());
};
const onMove = (e: PointerEvent): void => { if (mode === "drag") pointerMove(localX(e), perfNow()); };
const onKey = (e: KeyboardEvent): void => {
let p = page;
if (e.key === "ArrowRight" || e.key === "ArrowUp") p += 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") p -= 1;
else if (e.key === "PageUp") p += 5;
else if (e.key === "PageDown") p -= 5;
else if (e.key === "Home") p = 1;
else if (e.key === "End") p = TOTAL_PAGES;
else return;
e.preventDefault();
demo = false;
p = Math.max(1, Math.min(TOTAL_PAGES, p));
v = 0;
snapTo = tickX(p);
mode = reduce ? "idle" : "snap";
if (reduce) x = snapTo;
setPage(p, true);
};
rail.addEventListener("pointerdown", onDown);
rail.addEventListener("pointermove", onMove);
rail.addEventListener("pointerup", release);
rail.addEventListener("pointercancel", release);
thumb.addEventListener("keydown", onKey);
function demoDrive(now: number): void {
const t = now - cycleBase;
if (t >= CYCLE_MS) { cycleBase = now; demoStage = 0; x = 0; v = 0; mode = "idle"; setPage(1, false); return; }
if (t >= 100 && t < 450) {
const p1 = (t - 100) / 350;
if (mode !== "drag") { lastX = x; lastMoveTs = now; v = 0; }
pointerMove(80 * p1 * p1, now);
demoStage = 1;
} else if (demoStage === 1 && t >= 450) {
release();
demoStage = 2;
} else if (t >= 900 && t < 1240) {
if (demoStage < 3) { demoStage = 3; lastX = x; lastMoveTs = now; v = 0; mode = "drag"; dragFrom = x; }
const p2 = (t - 900) / 340;
pointerMove(dragFrom - 120 * p2 * p2, now);
} else if (demoStage === 3 && t >= 1240) {
release();
demoStage = 4;
}
}
function paintStatic(): void {
thumb!.style.transform = "translateX(" + x.toFixed(2) + "px)";
fill!.style.transform = "scaleX(" + (x / RAIL_W).toFixed(4) + ")";
}
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) demoDrive(now);
if (mode === "glide") {
x += v * dt;
v *= Math.exp(-FRICTION * dt);
if (x <= 0) { x = 0; v = 0; }
if (x >= RAIL_W) { x = RAIL_W; v = 0; }
if (Math.abs(v) < SNAP_SPEED) { snapTo = tickX(nearestPage(x)); mode = "snap"; }
}
if (mode === "snap") {
v *= Math.exp(-FRICTION * 3 * dt);
x += (snapTo - x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(snapTo - x) < 0.25) { x = snapTo; v = 0; mode = "idle"; }
}
if (mode === "drag") v *= Math.exp(-6 * dt);
setPage(nearestPage(x), mode === "idle" && !demo);
paintStatic();
const tilt = Math.max(-TILT_MAX, Math.min(TILT_MAX, v * TILT_GAIN));
bubble!.style.transform = "translateX(-50%) rotate(" + tilt.toFixed(2) + "deg)";
speedEl!.textContent = String(Math.round(Math.abs(v)));
raf = requestAnimationFrame(tick);
}
let follow: (() => void) | null = null;
if (reduce) {
demo = false;
x = tickX(9);
setPage(9, false);
paintStatic();
bubble.style.transform = "translateX(-50%)";
// reduce 에서도 눌러 끌면 바로 따라가고 놓으면 가장 가까운 눈금에 붙는다
const f = (): void => { setPage(nearestPage(x), true); paintStatic(); };
follow = f;
rail.addEventListener("pointermove", f);
rail.addEventListener("pointerup", f);
rail.addEventListener("pointerdown", f);
thumb.addEventListener("keydown", f);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
rail.removeEventListener("pointerdown", onDown);
rail.removeEventListener("pointermove", onMove);
rail.removeEventListener("pointerup", release);
rail.removeEventListener("pointercancel", release);
thumb.removeEventListener("keydown", onKey);
if (follow) {
rail.removeEventListener("pointermove", follow);
rail.removeEventListener("pointerup", follow);
rail.removeEventListener("pointerdown", follow);
thumb.removeEventListener("keydown", follow);
}
};
}, []);
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>
<div className={styles.pg__rail} ref={railRef}>
<div className={styles.pg__ticks} aria-hidden="true">
{Array.from({ length: TOTAL_PAGES }, (_, i) => (
<i className={styles.pg__tick} key={i} style={{ transform: "translateX(" + tickX(i + 1).toFixed(2) + "px)" }}></i>
))}
</div>
<div className={styles.pg__fill} ref={fillRef} aria-hidden="true"></div>
<div className={styles.pg__thumb} ref={thumbRef} role="slider" tabIndex={0} aria-label="페이지 이동" aria-valuemin={1} aria-valuemax={TOTAL_PAGES} aria-valuenow={1}>
<span className={styles.pg__bubble} ref={bubbleRef} aria-hidden="true">1</span>
</div>
</div>
<div className={styles.pg__read}><span className={styles.pg__k}>속도</span><b ref={speedRef}>0</b></div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}07 레일을 끌어 쪽을 고르는 관성 스냅 — 측정 누적면적 15.103% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동
이 데모가 속한 글
페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어 이 글의 데모 전부 보기

