04Numbers That Flash in Turn on a Long Jump
On a long jump such as page 1 to page 40, the numbers in between light up and fade one after another with a delay proportional to distance, so the eye follows the path.
Best forJumping to a distant page
STAGGER_STEPCHIP_COUNTATTACK_MSDECAY_MS

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="Page navigation"></nav>
<div class="pg__track" aria-hidden="true"><i class="pg__dot" id="pgDot"></i></div>
<div class="pg__read">
<b id="pgFrom">1</b><span class="pg__arrow">→</span><b id="pgTo">1</b>
<span class="pg__k">Span</span><b id="pgSpan">0</b>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) ms 늦게 번쩍인다 — 멀리 뛸수록 궤적이 길다.
$chip-w: 28px;
$chip-gap: 4px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; justify-items: center; color: $stage-paper; }
.pg__nav { display: flex; gap: $chip-gap; }
.pg__b {
box-sizing: border-box;
width: $chip-w;
height: 48px;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-sm;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
will-change: transform;
}
.pg__b.is-lit { background: $subject-lilac; border-color: $subject-lilac; color: $subject-ink; }
.pg__b.is-cur { background: $subject-mint; border-color: $subject-mint; color: $subject-ink; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__track { position: relative; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .14); width: 8 * ($chip-w + $chip-gap) + $chip-w; }
.pg__dot { position: absolute; left: 0; top: -4px; width: $chip-w; height: 12px; border-radius: $r-pill; background: $subject-pink; will-change: transform; }
.pg__read { display: flex; align-items: baseline; gap: $sp-2; font-size: 12px; line-height: 16px; }
.pg__read b { color: $subject-sky; font-variant-numeric: tabular-nums; }
.pg__arrow, .pg__k { color: rgba($stage-paper, .82); }
.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: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__b { 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__nav {
display: flex;
gap: 4px;
}
.pg__b {
box-sizing: border-box;
width: 28px;
height: 48px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
will-change: transform;
}
.pg__b.is-lit {
background: #b9a0ff;
border-color: #b9a0ff;
color: #17141a;
}
.pg__b.is-cur {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__track {
position: relative;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
width: 284px;
}
.pg__dot {
position: absolute;
left: 0;
top: -4px;
width: 28px;
height: 12px;
border-radius: 999px;
background: #ff9ebb;
will-change: transform;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
}
.pg__read b {
color: #6ec8ff;
font-variant-numeric: tabular-nums;
}
.pg__arrow, .pg__k {
color: rgba(255, 255, 255, 0.82);
}
.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: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__b {
transition: none;
}
}(function () {
// 기법: 출발 쪽과 도착 쪽 사이를 칩 아홉 개로 나눠 보여 주고, 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) 만큼 늦게 번쩍인다.
// 멀리 뛸수록 Total 궤적이 길어지고, 번쩍임은 attack 후 지수로 꺼지는 곡선이다.
var CHIP_COUNT = 9;
var TOTAL_PAGES = 40;
var STAGGER_STEP = 11;
var ATTACK_MS = 50;
var DECAY_MS = 220;
var SETTLE_MS = 160;
var START_MS = 100;
var DEMO_PAGES = [1, 40, 12, 33, 2, 25, 40];
var root = document.getElementById('pgRoot');
var nav = document.getElementById('pgNav');
var dot = document.getElementById('pgDot');
var fromEl = document.getElementById('pgFrom');
var toEl = document.getElementById('pgTo');
var spanEl = document.getElementById('pgSpan');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CHIP_W = 28;
var CHIP_GAP = 4;
var chips = [];
for (var i = 0; i < CHIP_COUNT; i += 1) {
var c = document.createElement('button');
c.type = 'button';
c.className = 'pg__b';
nav.appendChild(c);
chips.push(c);
}
var cur = 1;
var from = 1;
var to = 1;
var labels = [];
var delays = [];
var tStart = 0;
var totalMs = 0;
var running = false;
var t0 = null;
var demoIdx = 0;
var nextAt = START_MS;
function setLabels(a, b) {
labels = [];
delays = [];
for (var s = 0; s < CHIP_COUNT; s += 1) {
var page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
labels.push(page);
delays.push(Math.abs(page - a) * STAGGER_STEP);
chips[s].textContent = String(page);
}
totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
}
function paintRest() {
for (var s = 0; s < CHIP_COUNT; s += 1) {
var on = labels[s] === cur && (s === CHIP_COUNT - 1 || s === 0);
chips[s].className = 'pg__b' + (on ? ' is-cur' : '');
chips[s].style.transform = '';
if (on) chips[s].setAttribute('aria-current', 'page'); else chips[s].removeAttribute('aria-current');
}
fromEl.textContent = String(from);
toEl.textContent = String(to);
spanEl.textContent = String(Math.abs(to - from));
}
function jumpTo(page, byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (page === cur || page < 1 || page > TOTAL_PAGES) return;
from = cur;
to = page;
setLabels(from, to);
cur = page;
fromEl.textContent = String(from);
toEl.textContent = String(to);
spanEl.textContent = String(Math.abs(to - from));
if (byUser) sr.textContent = 'Page ' + cur + ' / ' + TOTAL_PAGES;
if (reduce) { setLabels(cur, cur); paintRest(); return; }
tStart = now;
running = true;
}
function pulseAt(age) {
if (age < 0) return 0;
if (age < ATTACK_MS) return age / ATTACK_MS;
return Math.exp(-(age - ATTACK_MS) / (DECAY_MS * 0.45));
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (root.classList.contains('is-demo') && now >= nextAt && !running) {
demoIdx = (demoIdx + 1) % DEMO_PAGES.length;
jumpTo(DEMO_PAGES[demoIdx], false, now);
nextAt = now + 1e9;
}
if (running) {
var elapsed = now - tStart;
for (var s = 0; s < CHIP_COUNT; s += 1) {
var p = pulseAt(elapsed - delays[s]);
var on = elapsed - delays[s] >= 0;
chips[s].className = 'pg__b' + (p > 0.02 ? ' is-lit' : '') + (on && s === CHIP_COUNT - 1 ? ' is-cur' : '');
chips[s].style.transform = p > 0.02 ? 'translateY(' + (-8 * p).toFixed(2) + 'px) scale(' + (1 + 0.22 * p).toFixed(3) + ')' : '';
chips[s].style.opacity = p > 0.02 ? '1' : (on ? '0.9' : '0.72');
}
var head = Math.min(1, elapsed / Math.max(1, totalMs - DECAY_MS));
dot.style.transform = 'translateX(' + (head * (CHIP_COUNT - 1) * (CHIP_W + CHIP_GAP)).toFixed(1) + 'px)';
if (elapsed >= totalMs + SETTLE_MS) {
running = false;
setLabels(cur, cur);
for (var q = 0; q < CHIP_COUNT; q += 1) chips[q].style.opacity = '';
dot.style.transform = 'translateX(0)';
paintRest();
if (root.classList.contains('is-demo')) nextAt = now + SETTLE_MS;
}
}
requestAnimationFrame(tick);
}
nav.addEventListener('click', function (e) {
var idx = chips.indexOf(e.target);
if (idx >= 0 && !running) jumpTo(labels[idx], true, t0 === null ? 0 : performance.now() - t0);
});
nav.addEventListener('keydown', function (e) {
var step = 0;
if (e.key === 'ArrowRight') step = 1;
else if (e.key === 'ArrowLeft') step = -1;
else if (e.key === 'PageDown') step = 10;
else if (e.key === 'PageUp') step = -10;
if (!step || running) return;
e.preventDefault();
jumpTo(Math.max(1, Math.min(TOTAL_PAGES, cur + step)), true, t0 === null ? 0 : performance.now() - t0);
});
setLabels(1, 1);
paintRest();
if (reduce) {
root.classList.remove('is-demo');
from = 1;
jumpTo(40, true, 0);
} else {
requestAnimationFrame(tick);
}
})();// JumpTrailPager — vanilla 판(04-pager-long-jump-stagger-trail-ripple) 의 React 포트.
// 출발·도착 사이를 칩 아홉 개로 나누고, 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) ms 늦게 번쩍인다.
import { useEffect, useRef } from "react";
import styles from "./JumpTrailPager.module.scss";
type Box<T> = { current: T };
const CHIP_COUNT = 9;
const TOTAL_PAGES = 40;
const STAGGER_STEP = 11;
const ATTACK_MS = 50;
const DECAY_MS = 220;
const SETTLE_MS = 160;
const START_MS = 100;
const CHIP_W = 28;
const CHIP_GAP = 4;
const DEMO_PAGES = [1, 40, 12, 33, 2, 25, 40];
export default function JumpTrailPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const navRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const dotRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const fromRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const toRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const spanRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const chipRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
useEffect(() => {
const nav = navRef.current;
const dot = dotRef.current;
const fromEl = fromRef.current;
const toEl = toRef.current;
const spanEl = spanRef.current;
const sr = srRef.current;
if (!nav || !dot || !fromEl || !toEl || !spanEl || !sr) return;
const chips = chipRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let cur = 1;
let from = 1;
let to = 1;
let labels: number[] = [];
let delays: number[] = [];
let tStart = 0;
let totalMs = 0;
let running = false;
let t0: number | null = null;
let demoIdx = 0;
let nextAt = START_MS;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function setLabels(a: number, b: number): void {
labels = [];
delays = [];
for (let s = 0; s < CHIP_COUNT; s += 1) {
const page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
labels.push(page);
delays.push(Math.abs(page - a) * STAGGER_STEP);
const chip = chips[s];
if (chip) chip.textContent = String(page);
}
totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
}
function paintRest(): void {
for (let s = 0; s < CHIP_COUNT; s += 1) {
const chip = chips[s];
if (!chip) continue;
const on = labels[s] === cur && (s === CHIP_COUNT - 1 || s === 0);
chip.className = [styles.pg__b, on ? styles["is-cur"] : ""].join(" ");
chip.style.transform = "";
if (on) chip.setAttribute("aria-current", "page"); else chip.removeAttribute("aria-current");
}
fromEl!.textContent = String(from);
toEl!.textContent = String(to);
spanEl!.textContent = String(Math.abs(to - from));
}
function jumpTo(page: number, byUser: boolean, now: number): void {
if (byUser) demo = false;
if (page === cur || page < 1 || page > TOTAL_PAGES) return;
from = cur;
to = page;
setLabels(from, to);
cur = page;
fromEl!.textContent = String(from);
toEl!.textContent = String(to);
spanEl!.textContent = String(Math.abs(to - from));
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
if (reduce) { setLabels(cur, cur); paintRest(); return; }
tStart = now;
running = true;
}
function pulseAt(age: number): number {
if (age < 0) return 0;
if (age < ATTACK_MS) return age / ATTACK_MS;
return Math.exp(-(age - ATTACK_MS) / (DECAY_MS * 0.45));
}
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
if (demo && now >= nextAt && !running) {
demoIdx = (demoIdx + 1) % DEMO_PAGES.length;
jumpTo(DEMO_PAGES[demoIdx], false, now);
nextAt = now + 1e9;
}
if (running) {
const elapsed = now - tStart;
for (let s = 0; s < CHIP_COUNT; s += 1) {
const chip = chips[s];
if (!chip) continue;
const p = pulseAt(elapsed - delays[s]);
const on = elapsed - delays[s] >= 0;
chip.className = [styles.pg__b, p > 0.02 ? styles["is-lit"] : "", on && s === CHIP_COUNT - 1 ? styles["is-cur"] : ""].join(" ");
chip.style.transform = p > 0.02 ? "translateY(" + (-8 * p).toFixed(2) + "px) scale(" + (1 + 0.22 * p).toFixed(3) + ")" : "";
chip.style.opacity = p > 0.02 ? "1" : on ? "0.9" : "0.72";
}
const head = Math.min(1, elapsed / Math.max(1, totalMs - DECAY_MS));
dot!.style.transform = "translateX(" + (head * (CHIP_COUNT - 1) * (CHIP_W + CHIP_GAP)).toFixed(1) + "px)";
if (elapsed >= totalMs + SETTLE_MS) {
running = false;
setLabels(cur, cur);
for (let q = 0; q < CHIP_COUNT; q += 1) {
const chip = chips[q];
if (chip) chip.style.opacity = "";
}
dot!.style.transform = "translateX(0)";
paintRest();
if (demo) nextAt = now + SETTLE_MS;
}
}
raf = requestAnimationFrame(tick);
}
const onClick = (e: MouseEvent): void => {
const idx = chips.indexOf(e.target as HTMLButtonElement);
if (idx >= 0 && !running) jumpTo(labels[idx], true, perfNow());
};
const onKey = (e: KeyboardEvent): void => {
let step = 0;
if (e.key === "ArrowRight") step = 1;
else if (e.key === "ArrowLeft") step = -1;
else if (e.key === "PageDown") step = 10;
else if (e.key === "PageUp") step = -10;
if (!step || running) return;
e.preventDefault();
jumpTo(Math.max(1, Math.min(TOTAL_PAGES, cur + step)), true, perfNow());
};
nav.addEventListener("click", onClick);
nav.addEventListener("keydown", onKey);
setLabels(1, 1);
paintRest();
if (reduce) {
demo = false;
from = 1;
jumpTo(40, true, 0);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
nav.removeEventListener("click", onClick);
nav.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<nav className={styles.pg__nav} ref={navRef} aria-label="페이지 이동">
{Array.from({ length: CHIP_COUNT }, (_, i) => (
<button type="button" className={styles.pg__b} key={i} ref={(el: HTMLButtonElement | null) => { chipRefs.current[i] = el; }}></button>
))}
</nav>
<div className={styles.pg__track} aria-hidden="true"><i className={styles.pg__dot} ref={dotRef}></i></div>
<div className={styles.pg__read}>
<b ref={fromRef}>1</b><span className={styles.pg__arrow}>→</span><b ref={toRef}>1</b>
<span className={styles.pg__k}>간격</span><b ref={spanRef}>0</b>
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}04 Numbers That Flash in Turn on a Long Jump — Measured area 13.989% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · Pagination CSS: 9 Pagers with Spring Pills
The post this demo lives in
Pagination CSS: 9 Pagers with Spring PillsPagination looks finished once ten numbers sit in a row, until page 40 makes the row change width and the current spot v See all demos in this post


