01サブタスク展開する行
todo行を押すと進捗バッジがまず縮み、その下にサブタスク一覧が実際のコンテンツ高さぶん伸びて広がる(231の矢印回転展開とは異なる実演 — ここでは進捗バッジがトリガー)。
使いどころtodoアプリのサブタスク一覧、チェックリストグループ
ratio() 배지 축소 계산--h scrollHeight 실측 max-heightaria-expanded

<main class="stage">
<ul class="list is-demo" id="list">
<li class="row" id="row-0">
<button class="rowBtn" id="btn-0" type="button" aria-expanded="false" aria-controls="sub-0">
<span class="badge" id="badge-0">1/3</span>
<span class="title">週次レポート準備</span>
<svg class="caret" id="caret-0" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
<div class="subWrap" id="sub-0" role="group" aria-label="サブタスク">
<ul class="subList">
<li class="done">資料集め</li>
<li>グラフ整理</li>
<li>レビュー依頼</li>
</ul>
</div>
</li>
<li class="row" id="row-1">
<button class="rowBtn" id="btn-1" type="button" aria-expanded="false" aria-controls="sub-1">
<span class="badge" id="badge-1">2/2</span>
<span class="title">デザインシステム整理</span>
<svg class="caret" id="caret-1" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
<div class="subWrap" id="sub-1" role="group" aria-label="サブタスク">
<ul class="subList">
<li class="done">トークン表作成</li>
<li class="done">コンポーネント一覧</li>
</ul>
</div>
</li>
<li class="row" id="row-2">
<button class="rowBtn" id="btn-2" type="button" aria-expanded="false" aria-controls="sub-2">
<span class="badge" id="badge-2">0/4</span>
<span class="title">顧客対応メール返信</span>
<svg class="caret" id="caret-2" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
<div class="subWrap" id="sub-2" role="group" aria-label="サブタスク">
<ul class="subList">
<li>問い合わせ分類</li>
<li>返信案作成</li>
<li>担当者確認</li>
<li>送信</li>
</ul>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 의 ratio(i) 가 배지 "완료/전체" 문자열을 파싱해 축소 비율을 산술로 구하고,
// measure() 가 하위 작업 목록의 실제 scrollHeight 를 재서 --h 로 넘긴다 — 고정 px 아코디언이 아니다.
@include stage($stage-ink);
.list {
list-style: none;
margin: 0;
padding: 0;
width: 300px;
display: grid;
gap: $sp-2;
}
.row {
border-radius: $r-card;
background: rgba($subject-cream, .06);
overflow: hidden;
}
.rowBtn {
appearance: none;
border: none;
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
background: transparent;
cursor: pointer;
text-align: left;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: -2px; }
}
.badge {
flex: 0 0 auto;
min-width: 34px;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
text-align: center;
transform-origin: center;
transition: transform $dur-quick $ease-out;
}
.title {
flex: 1 1 auto;
color: $subject-cream;
font-size: 14px;
font-weight: 600;
}
.caret {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: rgba($subject-cream, .7);
transition: transform $dur-base $ease-out;
}
.subWrap {
overflow: hidden;
max-height: var(--h, 0px);
transition: max-height $dur-enter $ease-spring;
&.open { max-height: var(--h, 240px); }
}
.subList {
list-style: none;
margin: 0;
padding: 0 $sp-4 $sp-3;
display: grid;
gap: $sp-1;
}
.subList li {
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba($subject-cream, .05);
color: rgba($subject-cream, .85);
font-size: 12px;
&.done {
color: rgba($subject-cream, .45);
text-decoration: line-through;
}
}
@media (max-width: 340px) {
// 320×200 폰 렌더 예산 안에 여유를 두려고 세 겹으로 줄인다(경계값 실측 09-22: 정확히 200px 근방은
// 폰트 렌더링 미세 편차로 넘침 판정이 흔들린다 — 여유 없이 딱 맞추지 않는다)
.list { width: 240px; gap: $sp-1; }
.title { font-size: 13px; }
.rowBtn { padding: $sp-1 $sp-3; }
.subWrap.open { max-height: 40px; overflow-y: auto; }
}
// reduce: 실사용 경로(클릭 토글)에는 그대로 적용 — 전환만 없앤다. 자동 루프는 JS 쪽(reduce 체크)이 애초에 안 돈다
@media (prefers-reduced-motion: reduce) {
.badge, .caret, .subWrap { 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);
}
.list {
list-style: none;
margin: 0;
padding: 0;
width: 300px;
display: grid;
gap: 8px;
}
.row {
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
overflow: hidden;
}
.rowBtn {
appearance: none;
border: none;
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: transparent;
cursor: pointer;
text-align: left;
}
.rowBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: -2px;
}
.badge {
flex: 0 0 auto;
min-width: 34px;
padding: 4px 8px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
text-align: center;
transform-origin: center;
transition: transform 220ms ease-out;
}
.title {
flex: 1 1 auto;
color: #fff7e6;
font-size: 14px;
font-weight: 600;
}
.caret {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: rgba(255, 247, 230, 0.7);
transition: transform 300ms ease-out;
}
.subWrap {
overflow: hidden;
max-height: var(--h, 0px);
transition: max-height 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.subWrap.open {
max-height: var(--h, 240px);
}
.subList {
list-style: none;
margin: 0;
padding: 0 16px 12px;
display: grid;
gap: 4px;
}
.subList li {
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.05);
color: rgba(255, 247, 230, 0.85);
font-size: 12px;
}
.subList li.done {
color: rgba(255, 247, 230, 0.45);
text-decoration: line-through;
}
@media (max-width: 340px) {
.list {
width: 240px;
gap: 4px;
}
.title {
font-size: 13px;
}
.rowBtn {
padding: 4px 12px;
}
.subWrap.open {
max-height: 40px;
overflow-y: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.badge, .caret, .subWrap {
transition: none;
}
}(function () {
var BADGE_SHRINK_MAX = 0.22; // 배지 축소폭 상한 — 완료 비율이 낮을수록(할 일이 많을수록) 더 작게 움츠러든다
var STEP_MS = 700; // 자동 루프에서 한 행이 열려 있는 시간 — 2초 측정 창 안에 세 행이 순서대로 열린다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var list = document.getElementById('list');
var rows = [0, 1, 2].map(function (i) {
return {
btn: document.getElementById('btn-' + i),
badge: document.getElementById('badge-' + i),
caret: document.getElementById('caret-' + i),
sub: document.getElementById('sub-' + i),
open: false
};
});
function ratio(i) {
var text = rows[i].badge.textContent; // "1/3" 꼴 — done/total 을 파싱해 축소폭을 계산한다
var parts = text.split('/');
var done = parseInt(parts[0], 10);
var total = parseInt(parts[1], 10);
return total > 0 ? done / total : 0;
}
function measure(sub) {
// 실제 콘텐츠 높이를 재서 CSS 변수로 넘긴다 — 고정 px 가 아니라 サブタスク 개수만큼 자란다
sub.style.setProperty('--h', sub.firstElementChild.scrollHeight + 'px');
}
function setOpen(i, open) {
var r = rows[i];
if (r.open === open) return;
r.open = open;
r.btn.setAttribute('aria-expanded', String(open));
// 배지가 먼저 줄어든다 — 축소폭은 완료 비율로 계산(할 일이 남을수록 더 움츠러들어 눈에 띈다)
var shrink = 1 - (1 - ratio(i)) * BADGE_SHRINK_MAX;
r.badge.style.transform = open ? 'scale(' + shrink + ')' : 'scale(1)';
r.caret.style.transform = open ? 'rotate(180deg)' : 'rotate(0deg)';
if (open) measure(r.sub);
r.sub.classList.toggle('open', open);
}
function human() {
if (list.classList.contains('is-demo')) list.classList.remove('is-demo');
window.clearInterval(loopTimer);
}
rows.forEach(function (r, i) {
r.btn.addEventListener('click', function () {
human();
setOpen(i, !r.open);
});
r.btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setOpen(i, !r.open); }
});
});
var cursor = 0;
function autoStep() {
if (reduce) return;
rows.forEach(function (r, i) { if (i !== cursor) setOpen(i, false); });
setOpen(cursor, true);
cursor = (cursor + 1) % rows.length;
}
var loopTimer;
if (!reduce) {
autoStep();
loopTimer = window.setInterval(autoStep, STEP_MS);
}
})();// RowExpand — vanilla 판(run/377/demo/01-row-expand/index.html) 의 React 포트.
// 배지가 완료 비율만큼 먼저 움츠러들고, 하위 작업 목록은 실제 scrollHeight 를 측정해 그 높이까지 펼쳐진다.
import { useEffect, useRef, useState } from "react";
import styles from "./RowExpand.module.scss";
type Box<T> = { current: T };
const BADGE_SHRINK_MAX = 0.22; // 배지 축소폭 상한 — 완료 비율이 낮을수록 더 작게 움츠러든다
const STEP_MS = 700; // 자동 루프에서 한 행이 열려 있는 시간
type Task = { title: string; subs: { text: string; done: boolean }[] };
const TASKS: Task[] = [
{ title: "주간 보고서 준비", subs: [{ text: "자료 취합", done: true }, { text: "차트 정리", done: false }, { text: "검토 요청", done: false }] },
{ title: "디자인 시스템 정리", subs: [{ text: "토큰 표 작성", done: true }, { text: "컴포넌트 목록", done: true }] },
{ title: "고객 응대 메일 회신", subs: [{ text: "문의 분류", done: false }, { text: "답변 초안", done: false }, { text: "담당자 확인", done: false }, { text: "발송", done: false }] },
];
function ratio(task: Task) {
const done = task.subs.filter(function (s) { return s.done; }).length;
return task.subs.length > 0 ? done / task.subs.length : 0;
}
export default function RowExpand() {
const [isDemo, setIsDemo] = useState(true);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const subRefs = useRef([]) as unknown as Box<(HTMLDivElement | null)[]>;
const cursorRef = useRef(0) as unknown as Box<number>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(function () {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { setOpenIndex(null); return; }
function autoStep() {
setOpenIndex(cursorRef.current);
cursorRef.current = (cursorRef.current + 1) % TASKS.length;
}
autoStep();
timerRef.current = window.setInterval(autoStep, STEP_MS);
return function () { window.clearInterval(timerRef.current); };
}, []);
useEffect(function () {
if (openIndex === null) return;
const el = subRefs.current[openIndex];
if (el && el.firstElementChild) {
el.style.setProperty("--h", (el.firstElementChild as HTMLElement).scrollHeight + "px");
}
}, [openIndex]);
function human() {
if (isDemo) { setIsDemo(false); window.clearInterval(timerRef.current); }
}
function toggle(i: number) {
human();
setOpenIndex(function (cur) { return cur === i ? null : i; });
}
return (
<main className={styles.stage}>
<ul className={styles.list + (isDemo ? " " + styles.isDemo : "")}>
{TASKS.map(function (task, i) {
const open = openIndex === i;
const done = task.subs.filter(function (s) { return s.done; }).length;
const shrink = 1 - (1 - ratio(task)) * BADGE_SHRINK_MAX;
return (
<li className={styles.row} key={task.title}>
<button
className={styles.rowBtn}
type="button"
aria-expanded={open}
aria-controls={"sub-" + i}
onClick={function () { toggle(i); }}
>
<span className={styles.badge} style={{ transform: open ? "scale(" + shrink + ")" : "scale(1)" }}>
{done}/{task.subs.length}
</span>
<span className={styles.title}>{task.title}</span>
<svg className={styles.caret} style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)" }} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
</button>
<div
className={styles.subWrap + (open ? " " + styles.open : "")}
id={"sub-" + i}
role="group"
aria-label="하위 작업"
ref={function (el: HTMLDivElement | null) { subRefs.current[i] = el; }}
>
<ul className={styles.subList}>
{task.subs.map(function (s) {
return <li key={s.text} className={s.done ? styles.done : ""}>{s.text}</li>;
})}
</ul>
</div>
</li>
);
})}
</ul>
</main>
);
}01 サブタスク展開する行 — 計測 累積面積 43.151% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · todo リスト ui 9パターン — 進捗ゲージから巻き戻しまで一気に整理
このデモが属する記事
todo リスト ui 9パターン — 進捗ゲージから巻き戻しまで一気に整理このtodo リスト ui 9パターンのうち、1つはもっと見るを押すたびに読み込む件数が2倍に増え、1つは巻き戻しを押すと完了タイムラインが直前の順の逆順で消え、1つはチェックのたびに円形ゲージがその比率ぶんすぐ満ちます。 この記事のデモをすべて見る


