08Nested menu
Inside the slide-in sidebar, a submenu unfolds from 0fr to 1fr via grid-template-rows.
Best forShopping-site sidebar navigation with multi-level categories
grid-template-rows0fr→1fr쉐브론 회전

<main class="stage is-demo">
<div class="page" id="page8">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer8">Open menu</button>
</div>
<div class="overlay"></div>
<nav class="drawer" id="drawer8" role="dialog" aria-modal="true" aria-labelledby="t8">
<h2 id="t8">Categories</h2>
<a class="drawer__link" href="#">All</a>
<div class="group">
<button class="group__toggle" type="button" aria-expanded="false" aria-controls="sub8">
Women <span class="chevron" aria-hidden="true"></span>
</button>
<div class="submenu" id="sub8">
<div class="submenu__inner">
<a href="#">Tops</a>
<a href="#">Bottoms</a>
<a href="#">Shoes</a>
</div>
</div>
</div>
<a class="drawer__link" href="#">Brands</a>
<button class="drawer__close" type="button">Close</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 600ms — 드로어가 밀려 들어오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 하위 메뉴 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .5);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-6 $sp-4;
background: $stage-ink; color: $stage-paper;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
}
.drawer__link, .group__toggle {
padding: $sp-2 $sp-1;
border-radius: $r-sm;
color: $stage-paper; text-decoration: none; font-weight: 500; font-size: 13px; line-height: 1; font-family: inherit;
&:hover, &:focus-visible { background: rgba($stage-paper, .1); }
}
.group__toggle {
border: 0; background: transparent;
display: flex; align-items: center; justify-content: space-between;
width: 100%; cursor: pointer;
font-family: inherit;
}
// 체크와 같은 원리: 한 요소의 border 코너를 45° 돌려 화살표를 만든다(BD16)
.chevron {
width: $sp-2; height: $sp-2;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform $dur-base $ease-out;
}
.group.is-open .chevron { transform: rotate(225deg); }
// height:auto 트랜지션 — grid-template-rows 0fr → 1fr (017-01 과 같은 방식)
.submenu {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $dur-base $ease-out;
}
.submenu__inner {
overflow: hidden;
display: flex; flex-direction: column; gap: $sp-1;
padding-left: $sp-4;
a { color: $stage-paper; opacity: .8; text-decoration: none; font-weight: 400; font-size: 12px; line-height: 1.8; font-family: inherit; }
a:hover, a:focus-visible { opacity: 1; color: $color; }
}
.group.is-open .submenu { grid-template-rows: 1fr; }
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $color; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
// 데모 루프: 드로어가 들어온 뒤 하위 메뉴가 펼쳐졌다 접히고, 드로어가 다시 나간다
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
.stage.is-demo .submenu { animation: submenu-loop $dur-loop $ease-out infinite; }
.stage.is-demo .chevron { animation: chevron-loop $dur-loop $ease-out infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
12%, 90% { opacity: 1; }
96% { opacity: 0; }
}
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
12%, 90% { transform: translateX(0); }
96% { transform: translateX(-100%); }
}
@keyframes submenu-loop {
0%, 25%, 100% { grid-template-rows: 0fr; }
45%, 75% { grid-template-rows: 1fr; }
}
@keyframes chevron-loop {
0%, 25%, 100% { transform: rotate(45deg); }
45%, 75% { transform: rotate(225deg); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer, .stage.is-demo .submenu, .stage.is-demo .chevron { animation: none; }
.overlay, .drawer, .submenu, .chevron { transition-duration: 1ms; }
}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);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #2f6df6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 24px 16px;
background: #17141a;
color: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer__link, .group__toggle {
padding: 8px 4px;
border-radius: 8px;
color: #ffffff;
text-decoration: none;
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.drawer__link:hover, .drawer__link:focus-visible, .group__toggle:hover, .group__toggle:focus-visible {
background: rgba(255, 255, 255, 0.1);
}
.group__toggle {
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
font-family: inherit;
}
.chevron {
width: 8px;
height: 8px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform 300ms ease-out;
}
.group.is-open .chevron {
transform: rotate(225deg);
}
.submenu {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease-out;
}
.submenu__inner {
overflow: hidden;
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 16px;
}
.submenu__inner a {
color: #ffffff;
opacity: 0.8;
text-decoration: none;
font-weight: 400;
font-size: 12px;
line-height: 1.8;
font-family: inherit;
}
.submenu__inner a:hover, .submenu__inner a:focus-visible {
opacity: 1;
color: #2f6df6;
}
.group.is-open .submenu {
grid-template-rows: 1fr;
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .submenu {
animation: submenu-loop 2s ease-out infinite;
}
.stage.is-demo .chevron {
animation: chevron-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
12%, 90% {
opacity: 1;
}
96% {
opacity: 0;
}
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
12%, 90% {
transform: translateX(0);
}
96% {
transform: translateX(-100%);
}
}
@keyframes submenu-loop {
0%, 25%, 100% {
grid-template-rows: 0fr;
}
45%, 75% {
grid-template-rows: 1fr;
}
}
@keyframes chevron-loop {
0%, 25%, 100% {
transform: rotate(45deg);
}
45%, 75% {
transform: rotate(225deg);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer, .stage.is-demo .submenu, .stage.is-demo .chevron {
animation: none;
}
.overlay, .drawer, .submenu, .chevron {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page8');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var drawer = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
var group = document.querySelector('.group');
var groupToggle = document.querySelector('.group__toggle');
function focusables() { return drawer.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
groupToggle.addEventListener('click', function () {
var open = group.classList.toggle('is-open');
groupToggle.setAttribute('aria-expanded', String(open));
});
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NestedMenuDrawer.module.scss";
export type NestedMenuDrawerProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function NestedMenuDrawer({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: NestedMenuDrawerProps) {
const [open, setOpen] = useState(false);
const [groupOpen, setGroupOpen] = useState(false);
const drawerRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) { triggerRef.current?.focus(); return; }
const drawer = drawerRef.current;
if (!drawer) return;
const focusables = () => Array.from(drawer.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
메뉴 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<nav ref={drawerRef} className={styles.drawer} data-open={open} role="dialog" aria-modal="true" aria-label="카테고리">
<h2>카테고리</h2>
<a className={styles.link} href="#">전체</a>
<div className={styles.group}>
<button
className={styles.groupToggle}
type="button"
aria-expanded={groupOpen}
onClick={() => setGroupOpen((v) => !v)}
>
여성 <span className={styles.chevron} data-open={groupOpen} aria-hidden="true" />
</button>
<div className={styles.submenu} data-open={groupOpen}>
<div className={styles.submenuInner}>
<a href="#">상의</a>
<a href="#">하의</a>
<a href="#">신발</a>
</div>
</div>
</div>
<a className={styles.link} href="#">브랜드</a>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</nav>
</div>
);
}08 Nested menu — Measured area 100.0% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Sidebar Animations — Minimal JS
The post this demo lives in
9 CSS Sidebar Animations — Minimal JSA css sidebar animation is a panel pinned to a screen edge that slides, fades, snaps, or scales open on a trigger, and t See all demos in this post
If you like this effect - similar demos
Transitions01 height:auto transition9 CSS Dropdown Menu Animation—No Library, Pure CSS
Transitions08 Nested depth slide — the whole menu shifts sideways by a distance tied to the entered depth valueContext Menu UI: 9 Right-Click Menu Patterns
Transitions03 Side drawer9 CSS Modal Animation Patterns, No Library Needed