04더 보기 버튼 모핑 — 남은 항목 값에 따라 너비가 바뀌는 버튼
더 보기 요청이 시작된 상태가 되면 버튼의 너비 값이 대기 중 표시에 맞춰 다시 계산되고, 다음 항목이 도착한 상태가 되면 남은 개수 값에 따라 원래 너비나 완료 표시로 되돌아갑니다.
쓰임댓글 더 보기, 검색 결과 하단
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">더 보기 (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 = '댓글 ' + 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 '모두 확인했어요'; }
if (loading) { return '불러오는 중'; }
return '더 보기 (' + 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 더 보기 버튼 모핑 — 남은 항목 값에 따라 너비가 바뀌는 버튼 — 측정 누적면적 2.625% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지
이 데모가 속한 글
무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지무한 스크롤은 스크롤이 끝에 닿을 때마다 다음 목록을 자동으로 불러와 이어 붙이는 방식인데, 아홉 개 중 하나는 남은 스크롤 거리 값으로 로딩 표시의 투명도를 다시 계산하고, 하나는 이전에 늘어난 높이 값만큼 스크롤 이 글의 데모 전부 보기


