04もっと見るボタンリスト
もっと見るを押すと一瞬スピナーが回り、新しい3行が順番に少しずつ下からせり上がって現れる。
使いどころブログ一覧のもっと見る、商品一覧の追加読み込み
stagger translateY+opacity버튼 로딩 상태 토글신뢰감 있는 밝은 톤

<main class="stage">
<div class="lm-list is-demo">
<ul class="lm-rows">
<li class="lm-row">お知らせ更新のご案内</li>
<li class="lm-row">9月定期メンテナンス予定</li>
<li class="lm-row lm-row--pending" data-n="1">新機能リリースノート</li>
<li class="lm-row lm-row--pending" data-n="2">イベント当選者発表</li>
<li class="lm-row lm-row--pending" data-n="3">利用規約改定</li>
</ul>
<button class="lm-btn" type="button">
<svg class="lm-btn__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg>
<span class="lm-btn__label">もっと見る</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 새 행이 밀려 올라오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 신뢰감 있는 브랜드 컬러(밝은 SaaS 톤)
@include stage($stage-paper);
.lm-list {
width: min(260px, 82vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.lm-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.lm-row {
padding: $sp-3;
border-radius: $r-sm;
background: #eef4ff; // $subject-blue 를 흰 배경에 6% 섞은 불투명 톤
font-size: 13px;
color: $stage-ink;
}
.lm-row--pending {
max-height: 0;
opacity: 0;
transform: translateY(10px);
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
margin: 0;
transition: max-height $duration $easing, opacity $duration $easing, transform $duration $easing, padding $duration $easing;
}
.lm-list.has-loaded .lm-row--pending { max-height: 40px; opacity: 1; transform: translateY(0); padding-top: $sp-3; padding-bottom: $sp-3; }
.lm-list.has-loaded .lm-row--pending[data-n="2"] { transition-delay: 90ms; }
.lm-list.has-loaded .lm-row--pending[data-n="3"] { transition-delay: 180ms; }
.lm-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 1px solid $color;
background: $stage-paper;
color: $color;
font-weight: 700;
font-size: 13px;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
cursor: pointer;
font-family: inherit;
}
.lm-btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lm-btn:disabled { opacity: .5; cursor: default; }
.lm-btn__icon { width: 14px; height: 14px; opacity: 0; width: 0; transition: opacity $dur-quick, width $dur-quick; }
.lm-list.is-loading .lm-btn__icon { opacity: 1; width: 14px; animation: lm-spin .6s linear infinite; }
@keyframes lm-spin { to { transform: rotate(360deg); } }
// 데모 강제 루프 — 스피너 회전 → 대기 행 순차 등장 → 리셋 을 한 keyframes 로
.lm-list.is-demo .lm-btn__icon {
opacity: 1; width: 14px;
animation: lm-loop-spin $dur-loop linear infinite;
}
.lm-list.is-demo .lm-row--pending {
animation: lm-loop-row $dur-loop $easing infinite;
}
.lm-list.is-demo .lm-row--pending[data-n="2"] { animation-delay: 60ms; }
.lm-list.is-demo .lm-row--pending[data-n="3"] { animation-delay: 120ms; }
@keyframes lm-loop-spin {
0%, 15% { transform: rotate(0deg); opacity: 1; }
30% { transform: rotate(360deg); opacity: 1; }
35%, 100% { opacity: 0; }
}
@keyframes lm-loop-row {
0%, 30% { max-height: 0; opacity: 0; transform: translateY(10px); padding-top: 0; padding-bottom: 0; }
55%, 85% { max-height: 40px; opacity: 1; transform: translateY(0); padding-top: $sp-3; padding-bottom: $sp-3; }
100% { max-height: 0; opacity: 0; transform: translateY(10px); padding-top: 0; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lm-row--pending, .lm-btn__icon { transition: none; animation: none; }
.lm-list.has-loaded .lm-row--pending { opacity: 1; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lm-list {
width: min(260px, 82vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.lm-rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lm-row {
padding: 12px;
border-radius: 8px;
background: #eef4ff;
font-size: 13px;
color: #17141a;
}
.lm-row--pending {
max-height: 0;
opacity: 0;
transform: translateY(10px);
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
margin: 0;
transition: max-height 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lm-list.has-loaded .lm-row--pending {
max-height: 40px;
opacity: 1;
transform: translateY(0);
padding-top: 12px;
padding-bottom: 12px;
}
.lm-list.has-loaded .lm-row--pending[data-n="2"] {
transition-delay: 90ms;
}
.lm-list.has-loaded .lm-row--pending[data-n="3"] {
transition-delay: 180ms;
}
.lm-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #2f6df6;
background: #ffffff;
color: #2f6df6;
font-weight: 700;
font-size: 13px;
border-radius: 999px;
padding: 8px 20px;
cursor: pointer;
font-family: inherit;
}
.lm-btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lm-btn:disabled {
opacity: 0.5;
cursor: default;
}
.lm-btn__icon {
width: 14px;
height: 14px;
opacity: 0;
width: 0;
transition: opacity 220ms, width 220ms;
}
.lm-list.is-loading .lm-btn__icon {
opacity: 1;
width: 14px;
animation: lm-spin 0.6s linear infinite;
}
@keyframes lm-spin {
to {
transform: rotate(360deg);
}
}
.lm-list.is-demo .lm-btn__icon {
opacity: 1;
width: 14px;
animation: lm-loop-spin 2s linear infinite;
}
.lm-list.is-demo .lm-row--pending {
animation: lm-loop-row 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.lm-list.is-demo .lm-row--pending[data-n="2"] {
animation-delay: 60ms;
}
.lm-list.is-demo .lm-row--pending[data-n="3"] {
animation-delay: 120ms;
}
@keyframes lm-loop-spin {
0%, 15% {
transform: rotate(0deg);
opacity: 1;
}
30% {
transform: rotate(360deg);
opacity: 1;
}
35%, 100% {
opacity: 0;
}
}
@keyframes lm-loop-row {
0%, 30% {
max-height: 0;
opacity: 0;
transform: translateY(10px);
padding-top: 0;
padding-bottom: 0;
}
55%, 85% {
max-height: 40px;
opacity: 1;
transform: translateY(0);
padding-top: 12px;
padding-bottom: 12px;
}
100% {
max-height: 0;
opacity: 0;
transform: translateY(10px);
padding-top: 0;
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lm-row--pending, .lm-btn__icon {
transition: none;
animation: none;
}
.lm-list.has-loaded .lm-row--pending {
opacity: 1;
transform: none;
}
}(function () {
var list = document.querySelector('.lm-list');
var btn = list.querySelector('.lm-btn');
btn.addEventListener('click', function () {
if (list.classList.contains('has-loaded')) return;
list.classList.remove('is-demo');
list.classList.add('is-loading');
setTimeout(function () {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
btn.querySelector('.lm-btn__label').textContent = 'これ以上ありません';
btn.disabled = true;
}, 500);
});
})();import { useState, type CSSProperties } from "react";
import styles from "./LoadMoreButton.module.scss";
export type LoadMoreButtonProps = {
initial: string[];
more: string[];
/** 새 행이 밀려 올라오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 브랜드 컬러 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 더보기 클릭 → 로딩 스피너 → 대기 행이 stagger 로 펼쳐진다 */
export default function LoadMoreButton({
initial,
more,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LoadMoreButtonProps) {
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const click = () => {
if (loaded) return;
setLoading(true);
setTimeout(() => { setLoading(false); setLoaded(true); }, 500);
};
return (
<div className={styles.list} style={vars}>
<ul className={styles.rows}>
{initial.map((t) => <li key={t} className={styles.row}>{t}</li>)}
{more.map((t, i) => (
<li key={t} className={`${styles.row} ${styles.pending} ${loaded ? styles.shown : ""}`} style={{ transitionDelay: `${i * 90}ms` }}>{t}</li>
))}
</ul>
<button className={styles.btn} type="button" onClick={click} disabled={loaded}>
<svg className={`${styles.icon} ${loading ? styles.spin : ""}`} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" /><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" /></g>
</svg>
<span>{loaded ? "더 이상 없음" : "더보기"}</span>
</button>
</div>
);
}04 もっと見るボタンリスト — 計測 累積面積 43.192% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · アコーディオンリスト UI 9選 — チェックリストまで
このデモが属する記事
アコーディオンリスト UI 9選 — チェックリストまでアコーディオンリストとは、行をクリックするとその場で高さがなめらかに伸びて詳細内容が開くUIで、別ページに移動せずリスト画面の中で情報を増やしたいときに使います。タップすると行がふわっと開くあの感じを、実際に動くコードとして九つ用意しました この記事のデモをすべて見る


