09Load More with a Remaining-Count Ring
A ring around the load-more button fills by the share already loaded, the remaining-count number ticks down, and at zero the button folds into a done mark.
Best forLists that never show everything at once
PAGE_SIZERING_STIFFNESSRING_DAMPINGNUM_RATE

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<button type="button" class="pg__more" id="pgMore" aria-describedby="pgSr">
<svg class="pg__ring" viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<circle class="pg__track" cx="48" cy="48" r="42"></circle>
<circle class="pg__arc" id="pgArc" cx="48" cy="48" r="42"></circle>
<circle class="pg__sweep" id="pgSweep" cx="48" cy="48" r="42"></circle>
</svg>
<span class="pg__num" id="pgNum">36</span>
<span class="pg__label" id="pgLabel">Load more</span>
</button>
<div class="pg__grid" id="pgGrid" aria-hidden="true"></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 링은 stroke-dashoffset 을 스프링으로 채우고, 남은 개수는 지수 보간으로 굴러 내려간다.
// 불러오는 동안(LOAD_MS) 호가 링을 돌고, 새로 들어온 칸은 순서대로 튄다.
$ring: 96px;
$cell: 18px;
$cell-gap: 4px;
@include stage($stage-ink);
.pg { display: flex; align-items: center; gap: $sp-4; color: $stage-paper; }
.pg__more {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
align-content: center;
width: $ring;
height: $ring;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
cursor: pointer;
}
.pg__more:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__more.is-done { cursor: default; }
.pg__ring { position: absolute; inset: 0; width: $ring; height: $ring; }
.pg__ring circle { fill: none; stroke-width: 6; stroke-linecap: round; transform-origin: 48px 48px; }
.pg__track { stroke: rgba($stage-paper, .16); }
.pg__arc { stroke: $subject-lilac; transform: rotate(-90deg); }
.pg__more.is-done .pg__arc { stroke: $subject-mint; }
.pg__sweep { stroke: $subject-pink; opacity: 0; }
.pg__num { position: relative; font-size: 28px; line-height: 32px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__label { position: relative; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba($stage-paper, .86); }
.pg__grid { display: grid; grid-template-columns: repeat(8, $cell); gap: $cell-gap; }
.pg__cell { display: block; width: $cell; height: $cell; border-radius: $r-xs; background: rgba($stage-paper, .1); }
.pg__cell.is-on.is-sky { background: $subject-sky; }
.pg__cell.is-on.is-mint { background: $subject-mint; }
.pg__cell.is-on.is-lilac { background: $subject-lilac; }
.pg__cell.is-on.is-pink { background: $subject-pink; }
.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 { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.pg__arc { 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: flex;
align-items: center;
gap: 16px;
color: #ffffff;
}
.pg__more {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
align-content: center;
width: 96px;
height: 96px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
cursor: pointer;
}
.pg__more:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__more.is-done {
cursor: default;
}
.pg__ring {
position: absolute;
inset: 0;
width: 96px;
height: 96px;
}
.pg__ring circle {
fill: none;
stroke-width: 6;
stroke-linecap: round;
transform-origin: 48px 48px;
}
.pg__track {
stroke: rgba(255, 255, 255, 0.16);
}
.pg__arc {
stroke: #b9a0ff;
transform: rotate(-90deg);
}
.pg__more.is-done .pg__arc {
stroke: #4cd4a6;
}
.pg__sweep {
stroke: #ff9ebb;
opacity: 0;
}
.pg__num {
position: relative;
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__label {
position: relative;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.86);
}
.pg__grid {
display: grid;
grid-template-columns: repeat(8, 18px);
gap: 4px;
}
.pg__cell {
display: block;
width: 18px;
height: 18px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
}
.pg__cell.is-on.is-sky {
background: #6ec8ff;
}
.pg__cell.is-on.is-mint {
background: #4cd4a6;
}
.pg__cell.is-on.is-lilac {
background: #b9a0ff;
}
.pg__cell.is-on.is-pink {
background: #ff9ebb;
}
.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 {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__arc {
transition: none;
}
}(function () {
// 기법: 링은 SVG 원의 stroke-dashoffset 으로 채운다. 채움 비율은 스프링으로 따라가 목표를 살짝 넘었다 돌아오고,
// Left 개수 숫자는 지수 보간으로 굴러 내려간다. 불러오는 동안(LOAD_MS)에는 호가 링을 한 바퀴씩 돈다.
var TOTAL_ITEMS = 48;
var PAGE_SIZE = 12;
var START_LOADED = 12;
var RING_R = 42;
var RING_STIFFNESS = 220;
var RING_DAMPING = 15;
var NUM_RATE = 12;
var LOAD_MS = 240;
var CELL_STAGGER = 24;
var POP_MS = 380;
var SUBSTEP = 1 / 240;
var CYCLE_MS = 1800;
var PRESS_AT = [100, 420, 740, 1060];
var TONES = ['is-sky', 'is-mint', 'is-lilac', 'is-pink'];
var CIRC = 2 * Math.PI * RING_R;
var root = document.getElementById('pgRoot');
var more = document.getElementById('pgMore');
var arc = document.getElementById('pgArc');
var sweep = document.getElementById('pgSweep');
var numEl = document.getElementById('pgNum');
var labelEl = document.getElementById('pgLabel');
var grid = document.getElementById('pgGrid');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
arc.style.strokeDasharray = CIRC.toFixed(2);
sweep.style.strokeDasharray = (CIRC * 0.16).toFixed(2) + ' ' + CIRC.toFixed(2);
var cells = [];
var born = [];
for (var i = 0; i < TOTAL_ITEMS; i += 1) {
var c = document.createElement('i');
c.className = 'pg__cell';
grid.appendChild(c);
cells.push(c);
born.push(-1);
}
var loaded = 0;
var pending = null;
var frac = 0;
var fv = 0;
var numShown = TOTAL_ITEMS;
var t0 = null;
var lastTs = null;
var cycleBase = 0;
var pi = 0;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paintCells() {
for (var s = 0; s < TOTAL_ITEMS; s += 1) {
cells[s].className = 'pg__cell' + (s < loaded ? ' is-on ' + TONES[Math.floor(s / PAGE_SIZE) % TONES.length] : '');
}
}
function finishLoad(now) {
var from = loaded;
loaded = Math.min(TOTAL_ITEMS, loaded + PAGE_SIZE);
for (var s = from; s < loaded; s += 1) born[s] = reduce ? -1 : now + (s - from) * CELL_STAGGER;
pending = null;
sweep.style.opacity = '0';
paintCells();
var remain = TOTAL_ITEMS - loaded;
var done = remain === 0;
labelEl.textContent = done ? 'Done' : 'Load more';
more.setAttribute('aria-disabled', done ? 'true' : 'false');
more.className = 'pg__more' + (done ? ' is-done' : '');
sr.textContent = done ? 'Done' : 'Left ' + remain + ' items';
if (reduce) { frac = loaded / TOTAL_ITEMS; numShown = remain; paintStatic(); }
}
function paintStatic() {
arc.style.strokeDashoffset = (CIRC * (1 - Math.min(1, frac))).toFixed(2);
numEl.textContent = loaded >= TOTAL_ITEMS ? '✓' : String(Math.round(numShown));
}
function press(byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (pending !== null || loaded >= TOTAL_ITEMS) return;
if (reduce) { finishLoad(now); return; }
pending = now;
sweep.style.opacity = '1';
}
more.addEventListener('click', function () { press(true, perfNow()); });
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 t = now - cycleBase;
while (pi < PRESS_AT.length && t >= PRESS_AT[pi]) { press(false, now); pi += 1; }
if (t >= CYCLE_MS) {
cycleBase = now; pi = 0; loaded = START_LOADED; frac = START_LOADED / TOTAL_ITEMS; fv = 0;
numShown = TOTAL_ITEMS - START_LOADED;
for (var q = 0; q < TOTAL_ITEMS; q += 1) born[q] = -1;
paintCells();
labelEl.textContent = 'Load more';
more.setAttribute('aria-disabled', 'false');
more.className = 'pg__more';
}
}
if (pending !== null) {
var ang = ((now - pending) / LOAD_MS) * 360;
sweep.style.transform = 'rotate(' + (ang - 90).toFixed(1) + 'deg)';
if (now - pending >= LOAD_MS) finishLoad(now);
}
var target = loaded / TOTAL_ITEMS;
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
frac += fv * h;
left -= h;
}
numShown += ((TOTAL_ITEMS - loaded) - numShown) * (1 - Math.exp(-dt * NUM_RATE));
paintStatic();
for (var s = 0; s < TOTAL_ITEMS; s += 1) {
if (born[s] < 0) continue;
var age = now - born[s];
var sc = age < 0 ? 0 : age >= POP_MS ? 1 : 1 - Math.exp(-age / 70) * Math.cos(age / 55);
cells[s].style.transform = sc === 1 ? '' : 'scale(' + sc.toFixed(3) + ')';
if (sc === 1) born[s] = -1;
}
requestAnimationFrame(tick);
}
loaded = START_LOADED;
frac = START_LOADED / TOTAL_ITEMS;
numShown = TOTAL_ITEMS - START_LOADED;
sweep.style.opacity = '0';
paintCells();
paintStatic();
if (reduce) {
root.classList.remove('is-demo');
} else {
requestAnimationFrame(tick);
}
})();// LoadMoreRing — vanilla 판(09-pager-load-more-remaining-count-ring) 의 React 포트.
// 링은 stroke-dashoffset 을 스프링으로 채우고, 남은 개수는 지수 보간으로 굴러 내려간다.
import { useEffect, useRef } from "react";
import styles from "./LoadMoreRing.module.scss";
type Box<T> = { current: T };
const TOTAL_ITEMS = 48;
const PAGE_SIZE = 12;
const START_LOADED = 12;
const RING_R = 42;
const RING_STIFFNESS = 220;
const RING_DAMPING = 15;
const NUM_RATE = 12;
const LOAD_MS = 240;
const CELL_STAGGER = 24;
const POP_MS = 380;
const SUBSTEP = 1 / 240;
const CYCLE_MS = 1800;
const PRESS_AT = [100, 420, 740, 1060];
const TONES = ["is-sky", "is-mint", "is-lilac", "is-pink"];
const CIRC = 2 * Math.PI * RING_R;
export default function LoadMoreRing() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const moreRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const arcRef = useRef<SVGCircleElement | null>(null) as Box<SVGCircleElement | null>;
const sweepRef = useRef<SVGCircleElement | null>(null) as Box<SVGCircleElement | null>;
const numRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const labelRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const cellRefs = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
useEffect(() => {
const more = moreRef.current;
const arc = arcRef.current;
const sweep = sweepRef.current;
const numEl = numRef.current;
const labelEl = labelRef.current;
const sr = srRef.current;
if (!more || !arc || !sweep || !numEl || !labelEl || !sr) return;
const cells = cellRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
arc.style.strokeDasharray = CIRC.toFixed(2);
sweep.style.strokeDasharray = (CIRC * 0.16).toFixed(2) + " " + CIRC.toFixed(2);
const born: number[] = [];
for (let i = 0; i < TOTAL_ITEMS; i += 1) born.push(-1);
let demo = true;
let loaded = START_LOADED;
let pending: number | null = null;
let frac = START_LOADED / TOTAL_ITEMS;
let fv = 0;
let numShown = TOTAL_ITEMS - START_LOADED;
let t0: number | null = null;
let lastTs: number | null = null;
let cycleBase = 0;
let pi = 0;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintCells(): void {
for (let s = 0; s < TOTAL_ITEMS; s += 1) {
const el = cells[s];
if (!el) continue;
el.className = s < loaded
? [styles.pg__cell, styles["is-on"], styles[TONES[Math.floor(s / PAGE_SIZE) % TONES.length]]].join(" ")
: styles.pg__cell;
}
}
function paintStatic(): void {
arc!.style.strokeDashoffset = (CIRC * (1 - Math.min(1, frac))).toFixed(2);
numEl!.textContent = loaded >= TOTAL_ITEMS ? "✓" : String(Math.round(numShown));
}
function finishLoad(now: number): void {
const from = loaded;
loaded = Math.min(TOTAL_ITEMS, loaded + PAGE_SIZE);
for (let s = from; s < loaded; s += 1) born[s] = reduce ? -1 : now + (s - from) * CELL_STAGGER;
pending = null;
sweep!.style.opacity = "0";
paintCells();
const remain = TOTAL_ITEMS - loaded;
const done = remain === 0;
labelEl!.textContent = done ? "완료" : "더 보기";
more!.setAttribute("aria-disabled", done ? "true" : "false");
more!.className = [styles.pg__more, done ? styles["is-done"] : ""].join(" ");
sr!.textContent = done ? "완료" : "남은 " + remain + "개";
if (reduce) { frac = loaded / TOTAL_ITEMS; numShown = remain; paintStatic(); }
}
function press(byUser: boolean, now: number): void {
if (byUser) demo = false;
if (pending !== null || loaded >= TOTAL_ITEMS) return;
if (reduce) { finishLoad(now); return; }
pending = now;
sweep!.style.opacity = "1";
}
const onClick = (): void => press(true, perfNow());
more.addEventListener("click", onClick);
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 t = now - cycleBase;
while (pi < PRESS_AT.length && t >= PRESS_AT[pi]) { press(false, now); pi += 1; }
if (t >= CYCLE_MS) {
cycleBase = now; pi = 0; loaded = START_LOADED; frac = START_LOADED / TOTAL_ITEMS; fv = 0;
numShown = TOTAL_ITEMS - START_LOADED;
for (let q = 0; q < TOTAL_ITEMS; q += 1) born[q] = -1;
paintCells();
labelEl!.textContent = "더 보기";
more!.setAttribute("aria-disabled", "false");
more!.className = styles.pg__more;
}
}
if (pending !== null) {
const ang = ((now - pending) / LOAD_MS) * 360;
sweep!.style.transform = "rotate(" + (ang - 90).toFixed(1) + "deg)";
if (now - pending >= LOAD_MS) finishLoad(now);
}
const target = loaded / TOTAL_ITEMS;
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
frac += fv * h;
left -= h;
}
numShown += (TOTAL_ITEMS - loaded - numShown) * (1 - Math.exp(-dt * NUM_RATE));
paintStatic();
for (let s = 0; s < TOTAL_ITEMS; s += 1) {
if (born[s] < 0) continue;
const age = now - born[s];
const sc = age < 0 ? 0 : age >= POP_MS ? 1 : 1 - Math.exp(-age / 70) * Math.cos(age / 55);
const el = cells[s];
if (el) el.style.transform = sc === 1 ? "" : "scale(" + sc.toFixed(3) + ")";
if (sc === 1) born[s] = -1;
}
raf = requestAnimationFrame(tick);
}
sweep.style.opacity = "0";
paintCells();
paintStatic();
if (reduce) demo = false; else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
more.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<button type="button" className={styles.pg__more} ref={moreRef}>
<svg className={styles.pg__ring} viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<circle className={styles.pg__track} cx="48" cy="48" r="42"></circle>
<circle className={styles.pg__arc} cx="48" cy="48" r="42" ref={arcRef}></circle>
<circle className={styles.pg__sweep} cx="48" cy="48" r="42" ref={sweepRef}></circle>
</svg>
<span className={styles.pg__num} ref={numRef}>36</span>
<span className={styles.pg__label} ref={labelRef}>더 보기</span>
</button>
<div className={styles.pg__grid} aria-hidden="true">
{Array.from({ length: TOTAL_ITEMS }, (_, i) => (
<i className={styles.pg__cell} key={i} ref={(el: HTMLElement | null) => { cellRefs.current[i] = el; }}></i>
))}
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}09 Load More with a Remaining-Count Ring — Measured area 11.583% · moving frames 23/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


