02Multi-level dropdown whose submenus slide right
Touch an arrowed item in the first list and the second level slides in from the right, and the hierarchy reads as deep as the levels you opened.
Best forDocs and help centers whose categories stack two or three levels
translateXaria-haspopup:focus-within

<main class="stage">
<div class="mls is-demo" id="root">
<button class="mls__trigger" id="trigger" type="button" aria-controls="l1">Library
<svg class="mls__caret" viewBox="0 0 256 256" width="16" height="16" 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>
<nav class="mls__l1" id="l1" aria-label="Library menu">
<ul class="mls__u">
<li class="mls__i"><a class="mls__a" href="#">Get started</a></li>
<li class="mls__i mls__i--parent mls__i--feat">
<button class="mls__b" type="button" aria-controls="l2">Features
<svg class="mls__sub-caret" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
</button>
<ul class="mls__l2" id="l2">
<li class="mls__i"><a class="mls__a" href="#">Make a list</a></li>
<li class="mls__i mls__i--parent mls__i--share">
<button class="mls__b" type="button" aria-controls="l3">Share
<svg class="mls__sub-caret" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
</button>
<ul class="mls__l3" id="l3">
<li class="mls__i"><a class="mls__a" href="#">Copy link</a></li>
<li class="mls__i"><a class="mls__a" href="#">Embed code</a></li>
</ul>
</li>
<li class="mls__i"><a class="mls__a" href="#">Import</a></li>
</ul>
</li>
<li class="mls__i"><a class="mls__a" href="#">Pricing</a></li>
<li class="mls__i"><a class="mls__a" href="#">Contact</a></li>
</ul>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
// 플라이아웃은 왼쪽에서 폭을 쓴다 — 무대 중앙 정렬 대신 상단 왼쪽 고정
.stage { align-items: flex-start; justify-items: start; }
$ink-70: rgba(23, 20, 26, .72);
.mls {
position: relative;
}
.mls__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-2;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
background: $subject-cream;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 18px;
color: $subject-ink;
cursor: pointer;
}
.mls__trigger:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// 실제 조작에서도 열려 있는 동안 트리거가 눌려 있음을 배경으로 보여 준다
.mls:not(.is-demo) .mls__trigger[aria-expanded="true"] { background-color: rgba(255, 210, 63, .45); }
.mls__caret { display: block; transition: transform $dur-quick $ease-spring; }
.mls__trigger[aria-expanded="true"] .mls__caret { transform: rotate(180deg); }
.mls__l1,
.mls__l2,
.mls__l3 {
position: absolute;
z-index: 2;
box-sizing: border-box;
margin: 0;
padding: $sp-1 0;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.mls__u {
margin: 0;
padding: 0;
list-style: none;
}
.mls__l1 {
top: 42px;
left: 0;
width: min(160px, 50vw);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.mls:not(.is-demo).is-open .mls__l1 {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s;
}
// 하위 단계는 각 항목(li) 기준 오른쪽 옆 — hover/:focus-within CSS 로 열린다
.mls__l2,
.mls__l3 {
top: 0;
left: 100%;
opacity: 0;
visibility: hidden;
transform: translateX(12px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.mls__l2 { width: min(140px, 44vw); }
.mls__l3 { width: min(120px, 38vw); }
.mls__i { position: relative; }
.mls__i--parent:hover > .mls__l2,
.mls__i--parent:focus-within > .mls__l2,
.mls__i--parent:hover > .mls__l3,
.mls__i--parent:focus-within > .mls__l3 {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s;
}
.mls__a,
.mls__b {
box-sizing: border-box;
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: $sp-1;
height: 30px;
padding: 0 $sp-3;
border: 0;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: $ink-70;
text-decoration: none;
cursor: pointer;
}
.mls__a:hover,
.mls__b:hover { background: rgba(255, 210, 63, .3); color: $subject-ink; }
.mls__a:focus-visible,
.mls__b:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.mls__b { font-weight: 700; color: $subject-ink; }
.mls__sub-caret { display: block; transition: transform $dur-quick $ease-spring; }
.mls__i--parent:hover > .mls__b .mls__sub-caret,
.mls__i--parent:focus-within > .mls__b .mls__sub-caret { transform: rotate(90deg); }
// ── 자동 루프 (2s) ────────────────────────────────────
// 6% L1 컷 등장 → 30% L2 슬라이드 → 52% L3 슬라이드 → 76·82·88.5% 에 나눠 순차 닫힘(L3→L2→L1)
// 슬라이드는 transform 을 $easing 으로, 보임은 steps(1, end) 컷으로 나눠 건다
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3 { visibility: visible; }
.mls.is-demo .mls__l1 {
animation-name: mls-l1;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__caret {
animation-name: mls-caret;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__trigger {
animation-name: mls-trig;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__l2 {
animation-name: mls-l2-slide, mls-l2-show;
animation-duration: $duration, $duration;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.mls.is-demo .mls__l3 {
animation-name: mls-l3-slide, mls-l3-show;
animation-duration: $duration, $duration;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.mls.is-demo .mls__i--feat > .mls__b .mls__sub-caret {
animation-name: mls-caret-feat;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__i--share > .mls__b .mls__sub-caret {
animation-name: mls-caret-share;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes mls-l1 {
0%, 5% { opacity: 0; transform: translateY(-6px); clip-path: inset(0 0 100% 0); }
6%, 82% { opacity: 1; transform: translateY(0); clip-path: inset(0 0 0 0); }
90%, 100% { opacity: 1; transform: translateY(0); clip-path: inset(100% 0 0 0); }
}
@keyframes mls-caret {
0%, 5% { transform: rotate(0deg); }
6%, 88% { transform: rotate(180deg); }
88.5%, 100% { transform: rotate(0deg); }
}
@keyframes mls-l2-slide {
0%, 28% { transform: translateX(12px); }
36%, 80% { transform: translateX(0); }
86%, 100% { transform: translateX(12px); }
}
@keyframes mls-l2-show {
0%, 29% { opacity: 0; }
31%, 82% { opacity: 1; }
82.1%, 100% { opacity: 0; }
}
@keyframes mls-caret-feat {
0%, 30% { transform: rotate(0deg); }
31%, 82% { transform: rotate(90deg); }
83%, 100% { transform: rotate(0deg); }
}
@keyframes mls-l3-slide {
0%, 50% { transform: translateX(12px); }
58%, 77% { transform: translateX(0); }
83%, 100% { transform: translateX(12px); }
}
@keyframes mls-l3-show {
0%, 51% { opacity: 0; }
53%, 76% { opacity: 1; }
76.1%, 100% { opacity: 0; }
}
@keyframes mls-caret-share {
0%, 52% { transform: rotate(0deg); }
53%, 76% { transform: rotate(90deg); }
77%, 100% { transform: rotate(0deg); }
}
// 트리거 배경도 열림 컷과 같은 순간에 갈린다 — 열림 순간의 변화량을 닫힘보다 크게
@keyframes mls-trig {
0%, 5.9% { background-color: transparent; }
6%, 88% { background-color: rgba(255, 210, 63, .45); }
88.5%, 100% { background-color: transparent; }
}
// 루프 동안 L1 목록을 훑는 하이라이트 띠 — 열려 있는 긴 유지 구간의 프레임을 채운다.
// 배경색 스윕은 강도가 0으로 측정되는 함정(134 실측)이 있어 띠가 실제 translateY 로 움직인다
.mls.is-demo .mls__l1::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: $sp-1;
height: 30px;
border-radius: $r-sm;
background: rgba(255, 210, 63, .3);
pointer-events: none;
animation-name: mls-sweep;
animation-duration: 900ms;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes mls-sweep {
0% { transform: translateY(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(90px); opacity: 0; }
}
// 320px 폰 — L1+L2+L3 가 옆으로 나가지 않게 폭을 줄인다.
// 슬라이드 중엔 부모 transform(+12px) 이 abs 자식에 겹쳐 최대 +24px 까지 나가므로
// 12.8 + 108+92+80 + 24 = 316.8 ≤ 320 이 되게 폭을 잡는다
@media (max-width: 340px) {
.mls__trigger { height: 30px; padding: 0 $sp-2; font-size: 11px; line-height: 15px; }
.mls__l1 { top: 34px; width: 108px; }
.mls__l2 { width: 92px; }
.mls__l3 { width: 80px; }
.mls__a,
.mls__b { height: 26px; padding: 0 $sp-1; gap: 0; font-size: 10px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3,
.mls.is-demo .mls__caret,
.mls.is-demo .mls__sub-caret { animation: none !important; }
.mls.is-demo .mls__l1::after { animation: none !important; opacity: 0; }
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3 { opacity: 1; visibility: visible; transform: none; }
.mls.is-demo .mls__caret { transform: rotate(180deg); }
.mls.is-demo .mls__trigger { background-color: rgba(255, 210, 63, .45); }
.mls.is-demo .mls__i--feat > .mls__b .mls__sub-caret,
.mls.is-demo .mls__i--share > .mls__b .mls__sub-caret { transform: rotate(90deg); }
}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);
}
.stage {
align-items: flex-start;
justify-items: start;
}
.mls {
position: relative;
}
.mls__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 18px;
color: #17141a;
cursor: pointer;
}
.mls__trigger:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
}
.mls:not(.is-demo) .mls__trigger[aria-expanded=true] {
background-color: rgba(255, 210, 63, 0.45);
}
.mls__caret {
display: block;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mls__trigger[aria-expanded=true] .mls__caret {
transform: rotate(180deg);
}
.mls__l1,
.mls__l2,
.mls__l3 {
position: absolute;
z-index: 2;
box-sizing: border-box;
margin: 0;
padding: 4px 0;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mls__u {
margin: 0;
padding: 0;
list-style: none;
}
.mls__l1 {
top: 42px;
left: 0;
width: min(160px, 50vw);
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;
}
.mls:not(.is-demo).is-open .mls__l1 {
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;
}
.mls__l2,
.mls__l3 {
top: 0;
left: 100%;
opacity: 0;
visibility: hidden;
transform: translateX(12px);
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;
}
.mls__l2 {
width: min(140px, 44vw);
}
.mls__l3 {
width: min(120px, 38vw);
}
.mls__i {
position: relative;
}
.mls__i--parent:hover > .mls__l2,
.mls__i--parent:focus-within > .mls__l2,
.mls__i--parent:hover > .mls__l3,
.mls__i--parent:focus-within > .mls__l3 {
opacity: 1;
visibility: visible;
transform: translateX(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;
}
.mls__a,
.mls__b {
box-sizing: border-box;
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 4px;
height: 30px;
padding: 0 12px;
border: 0;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgba(23, 20, 26, 0.72);
text-decoration: none;
cursor: pointer;
}
.mls__a:hover,
.mls__b:hover {
background: rgba(255, 210, 63, 0.3);
color: #17141a;
}
.mls__a:focus-visible,
.mls__b:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
}
.mls__b {
font-weight: 700;
color: #17141a;
}
.mls__sub-caret {
display: block;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mls__i--parent:hover > .mls__b .mls__sub-caret,
.mls__i--parent:focus-within > .mls__b .mls__sub-caret {
transform: rotate(90deg);
}
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3 {
visibility: visible;
}
.mls.is-demo .mls__l1 {
animation-name: mls-l1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__caret {
animation-name: mls-caret;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__trigger {
animation-name: mls-trig;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__l2 {
animation-name: mls-l2-slide, mls-l2-show;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.mls.is-demo .mls__l3 {
animation-name: mls-l3-slide, mls-l3-show;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.mls.is-demo .mls__i--feat > .mls__b .mls__sub-caret {
animation-name: mls-caret-feat;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mls.is-demo .mls__i--share > .mls__b .mls__sub-caret {
animation-name: mls-caret-share;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes mls-l1 {
0%, 5% {
opacity: 0;
transform: translateY(-6px);
clip-path: inset(0 0 100% 0);
}
6%, 82% {
opacity: 1;
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
90%, 100% {
opacity: 1;
transform: translateY(0);
clip-path: inset(100% 0 0 0);
}
}
@keyframes mls-caret {
0%, 5% {
transform: rotate(0deg);
}
6%, 88% {
transform: rotate(180deg);
}
88.5%, 100% {
transform: rotate(0deg);
}
}
@keyframes mls-l2-slide {
0%, 28% {
transform: translateX(12px);
}
36%, 80% {
transform: translateX(0);
}
86%, 100% {
transform: translateX(12px);
}
}
@keyframes mls-l2-show {
0%, 29% {
opacity: 0;
}
31%, 82% {
opacity: 1;
}
82.1%, 100% {
opacity: 0;
}
}
@keyframes mls-caret-feat {
0%, 30% {
transform: rotate(0deg);
}
31%, 82% {
transform: rotate(90deg);
}
83%, 100% {
transform: rotate(0deg);
}
}
@keyframes mls-l3-slide {
0%, 50% {
transform: translateX(12px);
}
58%, 77% {
transform: translateX(0);
}
83%, 100% {
transform: translateX(12px);
}
}
@keyframes mls-l3-show {
0%, 51% {
opacity: 0;
}
53%, 76% {
opacity: 1;
}
76.1%, 100% {
opacity: 0;
}
}
@keyframes mls-caret-share {
0%, 52% {
transform: rotate(0deg);
}
53%, 76% {
transform: rotate(90deg);
}
77%, 100% {
transform: rotate(0deg);
}
}
@keyframes mls-trig {
0%, 5.9% {
background-color: transparent;
}
6%, 88% {
background-color: rgba(255, 210, 63, 0.45);
}
88.5%, 100% {
background-color: transparent;
}
}
.mls.is-demo .mls__l1::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 4px;
height: 30px;
border-radius: 8px;
background: rgba(255, 210, 63, 0.3);
pointer-events: none;
animation-name: mls-sweep;
animation-duration: 900ms;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes mls-sweep {
0% {
transform: translateY(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(90px);
opacity: 0;
}
}
@media (max-width: 340px) {
.mls__trigger {
height: 30px;
padding: 0 8px;
font-size: 11px;
line-height: 15px;
}
.mls__l1 {
top: 34px;
width: 108px;
}
.mls__l2 {
width: 92px;
}
.mls__l3 {
width: 80px;
}
.mls__a,
.mls__b {
height: 26px;
padding: 0 4px;
gap: 0;
font-size: 10px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3,
.mls.is-demo .mls__caret,
.mls.is-demo .mls__sub-caret {
animation: none !important;
}
.mls.is-demo .mls__l1::after {
animation: none !important;
opacity: 0;
}
.mls.is-demo .mls__l1,
.mls.is-demo .mls__l2,
.mls.is-demo .mls__l3 {
opacity: 1;
visibility: visible;
transform: none;
}
.mls.is-demo .mls__caret {
transform: rotate(180deg);
}
.mls.is-demo .mls__trigger {
background-color: rgba(255, 210, 63, 0.45);
}
.mls.is-demo .mls__i--feat > .mls__b .mls__sub-caret,
.mls.is-demo .mls__i--share > .mls__b .mls__sub-caret {
transform: rotate(90deg);
}
}var root = document.getElementById('root');
var trigger = document.getElementById('trigger');
var parents = Array.prototype.slice.call(root.querySelectorAll('.mls__i--parent'));
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
parents.forEach(function (li) {
var btn = li.querySelector('button');
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
['mouseenter', 'focusin'].forEach(function (on) {
li.addEventListener(on, function () { btn.setAttribute('aria-expanded', 'true'); });
});
['mouseleave', 'focusout'].forEach(function (off) {
li.addEventListener(off, function () { btn.setAttribute('aria-expanded', 'false'); });
});
});
function stopDemo() { root.classList.remove('is-demo'); }
function setL1(on) {
stopDemo();
root.classList.toggle('is-open', on);
trigger.setAttribute('aria-expanded', String(on));
}
function closeAll() {
setL1(false);
parents.forEach(function (li) { li.querySelector('button').setAttribute('aria-expanded', 'false'); });
}
trigger.addEventListener('click', function () { setL1(!root.classList.contains('is-open')); });
trigger.addEventListener('focus', function () { setL1(true); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(); });
document.addEventListener('pointerdown', function (e) { if (!root.contains(e.target)) closeAll(); });
document.addEventListener('pointerdown', stopDemo, { once: true });
document.addEventListener('keydown', stopDemo, { once: true });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultilevelSlide.module.scss";
export type MultilevelSlideLabels = {
menuLabel: string;
trigger: string;
start: string;
features: string;
pricing: string;
contact: string;
makeList: string;
share: string;
importItems: string;
copyLink: string;
embed: string;
};
export type MultilevelSlideProps = {
labels?: MultilevelSlideLabels;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
type BlurEvt = { relatedTarget: Node | null; currentTarget: Node };
const DEFAULT_LABELS: MultilevelSlideLabels = {
menuLabel: "Library menu",
trigger: "Library",
start: "Get started",
features: "Features",
pricing: "Pricing",
contact: "Contact",
makeList: "Make a list",
share: "Share",
importItems: "Import",
copyLink: "Copy link",
embed: "Embed code",
};
function CaretDown() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" 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>
);
}
function CaretRight() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
);
}
export default function MultilevelSlide({
labels = DEFAULT_LABELS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: MultilevelSlideProps) {
const [live, setLive] = useState(true);
const [l1, setL1] = useState(false);
const [feat, setFeat] = useState(false);
const [share, setShare] = useState(false);
function wake() {
setLive(false);
}
function closeAll() {
wake();
setL1(false);
setFeat(false);
setShare(false);
}
function leftSub(e: BlurEvt) {
if (!e.currentTarget.contains(e.relatedTarget)) closeAll();
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={[styles.mls, live ? styles.isDemo : "", l1 ? styles.isOpen : ""].join(" ")}
style={vars}
onPointerDownCapture={wake}
onKeyDownCapture={wake}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") closeAll();
}}
onBlurCapture={leftSub}
>
<button
type="button"
className={styles.mlsTrigger}
aria-expanded={l1}
aria-haspopup="true"
onClick={() => {
wake();
setL1(!l1);
}}
onFocus={() => {
wake();
setL1(true);
}}
>
{labels.trigger}
<span className={styles.mlsCaret}>
<CaretDown />
</span>
</button>
<nav className={styles.mlsL1} aria-label={labels.menuLabel}>
<ul className={styles.mlsU}>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.start}</a>
</li>
<li
className={[styles.mlsI, styles.mlsParent, styles.iFeat].join(" ")}
onMouseEnter={() => {
wake();
setFeat(true);
}}
onMouseLeave={() => setFeat(false)}
onFocusCapture={() => setFeat(true)}
onBlurCapture={(e: BlurEvt) => {
if (!e.currentTarget.contains(e.relatedTarget)) setFeat(false);
}}
>
<button type="button" className={styles.mlsB} aria-expanded={feat} aria-haspopup="true">
{labels.features}
<span className={styles.mlsSubCaret}>
<CaretRight />
</span>
</button>
<ul className={[styles.mlsU, styles.mlsL2].join(" ")}>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.makeList}</a>
</li>
<li
className={[styles.mlsI, styles.mlsParent, styles.iShare].join(" ")}
onMouseEnter={() => {
wake();
setShare(true);
}}
onMouseLeave={() => setShare(false)}
onFocusCapture={() => setShare(true)}
onBlurCapture={(e: BlurEvt) => {
if (!e.currentTarget.contains(e.relatedTarget)) setShare(false);
}}
>
<button type="button" className={styles.mlsB} aria-expanded={share} aria-haspopup="true">
{labels.share}
<span className={styles.mlsSubCaret}>
<CaretRight />
</span>
</button>
<ul className={[styles.mlsU, styles.mlsL3].join(" ")}>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.copyLink}</a>
</li>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.embed}</a>
</li>
</ul>
</li>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.importItems}</a>
</li>
</ul>
</li>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.pricing}</a>
</li>
<li className={styles.mlsI}>
<a className={styles.mlsA} href="#">{labels.contact}</a>
</li>
</ul>
</nav>
</div>
);
}02 Multi-level dropdown whose submenus slide right — Measured area 16.116% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · 9 mega menu ui patterns, link list to site map
The post this demo lives in
9 mega menu ui patterns, link list to site mapA mega menu ui opens a screen-wide panel from one top-row item, turning the site into one readable map. The nine running See all demos in this post


