02日付グループの値で折りたたまれるヘッダーリスト
通知を日付グループの値でまとめてヘッダーの下に置き、ヘッダーを押すとそのグループの項目数に応じて高さの値が縮み、子リストが折りたたまれる。
使いどころ今日・昨日・それ以前でまとめた通知センター
NOTIF_GROUP_COLLAPSE_DELAY_MSgroupElstoggleGroup

<main class="stage">
<div class="gh is-demo" id="gh">
<section class="gh__group">
<button class="gh__header" id="ghHeaderToday" type="button" aria-expanded="true" aria-controls="ghListToday">
<svg class="gh__caret" id="ghCaretToday" viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<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"/>
</svg>
<span class="gh__headerLabel">今日 · <span id="ghCountToday">3</span></span>
</button>
<ul class="gh__list" id="ghListToday" role="list"></ul>
</section>
<section class="gh__group">
<button class="gh__header" id="ghHeaderYesterday" type="button" aria-expanded="true" aria-controls="ghListYesterday">
<svg class="gh__caret" id="ghCaretYesterday" viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<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"/>
</svg>
<span class="gh__headerLabel">昨日 · <span id="ghCountYesterday">2</span></span>
</button>
<ul class="gh__list" id="ghListYesterday" role="list"></ul>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html setGroup() 이 항목마다 실제 높이(getBoundingClientRect)를 재서
// dataset 에 저장해두고, 순번 x NOTIF_GROUP_COLLAPSE_DELAY_MS 를 transitionDelay 로 걸어
// 위→아래(펼 때는 아래→위) 순서로 접히는 계단식 흐름을 만든다.
.stage { @include stage($stage-yellow); }
.gh {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: $sp-4;
}
.gh__group { display: flex; flex-direction: column; gap: $sp-2; }
.gh__header {
display: flex;
align-items: center;
gap: $sp-2;
border: none;
background: transparent;
padding: $sp-1 0;
cursor: pointer;
color: $subject-ink;
}
.gh__caret {
width: 16px;
height: 16px;
color: $subject-ink;
transition: transform $dur-base $ease-spring;
transform-origin: center;
}
.gh__header[aria-expanded="false"] .gh__caret { transform: rotate(-90deg); }
.gh__headerLabel { font-size: 13px; font-weight: 700; }
.gh__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.gh__item {
overflow: hidden;
border-radius: $r-sm;
background: $subject-ink;
color: $subject-cream;
font-size: 12px;
padding: 0 $sp-3;
display: flex;
align-items: center;
box-sizing: border-box;
transition: height $dur-base $ease-spring, opacity $dur-base $ease-spring;
}
@media (max-width: 340px) {
.gh { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.gh__item { transition: none; }
.gh__caret { transition: none; }
}.stage html, .stage body {
margin: 0;
overflow: hidden;
}
.stage .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);
}
.gh {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: 16px;
}
.gh__group {
display: flex;
flex-direction: column;
gap: 8px;
}
.gh__header {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 4px 0;
cursor: pointer;
color: #17141a;
}
.gh__caret {
width: 16px;
height: 16px;
color: #17141a;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: center;
}
.gh__header[aria-expanded=false] .gh__caret {
transform: rotate(-90deg);
}
.gh__headerLabel {
font-size: 13px;
font-weight: 700;
}
.gh__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.gh__item {
overflow: hidden;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-size: 12px;
padding: 0 12px;
display: flex;
align-items: center;
box-sizing: border-box;
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (max-width: 340px) {
.gh {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.gh__item {
transition: none;
}
.gh__caret {
transition: none;
}
}(function () {
"use strict";
var NOTIF_GROUP_COLLAPSE_DELAY_MS = 80; // 항목마다 접히는 시작 시각이 이 값 x 순번만큼 밀린다
var GROUPS = {
today: ["決済が承認されました", "新しいコメントが付きました", "会議まであと10分です"],
yesterday: ["アップロードが完了しました", "招待状が届きました"]
};
var root = document.getElementById("gh");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var synthetic = false;
function human() {
if (synthetic) return;
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
function buildList(key) {
var ul = document.getElementById("ghList" + (key === "today" ? "Today" : "Yesterday"));
GROUPS[key].forEach(function (name) {
var li = document.createElement("li");
li.className = "gh__item";
li.setAttribute("role", "listitem");
li.textContent = name;
ul.appendChild(li);
});
}
buildList("today");
buildList("yesterday");
var state = { today: true, yesterday: true };
function groupEls(key) {
var suffix = key === "today" ? "Today" : "Yesterday";
return {
header: document.getElementById("ghHeader" + suffix),
caret: document.getElementById("ghCaret" + suffix),
list: document.getElementById("ghList" + suffix)
};
}
// 계산 겹: 항목마다 실제 높이를 getBoundingClientRect 로 재고, 순번(index) x
// NOTIF_GROUP_COLLAPSE_DELAY_MS 를 transitionDelay 로 걸어 위에서 아래로(펼 때는 아래에서
// 위로) 순서대로 접히고 펼쳐지는 계단식 흐름을 만든다.
function setGroup(key, expand) {
state[key] = expand;
var els = groupEls(key);
els.header.setAttribute("aria-expanded", expand ? "true" : "false");
var items = Array.prototype.slice.call(els.list.children);
var n = items.length;
items.forEach(function (li, i) {
var order = expand ? (n - 1 - i) : i;
var delay = reduce ? 0 : order * NOTIF_GROUP_COLLAPSE_DELAY_MS;
var h = li.dataset.h ? parseFloat(li.dataset.h) : null;
if (h === null) {
li.style.height = "auto";
h = li.getBoundingClientRect().height;
li.dataset.h = String(h);
}
li.style.transitionDelay = delay + "ms";
if (expand) {
li.style.height = "0px";
li.style.opacity = "0";
requestAnimationFrame(function () {
li.style.height = h + "px";
li.style.opacity = "1";
});
} else {
li.style.height = h + "px";
li.style.opacity = "1";
requestAnimationFrame(function () {
li.style.height = "0px";
li.style.opacity = "0";
});
}
});
}
function toggleGroup(key) {
human();
setGroup(key, !state[key]);
}
groupEls("today").header.addEventListener("click", function () { toggleGroup("today"); });
groupEls("yesterday").header.addEventListener("click", function () { toggleGroup("yesterday"); });
[groupEls("today").header, groupEls("yesterday").header].forEach(function (btn, i) {
btn.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleGroup(i === 0 ? "today" : "yesterday");
}
});
});
// 화면 폭이 바뀌면 줄바꿈 여부가 달라져 캐시해둔 실측 높이가 더는 맞지 않는다 — 지운다.
window.addEventListener("resize", function () {
Array.prototype.forEach.call(root.querySelectorAll(".gh__item"), function (li) {
delete li.dataset.h;
});
});
if (reduce) {
setGroup("today", false);
} else {
var step = 0;
function loop() {
if (!root.classList.contains("is-demo")) return;
synthetic = true;
if (step === 0) setGroup("today", false);
else if (step === 1) setGroup("today", true);
else if (step === 2) setGroup("yesterday", false);
else setGroup("yesterday", true);
synthetic = false;
step = (step + 1) % 4;
window.setTimeout(loop, 1500);
}
window.setTimeout(loop, 500);
}
})();// NotifGroupHeaderCollapse — vanilla 판(run/405/demo/02-.../index.html) 의 React 포트.
// 계산 겹: 항목마다 실제 높이를 getBoundingClientRect 로 재고, 순번(index) x
// NOTIF_GROUP_COLLAPSE_DELAY_MS 를 transitionDelay 로 걸어 위에서 아래로(펼 때는 아래에서
// 위로) 순서대로 접히고 펼쳐지는 계단식 흐름을 만든다 — vanilla 와 동일 동작.
import { useEffect, useRef, useState } from "react";
import styles from "./NotifGroupHeaderCollapse.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type GroupKey = "today" | "yesterday";
const NOTIF_GROUP_COLLAPSE_DELAY_MS = 80; // 항목마다 접히는 시작 시각이 이 값 x 순번만큼 밀린다
const GROUPS: { [k in GroupKey]: string[] } = {
today: ["결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요"],
yesterday: ["업로드가 끝났어요", "초대장이 도착했어요"],
};
export default function NotifGroupHeaderCollapse() {
const [isDemo, setIsDemo] = useState(true);
const [expanded, setExpanded] = useState<{ [k in GroupKey]: boolean }>({ today: true, yesterday: true });
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const headerRef = {
today: useRef(null) as unknown as Box<HTMLButtonElement>,
yesterday: useRef(null) as unknown as Box<HTMLButtonElement>,
};
const listRef = {
today: useRef(null) as unknown as Box<HTMLUListElement>,
yesterday: useRef(null) as unknown as Box<HTMLUListElement>,
};
const stateRef = useRef({ today: true, yesterday: true }) as unknown as Box<{ [k in GroupKey]: boolean }>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
const stepRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function buildList(key: GroupKey) {
const ul = listRef[key].current;
if (!ul) return;
ul.innerHTML = "";
GROUPS[key].forEach((name) => {
const li = document.createElement("li");
li.className = styles.item;
li.setAttribute("role", "listitem");
li.textContent = name;
ul.appendChild(li);
});
}
function setGroup(key: GroupKey, expand: boolean, reduce: boolean) {
stateRef.current[key] = expand;
setExpanded((prev) => ({ ...prev, [key]: expand }));
const list = listRef[key].current;
if (!list) return;
const items = Array.prototype.slice.call(list.children) as HTMLLIElement[];
const n = items.length;
items.forEach((li, i) => {
const order = expand ? n - 1 - i : i;
const delay = reduce ? 0 : order * NOTIF_GROUP_COLLAPSE_DELAY_MS;
let h = li.dataset.h ? parseFloat(li.dataset.h) : null;
if (h === null) {
li.style.height = "auto";
h = li.getBoundingClientRect().height;
li.dataset.h = String(h);
}
li.style.transitionDelay = delay + "ms";
if (expand) {
li.style.height = "0px";
li.style.opacity = "0";
requestAnimationFrame(() => { li.style.height = h + "px"; li.style.opacity = "1"; });
} else {
li.style.height = h + "px";
li.style.opacity = "1";
requestAnimationFrame(() => { li.style.height = "0px"; li.style.opacity = "0"; });
}
});
}
function toggleGroup(key: GroupKey) {
human();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setGroup(key, !stateRef.current[key], reduce);
}
function onKeyDown(key: GroupKey, e: KeyEvt) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleGroup(key);
}
}
useEffect(() => {
buildList("today");
buildList("yesterday");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function onResize() {
const root = rootRef.current;
if (!root) return;
Array.prototype.forEach.call(root.querySelectorAll("li"), (li: HTMLLIElement) => { delete li.dataset.h; });
}
window.addEventListener("resize", onResize);
if (reduce) {
setGroup("today", false, true);
} else {
function loop() {
if (!isDemoRef.current) return;
synthRef.current = true;
const step = stepRef.current;
if (step === 0) setGroup("today", false, false);
else if (step === 1) setGroup("today", true, false);
else if (step === 2) setGroup("yesterday", false, false);
else setGroup("yesterday", true, false);
synthRef.current = false;
stepRef.current = (step + 1) % 4;
loopTimerRef.current = window.setTimeout(loop, 1500);
}
loopTimerRef.current = window.setTimeout(loop, 500);
}
return () => {
window.removeEventListener("resize", onResize);
window.clearTimeout(loopTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.gh + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<section className={styles.group}>
<button className={styles.header} type="button" aria-expanded={expanded.today ? "true" : "false"}
aria-controls="ghListToday" ref={headerRef.today}
onClick={() => toggleGroup("today")} onKeyDown={(e: KeyEvt) => onKeyDown("today", e)}>
<svg className={styles.caret} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<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" />
</svg>
<span className={styles.headerLabel}>오늘 · {GROUPS.today.length}</span>
</button>
<ul className={styles.list} id="ghListToday" role="list" ref={listRef.today} />
</section>
<section className={styles.group}>
<button className={styles.header} type="button" aria-expanded={expanded.yesterday ? "true" : "false"}
aria-controls="ghListYesterday" ref={headerRef.yesterday}
onClick={() => toggleGroup("yesterday")} onKeyDown={(e: KeyEvt) => onKeyDown("yesterday", e)}>
<svg className={styles.caret} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<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" />
</svg>
<span className={styles.headerLabel}>어제 · {GROUPS.yesterday.length}</span>
</button>
<ul className={styles.list} id="ghListYesterday" role="list" ref={listRef.yesterday} />
</section>
</div>
</main>
);
}02 日付グループの値で折りたたまれるヘッダーリスト — 計測 累積面積 29.136% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 通知 9選 — バッジ・パネルアニメーション
このデモが属する記事
css 通知 9選 — バッジ・パネルアニメーションcss 通知で探す人に、バッジの値一つで答えるパネルを九種集めました。「未読はあといくつ?」と聞かれたら、このパネルは未読件数の値ひとつで答える。3番はその値が0になった瞬間にバッジを輪の形に戻し、6番は同じ値が空になるとリスト全体を空状態 この記事のデモをすべて見る


