02中間階層をたたむメニュー
経路が長いと中間の階層が3点ボタン1つにたたまれ、押すと隠れた階層がリストで開き、Escapeで閉じます。
使いどころカテゴリが4階層以上深くなるECやドキュメントサイトで、1行に収めたいとき
aria-expandedtranslateYEscape

<main class="stage">
<div class="cm is-demo">
<nav aria-label="パンくずリスト">
<ol class="cm__list">
<li><a href="#">ホーム</a></li>
<li class="cm__slot" id="cm-slot">
<button class="cm__dots" id="cm-btn" type="button" aria-expanded="false" aria-controls="cm-menu" aria-label="隠れた階層を表示">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M240 96v64a16 16 0 0 1-16 16H32a16 16 0 0 1-16-16V96a16 16 0 0 1 16-16h192a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m56-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-136 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<ul class="cm__menu" id="cm-menu">
<li><a href="#"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>電子機器</a></li>
<li><a href="#"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>コンピューター</a></li>
</ul>
</li>
<li><a href="#">ノートPC</a></li>
<li><span class="cm__cur" aria-current="page">13インチ ゲーミング</span></li>
</ol>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 절대배치 목록은 부모 높이 계산에서 빠지지만 scrollHeight 엔 잡힌다 →
// 뿌리에 열린 목록 높이(8px 띄움 + 64px 목록)만큼 자리를 미리 확보한다 (BD9)
.cm {
padding-bottom: 72px;
user-select: none;
-webkit-user-select: none;
}
.cm__list {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
white-space: nowrap;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 600;
}
.cm__list > li { display: inline-flex; align-items: center; gap: $sp-2; }
.cm__list > li + li::before {
content: "\203A";
display: inline-flex;
justify-content: center;
width: 8px;
color: rgba($subject-cream, .45);
}
.cm__list a { color: rgba($subject-cream, .82); text-decoration: none; border-radius: $r-xs; }
.cm__list a:hover { color: $subject-cream; text-decoration: underline; text-underline-offset: 4px; }
.cm__list a:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.cm__cur { color: $subject-cream; font-weight: 800; }
.cm__slot { position: relative; }
.cm__dots {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 24px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: rgba($subject-cream, .14);
color: $subject-cream;
cursor: pointer;
transition: transform $duration $easing, background-color $duration $easing;
}
.cm__dots:hover { background: rgba($subject-cream, .26); }
.cm__dots:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.cm__slot.is-open .cm__dots { background: $color; }
.cm__menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 2;
min-width: 132px;
margin: 0;
padding: $sp-1;
list-style: none;
border-radius: $r-control;
background: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing, visibility 0s linear $duration;
}
.cm__slot.is-open .cm__menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity $duration $easing, transform $duration $easing, visibility 0s linear 0s;
}
.cm__menu li { display: block; }
.cm__menu a {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
color: $subject-ink;
text-decoration: none;
font-size: 12px;
line-height: 20px;
font-weight: 600;
white-space: nowrap;
}
.cm__menu a:hover { background-color: rgba($color, .18); color: $subject-ink; text-decoration: none; }
.cm__menu a:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.cm__menu svg { flex: none; color: rgba($subject-ink, .55); }
// ── 자동 루프: 눌림 → 목록이 translateY+opacity 로 내려옴 → 유지(hover 표시가 steps 로 옮겨감) → 접힘 ──
.cm.is-demo .cm__dots {
animation-name: cm-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cm-press {
0%, 4% { transform: scale(1); }
10% { transform: scale(.84); }
16%, 100% { transform: scale(1); }
}
// 나타남/사라짐은 steps(1, end) 로 한 프레임에 컷하고, 움직임은 translateY 가 맡는다.
// opacity 를 서서히 올리면 poster 가 반투명하게 뜬 흐린 패널이 된다 (blog-demo 실패표)
.cm.is-demo .cm__menu {
visibility: visible;
animation-name: cm-cut, cm-slide;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes cm-cut {
0% { opacity: 0; }
14% { opacity: 1; }
84%, 100% { opacity: 0; }
}
@keyframes cm-slide {
0%, 14% { transform: translateY(-10px); }
42%, 70% { transform: translateY(0); }
84%, 100% { transform: translateY(-10px); }
}
// hover 표시는 한 프레임에 컷으로 옮긴다 — 겹치면 poster 에 두 줄이 동시에 눌린 것처럼 보인다
.cm.is-demo .cm__menu li:nth-child(1) a,
.cm.is-demo .cm__menu li:nth-child(2) a {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cm.is-demo .cm__menu li:nth-child(1) a { animation-name: cm-hi1; }
.cm.is-demo .cm__menu li:nth-child(2) a { animation-name: cm-hi2; }
@keyframes cm-hi1 { 0% { background-color: rgba($color, 0); } 46% { background-color: rgba($color, .18); } 60%, 100% { background-color: rgba($color, 0); } }
@keyframes cm-hi2 { 0% { background-color: rgba($color, 0); } 60% { background-color: rgba($color, .18); } 74%, 100% { background-color: rgba($color, 0); } }
@media (max-width: 340px) {
.cm { padding-bottom: 68px; }
.cm__menu { min-width: 120px; }
.cm__dots { width: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dots,
.cm.is-demo .cm__menu,
.cm.is-demo .cm__menu a { animation: none !important; }
.cm.is-demo .cm__menu { opacity: 1; transform: none; }
.cm__dots, .cm__menu { transition: none; }
}@charset "UTF-8";
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);
}
.cm {
padding-bottom: 72px;
user-select: none;
-webkit-user-select: none;
}
.cm__list {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
white-space: nowrap;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 600;
}
.cm__list > li {
display: inline-flex;
align-items: center;
gap: 8px;
}
.cm__list > li + li::before {
content: "›";
display: inline-flex;
justify-content: center;
width: 8px;
color: rgba(255, 247, 230, 0.45);
}
.cm__list a {
color: rgba(255, 247, 230, 0.82);
text-decoration: none;
border-radius: 4px;
}
.cm__list a:hover {
color: #fff7e6;
text-decoration: underline;
text-underline-offset: 4px;
}
.cm__list a:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cm__cur {
color: #fff7e6;
font-weight: 800;
}
.cm__slot {
position: relative;
}
.cm__dots {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 24px;
padding: 0;
border: 0;
border-radius: 8px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cm__dots:hover {
background: rgba(255, 247, 230, 0.26);
}
.cm__dots:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cm__slot.is-open .cm__dots {
background: #2f6df6;
}
.cm__menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 2;
min-width: 132px;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s linear 220ms;
}
.cm__slot.is-open .cm__menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s linear 0s;
}
.cm__menu li {
display: block;
}
.cm__menu a {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
font-size: 12px;
line-height: 20px;
font-weight: 600;
white-space: nowrap;
}
.cm__menu a:hover {
background-color: rgba(47, 109, 246, 0.18);
color: #17141a;
text-decoration: none;
}
.cm__menu a:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.cm__menu svg {
flex: none;
color: rgba(23, 20, 26, 0.55);
}
.cm.is-demo .cm__dots {
animation-name: cm-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cm-press {
0%, 4% {
transform: scale(1);
}
10% {
transform: scale(0.84);
}
16%, 100% {
transform: scale(1);
}
}
.cm.is-demo .cm__menu {
visibility: visible;
animation-name: cm-cut, cm-slide;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes cm-cut {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes cm-slide {
0%, 14% {
transform: translateY(-10px);
}
42%, 70% {
transform: translateY(0);
}
84%, 100% {
transform: translateY(-10px);
}
}
.cm.is-demo .cm__menu li:nth-child(1) a,
.cm.is-demo .cm__menu li:nth-child(2) a {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cm.is-demo .cm__menu li:nth-child(1) a {
animation-name: cm-hi1;
}
.cm.is-demo .cm__menu li:nth-child(2) a {
animation-name: cm-hi2;
}
@keyframes cm-hi1 {
0% {
background-color: rgba(47, 109, 246, 0);
}
46% {
background-color: rgba(47, 109, 246, 0.18);
}
60%, 100% {
background-color: rgba(47, 109, 246, 0);
}
}
@keyframes cm-hi2 {
0% {
background-color: rgba(47, 109, 246, 0);
}
60% {
background-color: rgba(47, 109, 246, 0.18);
}
74%, 100% {
background-color: rgba(47, 109, 246, 0);
}
}
@media (max-width: 340px) {
.cm {
padding-bottom: 68px;
}
.cm__menu {
min-width: 120px;
}
.cm__dots {
width: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dots,
.cm.is-demo .cm__menu,
.cm.is-demo .cm__menu a {
animation: none !important;
}
.cm.is-demo .cm__menu {
opacity: 1;
transform: none;
}
.cm__dots, .cm__menu {
transition: none;
}
}// 실동작: 클릭 토글 · Escape 로 닫기 · ArrowDown/ArrowUp 으로 항목 이동 · 목록 밖 클릭으로 닫기
var root = document.querySelector('.cm');
var slot = document.getElementById('cm-slot');
var btn = document.getElementById('cm-btn');
var links = [].slice.call(document.querySelectorAll('#cm-menu a'));
function isOpen() { return btn.getAttribute('aria-expanded') === 'true'; }
function setOpen(on, restoreFocus) {
root.classList.remove('is-demo');
slot.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', on ? 'true' : 'false');
if (on) links[0].focus({ preventScroll: true });
else if (restoreFocus) btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen(), true); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); setOpen(false, true); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
var i = links.indexOf(document.activeElement);
if (i < 0 && document.activeElement !== btn) return;
e.preventDefault();
if (!isOpen()) { setOpen(true, false); return; }
var step = e.key === 'ArrowDown' ? 1 : links.length - 1;
links[(i + step + links.length) % links.length].focus({ preventScroll: true });
});
document.addEventListener('click', function (e) {
if (isOpen() && !slot.contains(e.target)) setOpen(false, false);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CollapseMiddleMenu.module.scss";
export type CollapseMiddleMenuProps = {
head?: string;
hidden?: string[];
tail?: string[];
duration?: number;
easing?: string;
color?: string;
};
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconDots = () => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M240 96v64a16 16 0 0 1-16 16H32a16 16 0 0 1-16-16V96a16 16 0 0 1 16-16h192a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m56-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-136 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
);
const IconFolder = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
);
export default function CollapseMiddleMenu({
head = "Home",
hidden = ["Electronics", "Computers"],
tail = ["Laptops", "13-inch gaming"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CollapseMiddleMenuProps) {
const [open, setOpen] = useState(false);
const slotRef = useRef<HTMLLIElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!open) return;
const onDoc = (e: Event) => {
const el = slotRef.current;
if (el && e.target && !el.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("click", onDoc);
return () => document.removeEventListener("click", onDoc);
}, [open]);
function step(dir: number) {
const el = slotRef.current;
if (!el) return;
const links = Array.from(el.querySelectorAll("a"));
const i = links.indexOf(document.activeElement as HTMLAnchorElement);
links[(i + dir + links.length) % links.length].focus({ preventScroll: true });
}
function onKey(e: KeyEvt) {
if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
btnRef.current?.focus({ preventScroll: true });
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
if (!open) setOpen(true);
else step(e.key === "ArrowDown" ? 1 : -1);
}
return (
<div className={styles.cm} style={vars} onKeyDown={onKey}>
<nav aria-label="Breadcrumb">
<ol className={styles.list}>
<li><a href="#">{head}</a></li>
<li ref={slotRef} className={[styles.slot, open ? styles.isOpen : ""].join(" ")}>
<button
ref={btnRef}
type="button"
className={styles.dots}
aria-expanded={open}
aria-controls="cm-menu"
aria-label="Show hidden steps"
onClick={(): void => setOpen(!open)}
>
<IconDots />
</button>
<ul className={styles.menu} id="cm-menu">
{hidden.map((label) => (
<li key={label}><a href="#"><IconFolder />{label}</a></li>
))}
</ul>
</li>
{tail.map((label, i) => (
<li key={label}>
{i === tail.length - 1
? <span className={styles.cur} aria-current="page">{label}</span>
: <a href="#">{label}</a>}
</li>
))}
</ol>
</nav>
</div>
);
}02 中間階層をたたむメニュー — 計測 累積面積 7.289% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パンくずリスト 9選 — 階層が深くても崩れない
このデモが属する記事
パンくずリスト 9選 — 階層が深くても崩れないパンくずリストは、今開いているページがサイトのどこにあるかをホームから順に経路で示すナビゲーションです。階層が深くても崩れない9種を、実際に動くコードで配ります。9つのうち1つは画面の経路とは別に検索エンジン用の経路をもう一組持ち、もう1つ この記事のデモをすべて見る

