04Load-more button morph — a button recomputing its width from the remaining item count
Once a load-more request starts, the button's width value is recomputed to fit a waiting display, and once the next items arrive it returns to its original width or a completed display depending on the remaining-count value.
Best forA comments load-more control, the bottom of search results
morphToWIDTH_LERPrequestAnimationFrame

<main class="stage">
<div class="lbm is-demo" id="lbm">
<ul class="lbm__list" id="lbmList"></ul>
<button class="lbm__btn" id="lbmBtn" type="button">
<span class="lbm__label" id="lbmLabel">Load more (24)</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 더 보기 버튼 모핑 — morphTo() 가 라벨의 실측 폭 + 여백을 목표 너비로 삼고,
// 매 프레임 현재 너비를 목표 쪽으로 24% 씩 당기는 lerp(관성)로 버튼 폭을 바꾼다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.lbm {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
width: min(100%, 280px);
}
.lbm__list {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: $sp-2;
max-height: 156px;
overflow: hidden;
}
.lbm__row {
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .08);
color: $color;
font-weight: 600;
font-size: 12px;
}
.lbm__btn {
box-sizing: border-box;
height: 40px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-pill;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
}
.lbm__btn--loading {
opacity: .82;
cursor: progress;
}
.lbm__btn--done {
background: rgba(23, 20, 26, .28);
cursor: default;
}
.lbm__label {
display: inline-block;
}
@media (max-width: 340px) {
.lbm { gap: $sp-1; }
.lbm__list { max-height: 80px; }
.lbm__row { font-size: 11px; }
.lbm__btn { font-size: 11px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.lbm__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lbm {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: min(100%, 280px);
}
.lbm__list {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 156px;
overflow: hidden;
}
.lbm__row {
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 12px;
}
.lbm__btn {
box-sizing: border-box;
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
}
.lbm__btn--loading {
opacity: 0.82;
cursor: progress;
}
.lbm__btn--done {
background: rgba(23, 20, 26, 0.28);
cursor: default;
}
.lbm__label {
display: inline-block;
}
@media (max-width: 340px) {
.lbm {
gap: 4px;
}
.lbm__list {
max-height: 80px;
}
.lbm__row {
font-size: 11px;
}
.lbm__btn {
font-size: 11px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.lbm__btn {
transition: none !important;
}
}(function () {
var list = document.getElementById('lbmList');
var btn = document.getElementById('lbmBtn');
var label = document.getElementById('lbmLabel');
var root = document.getElementById('lbm');
var BATCH_SIZE = 6;
var TOTAL = 24;
var LOAD_DELAY_MS = 560;
var WIDTH_LERP = 0.24;
var PADDING_X = 40;
var remaining = TOTAL;
var shown = 0;
var loading = false;
var done = false;
var resetting = false;
var targetWidth = 0;
var widthRaf = null;
var autoplayTimer = null;
function addCard(n) {
var li = document.createElement('li');
li.className = 'lbm__row';
li.textContent = 'Comment ' + String(n).padStart(2, '0');
list.appendChild(li);
}
for (var i = 0; i < 4; i += 1) { shown += 1; addCard(shown); }
remaining = TOTAL - shown;
// 계산 겹: 라벨 문자열이 바뀔 때마다 실제 렌더 폭(getBoundingClientRect)을 재고 그 값 + 여백을
// 목표 너비로 삼아, 매 프레임 현재 너비를 목표 쪽으로 24% 씩 당기는 lerp 로 버튼을 모핑한다.
function morphTo(text) {
label.textContent = text;
var natural = label.getBoundingClientRect().width;
targetWidth = Math.round(natural + PADDING_X);
if (widthRaf) { return; }
var current = btn.getBoundingClientRect().width;
function tick() {
current += (targetWidth - current) * WIDTH_LERP;
btn.style.width = current + 'px';
if (Math.abs(targetWidth - current) > 0.5) {
widthRaf = requestAnimationFrame(tick);
} else {
btn.style.width = targetWidth + 'px';
widthRaf = null;
}
}
widthRaf = requestAnimationFrame(tick);
}
function labelFor() {
if (done) { return 'All caught up'; }
if (loading) { return 'Loading'; }
return 'Load more (' + remaining + ')';
}
function press() {
if (loading || done) { return; }
loading = true;
btn.classList.add('lbm__btn--loading');
morphTo(labelFor());
setTimeout(function () {
var n = Math.min(BATCH_SIZE, remaining);
for (var k = 0; k < n; k += 1) { shown += 1; addCard(shown); }
remaining -= n;
loading = false;
btn.classList.remove('lbm__btn--loading');
if (remaining <= 0) {
done = true;
btn.classList.add('lbm__btn--done');
btn.disabled = true;
}
morphTo(labelFor());
}, LOAD_DELAY_MS);
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
press();
});
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function autoplayTick() {
if (done && !resetting) {
resetting = true;
setTimeout(function () {
while (list.firstChild) { list.removeChild(list.firstChild); }
shown = 0; remaining = TOTAL; done = false; resetting = false;
btn.disabled = false;
btn.classList.remove('lbm__btn--done');
for (var i = 0; i < 4; i += 1) { shown += 1; addCard(shown); }
remaining = TOTAL - shown;
morphTo(labelFor());
}, 700);
return;
}
if (resetting || loading) { return; }
press();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
morphTo(labelFor());
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 780);
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./LoadMoreButtonMorph.module.scss";
// 더 보기 버튼 모핑 — morphTo() 가 라벨의 실측 폭 + 여백을 목표 너비로 삼고,
// 매 프레임 현재 너비를 목표 쪽으로 24% 씩 당기는 lerp(관성)로 버튼 폭을 바꾼다.
const BATCH_SIZE = 6;
const TOTAL = 24;
const LOAD_DELAY_MS = 560;
const WIDTH_LERP = 0.24;
const PADDING_X = 40;
export default function LoadMoreButtonMorph() {
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [rows, setRows] = useState(4);
const [label, setLabel] = useState("더 보기 (20)");
const [btnState, setBtnState] = useState<"idle" | "loading" | "done">("idle");
const rowsRef = useRef(4);
rowsRef.current = rows;
const stateRef = useRef<"idle" | "loading" | "done">("idle");
stateRef.current = btnState;
const widthRafRef = useRef<number | null>(null);
const targetWidthRef = useRef<number>(0);
const autoplayRef = useRef<number | null>(null);
// 계산 겹: 라벨 문자열이 바뀔 때마다 실제 렌더 폭(getBoundingClientRect)을 재고 그 값 + 여백을
// 목표 너비로 삼아, 매 프레임 현재 너비를 목표 쪽으로 24% 씩 당기는 lerp 로 버튼을 모핑한다.
const morphTo = useCallback((text: string) => {
const btn = btnRef.current;
const labelEl = labelRef.current;
if (!btn || !labelEl) { return; }
labelEl.textContent = text;
const natural = labelEl.getBoundingClientRect().width;
targetWidthRef.current = Math.round(natural + PADDING_X);
if (widthRafRef.current !== null) { return; }
let current = btn.getBoundingClientRect().width;
const tick = () => {
const target = targetWidthRef.current ?? 0;
current += (target - current) * WIDTH_LERP;
btn.style.width = current + "px";
if (Math.abs(target - current) > 0.5) {
widthRafRef.current = window.requestAnimationFrame(tick);
} else {
btn.style.width = target + "px";
widthRafRef.current = null;
}
};
widthRafRef.current = window.requestAnimationFrame(tick);
}, []);
const labelFor = useCallback(() => {
const shown = rowsRef.current ?? 0;
const remaining = TOTAL - shown;
const st = stateRef.current ?? "idle";
if (st === "done") { return "모두 확인했어요"; }
if (st === "loading") { return "불러오는 중"; }
return "더 보기 (" + remaining + ")";
}, []);
const press = useCallback(() => {
if (stateRef.current !== "idle") { return; }
setBtnState("loading");
setLabel(labelFor());
morphTo(labelFor());
window.setTimeout(() => {
setRows((prev) => Math.min(prev + BATCH_SIZE, TOTAL));
const after = Math.min((rowsRef.current ?? 0) + BATCH_SIZE, TOTAL);
if (after >= TOTAL) {
setBtnState("done");
} else {
setBtnState("idle");
}
const shownNow = stateRef.current === "done" ? TOTAL : after;
const st = shownNow >= TOTAL ? "done" : "idle";
setLabel(st === "done" ? "모두 확인했어요" : "더 보기 (" + (TOTAL - shownNow) + ")");
morphTo(st === "done" ? "모두 확인했어요" : "더 보기 (" + (TOTAL - shownNow) + ")");
}, LOAD_DELAY_MS);
}, [labelFor, morphTo]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRef.current = window.setInterval(() => {
press();
}, 780);
}
return () => {
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
if (widthRafRef.current !== null) { window.cancelAnimationFrame(widthRafRef.current); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rowEls = [];
for (let i = 0; i < rows; i += 1) {
rowEls.push(
<li key={i} className={styles["lbm__row"]}>댓글 {String(i + 1).padStart(2, "0")}</li>
);
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lbm} ${styles["is-demo"]}`} id="lbm">
<ul className={styles["lbm__list"]}>{rowEls}</ul>
<button
ref={btnRef}
className={`${styles["lbm__btn"]} ${btnState === "loading" ? styles["lbm__btn--loading"] : ""} ${btnState === "done" ? styles["lbm__btn--done"] : ""}`}
type="button"
disabled={btnState === "done"}
onClick={() => {
rootRef.current?.classList.remove(styles["is-demo"]);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
press();
}}
>
<span ref={labelRef} className={styles["lbm__label"]}>{label}</span>
</button>
</div>
</main>
);
}04 Load-more button morph — a button recomputing its width from the remaining item count — Measured area 2.625% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Infinite Scroll Loading: 9 Essential UI Patterns
The post this demo lives in
Infinite Scroll Loading: 9 Essential UI PatternsOne indicator recomputes opacity from the remaining scroll distance, one offsets scroll position by the height added abo See all demos in this post
If you like this effect - similar demos
Loaders06 Group chat cycle — a label redrawing width from remaining time as typers change9 Typing Indicator CSS Patterns (Chat UI)
Loaders02 Circular percent ring computed from its own circumferenceFile Upload Progress Bar Loader: 9 UI Demos
Loaders06 Footer loader that fetches the next page at the end of the listPage Loading Animation CSS: 9 Route-Change Demos