9 mega menu ui patterns, link list to site map
A mega menu ui opens a screen-wide panel from one top-row item, turning the site into one readable map.
01 navmenu-fullwide-hover-panel
02 navmenu-multilevel-slide
03 navmenu-tabbed-panel
04 navmenu-shrink-scroll
05 navmenu-sticky-subnav
06 navmenu-fullscreen-morph
07 navmenu-side-drawer
08 navmenu-search-expand
09 navmenu-notification-bell
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Full-width mega panel that opens on hover
- 02 Multi-level dropdown whose submenus slide right
- 03 Mega menu that swaps panels by tab
- 04 Header that folds smaller as you scroll
- 05 Sticky sub-nav that announces the current section
- 06 Fullscreen overlay where the burger folds into an X
- 07 Side drawer that slides in from the right
- 08 Search that unfolds from an icon into an input
- 09 Notification badge menu with live read state
The order is not a popularity list; it follows the weight the menu has to carry. The first three are desktop ways to unfold information. Hang a full-width panel on one row (01), push the hierarchy sideways (02), and swap bundles inside one panel (03). The next two meet the scroll. A header that folds as the page scrolls past (04) and a sub-nav that pins itself and announces the current section (05). Sixth and seventh answer the narrow screen. The burger folds into an X over a fullscreen sheet (06) and a drawer slides in from the right edge (07). The last two are the remaining jobs of a menu: finding and being notified. An icon unfolds into an input (08) and a bell counts what you haven't read (09). From the first cell to the last, the menu stops being a list of links and becomes the cockpit of the site. Neighboring pieces on the same axis live in hamburger menu animations and tab menu transitions, and the select-shaped side of choosing is covered in custom select dropdowns. What makes these mega menu ui examples hard is not the layout but deciding who owns focus and scrolling while a panel is open. If only the shrinking header of 04 is what you came for, 9 CSS sticky header effects takes that apart on its own, and the version that floats near the bottom edge instead of the top lives in 9 floating dock styles.
01Full-width mega panel that opens on hover
What you see
Rest the pointer on a top-level item and a panel as wide as the screen drops in, carrying three category columns and one highlight card. The opening lands as a six-pixel translateY plus a single cut, and the columns rise with a stagger after it, so eighteen of the twenty-four frames move inside the two-second loop.

<main class="stage">
<div class="fwp is-demo" id="root">
<header class="fwp__bar">
<span class="fwp__brand">Gift Box</span>
<nav class="fwp__nav" aria-label="Main menu">
<a class="fwp__link" href="#">All</a>
<button class="fwp__trigger" id="trigger" type="button" aria-controls="panel">Gifts
<svg class="fwp__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>
<a class="fwp__link" href="#">Furniture</a>
<a class="fwp__link" href="#">Help</a>
</nav>
</header>
<div class="fwp__hints" aria-hidden="true"><span></span><span></span></div>
<div class="fwp__panel" id="panel">
<div class="fwp__grid">
<section class="fwp__col">
<h3>Seasonal gifts</h3>
<ul><li>New Year set</li><li>Summer cooler</li><li>Winter blanket</li></ul>
</section>
<section class="fwp__col">
<h3>Price range</h3>
<ul><li>Under 30,000</li><li>Under 60,000</li><li>100,000 and up</li></ul>
</section>
<section class="fwp__col">
<h3>Recipient</h3>
<ul><li>Family</li><li>Coworkers</li><li>Friends</li></ul>
</section>
<aside class="fwp__promo">
<span class="fwp__promo-label">This week's pick</span>
<strong class="fwp__promo-title">Summer cooler set</strong>
<a class="fwp__promo-link" href="#">See details
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</a>
</aside>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 패널이 아래로 열리는 드롭다운 — 무대 중앙 정렬 대신 뿌리를 상단에 붙인다
.stage { align-items: flex-start; }
$ink-70: rgba(23, 20, 26, .7);
$ink-55: rgba(23, 20, 26, .55);
$ink-14: rgba(23, 20, 26, .14);
$ink-08: rgba(23, 20, 26, .08);
.fwp {
position: relative;
width: 100%;
}
.fwp__bar {
position: relative;
z-index: 3;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 44px;
padding: 0 $sp-4;
background: $stage-paper;
border-bottom: 1px solid $ink-14;
}
.fwp__brand {
font-size: 14px;
font-weight: 700;
line-height: 20px;
color: $subject-ink;
}
.fwp__nav {
display: flex;
align-items: center;
gap: $sp-3;
}
.fwp__link {
font-size: 12px;
line-height: 18px;
color: $ink-70;
text-decoration: none;
transition: color $dur-quick $easing;
}
.fwp__link:hover { color: $subject-ink; }
.fwp__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-control;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 700;
line-height: 18px;
color: $subject-ink;
cursor: pointer;
transition: background-color $dur-quick $easing;
}
.fwp__trigger:hover { background: rgba(47, 109, 246, .12); }
.fwp__trigger:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.fwp__caret { display: block; transition: transform $dur-quick $ease-spring; }
.fwp__trigger[aria-expanded="true"] .fwp__caret { transform: rotate(180deg); }
// 본문 흉내 — 패널이 열리면 이 위를 덮는다
.fwp__hints {
display: grid;
gap: $sp-2;
margin: $sp-4 $sp-4 0;
}
.fwp__hints span {
display: block;
height: 8px;
border-radius: $r-xs;
background: $ink-08;
}
.fwp__hints span:last-child { width: 62%; }
.fwp__panel {
position: absolute;
left: 0;
right: 0;
top: 45px;
z-index: 2;
box-sizing: border-box;
padding: $sp-4;
background: $stage-paper;
border-radius: 0 0 $r-card $r-card;
box-shadow: $shadow-raised;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.fwp:not(.is-demo).is-open .fwp__panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s;
}
.fwp__grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1.2fr;
column-gap: $sp-6;
align-items: start;
}
.fwp__col h3 {
margin: 0 0 $sp-2;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: $subject-ink;
}
.fwp__col ul {
margin: 0;
padding: 0;
list-style: none;
}
.fwp__col li {
font-size: 11px;
line-height: 22px;
color: $ink-70;
}
.fwp__col li:hover { color: $subject-ink; }
.fwp__promo {
box-sizing: border-box;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-1;
padding: $sp-3;
border-radius: $r-sm;
background: $subject-blue;
color: #ffffff;
}
.fwp__promo-label {
font-size: 10px;
font-weight: 700;
line-height: 13px;
color: rgba(255, 255, 255, .8);
}
.fwp__promo-title {
font-size: 13px;
font-weight: 700;
line-height: 17px;
color: #ffffff;
}
.fwp__promo-link {
display: inline-flex;
align-items: center;
gap: $sp-1;
width: fit-content;
font-size: 11px;
line-height: 15px;
color: $subject-cream;
text-decoration: underline;
text-underline-offset: 4px;
}
.fwp__promo-link:hover { color: #ffffff; }
.fwp__promo-link svg { display: block; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0-8% 닫힘 → 8-12% 패널 등장 + 열 항목 stagger → 75-78.5% 닫힘(짧게) → 90% 안에 원 상태
// 열 항목은 $duration 전체를 주기로 하는 애니메이션에 delay 만 얹어 시차를 낸다 —
// 매 사이클마다 패널이 다시 열릴 때 항목이 다시 떠오른다
.fwp.is-demo .fwp__panel {
visibility: visible;
animation-name: fwp-panel;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__caret {
animation-name: fwp-caret;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__grid > * {
animation-name: fwp-item;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__grid > :nth-child(1) { animation-delay: 240ms; }
.fwp.is-demo .fwp__grid > :nth-child(2) { animation-delay: 300ms; }
.fwp.is-demo .fwp__grid > :nth-child(3) { animation-delay: 360ms; }
.fwp.is-demo .fwp__grid > :nth-child(4) { animation-delay: 420ms; }
// 패널이 열려 있는 동안 화면이 죽지 않게 — 카드 링크의 화살표가 천천히 오른위로 쉰다.
// 열림·닫힘 컷 사이의 긴 유지 구간을 채우는 연속 동작 (움직인프레임 5→보강)
.fwp.is-demo .fwp__promo-link svg {
animation-name: fwp-nudge;
animation-duration: 640ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes fwp-nudge {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -2px); }
}
@keyframes fwp-panel {
0%, 8.4% { opacity: 0; transform: translateY(-6px); clip-path: inset(0 0 100% 0); }
8.5%, 74% { opacity: 1; transform: translateY(0); clip-path: inset(0 0 0 0); }
86%, 100% { opacity: 1; transform: translateY(0); clip-path: inset(100% 0 0 0); }
}
@keyframes fwp-caret {
0%, 8.4% { transform: rotate(0deg); }
8.5%, 74% { transform: rotate(180deg); }
86%, 100% { transform: rotate(0deg); }
}
@keyframes fwp-item {
0% { opacity: 0; transform: translateY(8px); }
4%, 100% { opacity: 1; transform: translateY(0); }
}
@media (max-width: 340px) {
.fwp__bar { height: 32px; padding: 0 $sp-2; }
.fwp__brand { font-size: 12px; line-height: 16px; }
.fwp__nav { gap: $sp-2; }
.fwp__link { font-size: 11px; line-height: 16px; }
.fwp__trigger { padding: 0 $sp-1; font-size: 11px; line-height: 16px; }
.fwp__hints { gap: $sp-1; margin: $sp-2 $sp-2 0; }
.fwp__hints span { height: 6px; }
.fwp__panel { top: 33px; padding: $sp-2; }
.fwp__grid { grid-template-columns: 1fr 1fr; gap: $sp-2; }
.fwp__col h3 { margin-bottom: 0; font-size: 10px; line-height: 13px; }
.fwp__col li { font-size: 10px; line-height: 14px; }
.fwp__promo { padding: $sp-1; }
.fwp__promo-label { font-size: 9px; line-height: 12px; }
.fwp__promo-title { font-size: 10px; line-height: 13px; }
.fwp__promo-link { font-size: 9px; line-height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.fwp.is-demo .fwp__panel,
.fwp.is-demo .fwp__caret,
.fwp.is-demo .fwp__promo-link svg,
.fwp.is-demo .fwp__grid > * { animation: none !important; }
.fwp.is-demo .fwp__panel { opacity: 1; visibility: visible; transform: none; }
}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 {
align-items: flex-start;
}
.fwp {
position: relative;
width: 100%;
}
.fwp__bar {
position: relative;
z-index: 3;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 44px;
padding: 0 16px;
background: #ffffff;
border-bottom: 1px solid rgba(23, 20, 26, 0.14);
}
.fwp__brand {
font-size: 14px;
font-weight: 700;
line-height: 20px;
color: #17141a;
}
.fwp__nav {
display: flex;
align-items: center;
gap: 12px;
}
.fwp__link {
font-size: 12px;
line-height: 18px;
color: rgba(23, 20, 26, 0.7);
text-decoration: none;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fwp__link:hover {
color: #17141a;
}
.fwp__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 0;
border-radius: 12px;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 700;
line-height: 18px;
color: #17141a;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fwp__trigger:hover {
background: rgba(47, 109, 246, 0.12);
}
.fwp__trigger:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.fwp__caret {
display: block;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fwp__trigger[aria-expanded=true] .fwp__caret {
transform: rotate(180deg);
}
.fwp__hints {
display: grid;
gap: 8px;
margin: 16px 16px 0;
}
.fwp__hints span {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
}
.fwp__hints span:last-child {
width: 62%;
}
.fwp__panel {
position: absolute;
left: 0;
right: 0;
top: 45px;
z-index: 2;
box-sizing: border-box;
padding: 16px;
background: #ffffff;
border-radius: 0 0 16px 16px;
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;
}
.fwp:not(.is-demo).is-open .fwp__panel {
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;
}
.fwp__grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1.2fr;
column-gap: 24px;
align-items: start;
}
.fwp__col h3 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: #17141a;
}
.fwp__col ul {
margin: 0;
padding: 0;
list-style: none;
}
.fwp__col li {
font-size: 11px;
line-height: 22px;
color: rgba(23, 20, 26, 0.7);
}
.fwp__col li:hover {
color: #17141a;
}
.fwp__promo {
box-sizing: border-box;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 12px;
border-radius: 8px;
background: #2f6df6;
color: #ffffff;
}
.fwp__promo-label {
font-size: 10px;
font-weight: 700;
line-height: 13px;
color: rgba(255, 255, 255, 0.8);
}
.fwp__promo-title {
font-size: 13px;
font-weight: 700;
line-height: 17px;
color: #ffffff;
}
.fwp__promo-link {
display: inline-flex;
align-items: center;
gap: 4px;
width: fit-content;
font-size: 11px;
line-height: 15px;
color: #fff7e6;
text-decoration: underline;
text-underline-offset: 4px;
}
.fwp__promo-link:hover {
color: #ffffff;
}
.fwp__promo-link svg {
display: block;
}
.fwp.is-demo .fwp__panel {
visibility: visible;
animation-name: fwp-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__caret {
animation-name: fwp-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__grid > * {
animation-name: fwp-item;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fwp.is-demo .fwp__grid > :nth-child(1) {
animation-delay: 240ms;
}
.fwp.is-demo .fwp__grid > :nth-child(2) {
animation-delay: 300ms;
}
.fwp.is-demo .fwp__grid > :nth-child(3) {
animation-delay: 360ms;
}
.fwp.is-demo .fwp__grid > :nth-child(4) {
animation-delay: 420ms;
}
.fwp.is-demo .fwp__promo-link svg {
animation-name: fwp-nudge;
animation-duration: 640ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes fwp-nudge {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(2px, -2px);
}
}
@keyframes fwp-panel {
0%, 8.4% {
opacity: 0;
transform: translateY(-6px);
clip-path: inset(0 0 100% 0);
}
8.5%, 74% {
opacity: 1;
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
86%, 100% {
opacity: 1;
transform: translateY(0);
clip-path: inset(100% 0 0 0);
}
}
@keyframes fwp-caret {
0%, 8.4% {
transform: rotate(0deg);
}
8.5%, 74% {
transform: rotate(180deg);
}
86%, 100% {
transform: rotate(0deg);
}
}
@keyframes fwp-item {
0% {
opacity: 0;
transform: translateY(8px);
}
4%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.fwp__bar {
height: 32px;
padding: 0 8px;
}
.fwp__brand {
font-size: 12px;
line-height: 16px;
}
.fwp__nav {
gap: 8px;
}
.fwp__link {
font-size: 11px;
line-height: 16px;
}
.fwp__trigger {
padding: 0 4px;
font-size: 11px;
line-height: 16px;
}
.fwp__hints {
gap: 4px;
margin: 8px 8px 0;
}
.fwp__hints span {
height: 6px;
}
.fwp__panel {
top: 33px;
padding: 8px;
}
.fwp__grid {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.fwp__col h3 {
margin-bottom: 0;
font-size: 10px;
line-height: 13px;
}
.fwp__col li {
font-size: 10px;
line-height: 14px;
}
.fwp__promo {
padding: 4px;
}
.fwp__promo-label {
font-size: 9px;
line-height: 12px;
}
.fwp__promo-title {
font-size: 10px;
line-height: 13px;
}
.fwp__promo-link {
font-size: 9px;
line-height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.fwp.is-demo .fwp__panel,
.fwp.is-demo .fwp__caret,
.fwp.is-demo .fwp__promo-link svg,
.fwp.is-demo .fwp__grid > * {
animation: none !important;
}
.fwp.is-demo .fwp__panel {
opacity: 1;
visibility: visible;
transform: none;
}
}var root = document.getElementById('root');
var trigger = document.getElementById('trigger');
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
function stopDemo() { root.classList.remove('is-demo'); }
function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }
trigger.addEventListener('mouseenter', open);
trigger.addEventListener('focus', open);
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
root.addEventListener('mouseleave', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.addEventListener('pointerdown', stopDemo, { once: true });
document.addEventListener('keydown', stopDemo, { once: true });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullwideHoverPanel.module.scss";
export type FullwideHoverPanelLabels = {
navLabel: string;
brand: string;
navAll: string;
trigger: string;
navFurniture: string;
navHelp: string;
colSeasonal: string;
colPrice: string;
colRecipient: string;
itemsSeasonal: string[];
itemsPrice: string[];
itemsRecipient: string[];
pickLabel: string;
pickTitle: string;
pickLink: string;
};
export type FullwideHoverPanelProps = {
labels?: FullwideHoverPanelLabels;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
const DEFAULT_LABELS: FullwideHoverPanelLabels = {
navLabel: "Main menu",
brand: "Gift Box",
navAll: "All",
trigger: "Gifts",
navFurniture: "Furniture",
navHelp: "Help",
colSeasonal: "Seasonal",
colPrice: "Price range",
colRecipient: "Recipient",
itemsSeasonal: ["New Year set", "Summer cooler", "Winter blanket"],
itemsPrice: ["$20 range", "$50 range", "$100 and up"],
itemsRecipient: ["Family", "Coworkers", "Friends"],
pickLabel: "This week's pick",
pickTitle: "Cooler gift set",
pickLink: "See details",
};
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 ArrowUpRight() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<g fill="currentColor">
<path d="M192 64v104L88 64Z" opacity=".2" />
<path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z" />
</g>
</svg>
);
}
export default function FullwideHoverPanel({
labels = DEFAULT_LABELS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FullwideHoverPanelProps) {
const [live, setLive] = useState(true);
const [open, setOpen] = useState(false);
function wake() {
setLive(false);
}
function show() {
wake();
setOpen(true);
}
function hide() {
wake();
setOpen(false);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cols = [
{ title: labels.colSeasonal, items: labels.itemsSeasonal },
{ title: labels.colPrice, items: labels.itemsPrice },
{ title: labels.colRecipient, items: labels.itemsRecipient },
];
return (
<div
className={[styles.fwp, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")}
style={vars}
onMouseLeave={hide}
onPointerDownCapture={wake}
onKeyDownCapture={wake}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") hide();
}}
>
<header className={styles.fwpBar}>
<span className={styles.fwpBrand}>{labels.brand}</span>
<nav className={styles.fwpNav} aria-label={labels.navLabel}>
<a className={styles.fwpLink} href="#">{labels.navAll}</a>
<button
type="button"
className={styles.fwpTrigger}
aria-expanded={open}
aria-haspopup="true"
onMouseEnter={show}
onFocus={show}
onClick={() => (open ? hide() : show())}
>
{labels.trigger}
<span className={styles.fwpCaret}>
<CaretDown />
</span>
</button>
<a className={styles.fwpLink} href="#">{labels.navFurniture}</a>
<a className={styles.fwpLink} href="#">{labels.navHelp}</a>
</nav>
</header>
<div className={styles.fwpHints} aria-hidden="true">
<span />
<span />
</div>
<div className={styles.fwpPanel}>
<div className={styles.fwpGrid}>
{cols.map((c) => (
<section className={styles.fwpCol} key={c.title}>
<h3>{c.title}</h3>
<ul>
{c.items.map((it) => (
<li key={it}>{it}</li>
))}
</ul>
</section>
))}
<aside className={styles.fwpPromo}>
<span className={styles.fwpPromoLabel}>{labels.pickLabel}</span>
<strong className={styles.fwpPromoTitle}>{labels.pickTitle}</strong>
<a className={styles.fwpPromoLink} href="#">
{labels.pickLink}
<ArrowUpRight />
</a>
</aside>
</div>
</div>
</div>
);
}01 · Full-width mega panel that opens on hover — Measured area 12.979% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It fits shopping and SaaS headers with three or more product families.
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }
trigger.addEventListener('mouseenter', open);
trigger.addEventListener('focus', open);
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
root.addEventListener('mouseleave', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
02Multi-level dropdown whose submenus slide right
How a mega menu ui design slides its submenus on screen
Hover an arrowed item in the first list and the second level slides in from the right; an arrowed item inside it carries a third level. Submenus are driven purely by CSS :hover and :focus-within, so Tabbing into an item opens it at the same instant, while JS only guards the top-level open state and aria-expanded.

<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 · See the password
How to read the code
It belongs in docs and help centers whose categories stack two or three levels.
// Submenus sit to the right of their parent li — opened by hover/:focus-within alone
.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__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);
}
03Mega menu that swaps panels by tab
What moves
Press a tab inside the panel and the card bundle swaps in place within a single frame, while only the pressed tab reads as selected. The swap slices the two bundles apart with a steps(1, end) cut, and the fresh cards pop on a translateY stagger right after, which lifts the moving-frame count by more than half compared to a cut alone.

<main class="stage">
<div class="tmp is-demo" id="root">
<header class="tmp__bar">
<span class="tmp__brand">Homeware</span>
<button class="tmp__trigger" id="trigger" type="button" aria-controls="panel">Shop
<svg class="tmp__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>
</header>
<div class="tmp__panel" id="panel">
<div class="tmp__tabs" role="tablist" aria-label="Shop menu" id="tablist">
<button class="tmp__tab" id="tab0" type="button" role="tab" aria-controls="set0" data-i="0">Popular</button>
<button class="tmp__tab" id="tab1" type="button" role="tab" aria-controls="set1" data-i="1">New</button>
<button class="tmp__tab" id="tab2" type="button" role="tab" aria-controls="set2" data-i="2">Sale</button>
</div>
<div class="tmp__body">
<ul class="tmp__set" id="set0" role="tabpanel" aria-labelledby="tab0">
<li class="tmp__card">
<span class="tmp__block tmp__block--0"></span>
<strong class="tmp__name">Wall shelf</strong>
<span class="tmp__price">24,000</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--1"></span>
<strong class="tmp__name">Mini planter</strong>
<span class="tmp__price">9,800</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--2"></span>
<strong class="tmp__name">Mood lamp</strong>
<span class="tmp__price">16,500</span>
</li>
</ul>
<ul class="tmp__set" id="set1" role="tabpanel" aria-labelledby="tab1">
<li class="tmp__card">
<span class="tmp__block tmp__block--0"></span>
<strong class="tmp__name">Bamboo desk</strong>
<span class="tmp__price">89,000</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--1"></span>
<strong class="tmp__name">Glass cup set</strong>
<span class="tmp__price">19,900</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--2"></span>
<strong class="tmp__name">Drawer unit</strong>
<span class="tmp__price">42,000</span>
</li>
</ul>
<ul class="tmp__set" id="set2" role="tabpanel" aria-labelledby="tab2">
<li class="tmp__card">
<span class="tmp__block tmp__block--0"><span class="tmp__badge">30% off</span></span>
<strong class="tmp__name">Rug</strong>
<span class="tmp__price">27,300</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--1"><span class="tmp__badge">20% off</span></span>
<strong class="tmp__name">Clothes rack</strong>
<span class="tmp__price">31,200</span>
</li>
<li class="tmp__card">
<span class="tmp__block tmp__block--2"><span class="tmp__badge">45% off</span></span>
<strong class="tmp__name">Mirror</strong>
<span class="tmp__price">15,400</span>
</li>
</ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 패널이 아래로 열리는 드롭다운 — 무대 중앙 정렬 대신 뿌리를 상단에 붙인다
.stage { align-items: flex-start; }
$ink-70: rgba(23, 20, 26, .7);
$ink-55: rgba(23, 20, 26, .55);
$ink-14: rgba(23, 20, 26, .14);
.tmp {
position: relative;
width: 100%;
}
.tmp__bar {
position: relative;
z-index: 3;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 40px;
padding: 0 $sp-4;
background: $stage-paper;
border-bottom: 1px solid $ink-14;
}
.tmp__brand {
font-size: 14px;
font-weight: 700;
line-height: 20px;
color: $subject-ink;
}
.tmp__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-control;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 700;
line-height: 18px;
color: $subject-ink;
cursor: pointer;
transition: background-color $dur-quick $easing;
}
.tmp__trigger:hover { background: rgba(47, 109, 246, .12); }
.tmp__trigger:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tmp__caret { display: block; transition: transform $dur-quick $ease-spring; }
.tmp__trigger[aria-expanded="true"] .tmp__caret { transform: rotate(180deg); }
.tmp__panel {
position: absolute;
left: 0;
right: 0;
top: 41px;
z-index: 2;
box-sizing: border-box;
padding: $sp-2 $sp-3 $sp-3;
background: $stage-paper;
border-radius: 0 0 $r-card $r-card;
box-shadow: $shadow-raised;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.tmp:not(.is-demo).is-open .tmp__panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s;
}
// 탭은 flex: 0 0 auto — 밑줄은 탭마다 제자리(::after)에서 채워지고, 이동하지 않는다
.tmp__tabs {
display: flex;
align-items: stretch;
gap: $sp-2;
height: 34px;
margin-bottom: $sp-2;
border-bottom: 1px solid $ink-14;
}
.tmp__tab {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 $sp-2;
border: 0;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 34px;
color: $ink-55;
cursor: pointer;
transition: color $dur-quick $easing;
}
.tmp__tab:hover { color: $ink-70; }
.tmp__tab:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tmp__tab[aria-selected="true"] { color: $subject-ink; }
.tmp__tab::after {
content: "";
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: 0;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: center;
transition: transform $dur-quick $ease-spring;
}
.tmp__tab[aria-selected="true"]::after { transform: scaleX(1); }
.tmp__body {
position: relative;
height: 108px;
}
.tmp__set {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.tmp__set[hidden] { display: none; }
.tmp__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
border: 1px solid $ink-14;
border-radius: $r-sm;
background: $stage-paper;
}
.tmp__block {
position: relative;
display: block;
height: 52px;
border-radius: $r-xs;
}
.tmp__block--0 { background: $subject-blue; }
.tmp__block--1 { background: $stage-yellow; }
.tmp__block--2 { background: $stage-orange; }
.tmp__name {
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: $subject-ink;
}
.tmp__price {
font-size: 11px;
font-weight: 400;
line-height: 14px;
color: $ink-70;
}
.tmp__badge {
position: absolute;
top: $sp-1;
right: $sp-1;
box-sizing: border-box;
padding: 0 $sp-1;
border-radius: $r-xs;
background: $subject-ink;
color: $subject-cream;
font-size: 9px;
font-weight: 700;
line-height: 12px;
}
// 정적 조작 — 탭을 고르면 해당 묶음만 살리고 카드가 시차로 팝한다
.tmp__set.is-pop .tmp__card {
animation-name: tmp-card-pop;
animation-duration: $dur-base;
animation-timing-function: $ease-pop;
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.tmp__set.is-pop .tmp__card:nth-child(2) { animation-delay: 40ms; }
.tmp__set.is-pop .tmp__card:nth-child(3) { animation-delay: 80ms; }
@keyframes tmp-card-pop {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 8% 패널 등장(인기) → 34% 신상 컷+팝 → 58% 할인 컷+팝 → 80-84% 닫힘(짧게)
// 묶음 교체는 steps(1, end) 컷, 그 옆에서 새 카드가 translateY 팝으로 프레임을 채운다
.tmp.is-demo .tmp__panel {
visibility: visible;
animation-name: tmp-panel;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo .tmp__caret {
animation-name: tmp-caret;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo .tmp__set,
.tmp.is-demo .tmp__set[hidden] { display: grid; }
.tmp.is-demo .tmp__set {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #set0 { animation-name: tmp-set0; }
.tmp.is-demo #set1 { animation-name: tmp-set1; }
.tmp.is-demo #set2 { animation-name: tmp-set2; }
.tmp.is-demo .tmp__tab {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #tab0 { animation-name: tmp-tab0; }
.tmp.is-demo #tab1 { animation-name: tmp-tab1; }
.tmp.is-demo #tab2 { animation-name: tmp-tab2; }
.tmp.is-demo #tab0::after,
.tmp.is-demo #tab1::after,
.tmp.is-demo #tab2::after {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #tab0::after { animation-name: tmp-u0; }
.tmp.is-demo #tab1::after { animation-name: tmp-u1; }
.tmp.is-demo #tab2::after { animation-name: tmp-u2; }
.tmp.is-demo .tmp__card {
animation-name: tmp-card;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #set0 .tmp__card:nth-child(1) { animation-delay: 220ms; }
.tmp.is-demo #set0 .tmp__card:nth-child(2) { animation-delay: 260ms; }
.tmp.is-demo #set0 .tmp__card:nth-child(3) { animation-delay: 300ms; }
.tmp.is-demo #set1 .tmp__card:nth-child(1) { animation-delay: 700ms; }
.tmp.is-demo #set1 .tmp__card:nth-child(2) { animation-delay: 740ms; }
.tmp.is-demo #set1 .tmp__card:nth-child(3) { animation-delay: 780ms; }
.tmp.is-demo #set2 .tmp__card:nth-child(1) { animation-delay: 1180ms; }
.tmp.is-demo #set2 .tmp__card:nth-child(2) { animation-delay: 1220ms; }
.tmp.is-demo #set2 .tmp__card:nth-child(3) { animation-delay: 1260ms; }
@keyframes tmp-panel {
0%, 8.3% { opacity: 0; transform: translateY(-6px); clip-path: inset(0 0 100% 0); }
8.4%, 74% { opacity: 1; transform: translateY(0); clip-path: inset(0 0 0 0); }
86%, 100% { opacity: 1; transform: translateY(0); clip-path: inset(100% 0 0 0); }
}
@keyframes tmp-caret {
0%, 8.3% { transform: rotate(0deg); }
8.4%, 74% { transform: rotate(180deg); }
86%, 100% { transform: rotate(0deg); }
}
@keyframes tmp-set0 {
0%, 7.9% { opacity: 0; }
8%, 33.9% { opacity: 1; }
34%, 100% { opacity: 0; }
}
@keyframes tmp-set1 {
0%, 33.9% { opacity: 0; }
34%, 57.9% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@keyframes tmp-set2 {
0%, 57.9% { opacity: 0; }
58%, 100% { opacity: 1; }
}
@keyframes tmp-tab0 {
0%, 7.9% { color: $ink-55; }
8%, 33.9% { color: $subject-ink; }
34%, 100% { color: $ink-55; }
}
@keyframes tmp-tab1 {
0%, 33.9% { color: $ink-55; }
34%, 57.9% { color: $subject-ink; }
58%, 100% { color: $ink-55; }
}
@keyframes tmp-tab2 {
0%, 57.9% { color: $ink-55; }
58%, 100% { color: $subject-ink; }
}
@keyframes tmp-u0 {
0%, 7.9% { transform: scaleX(0); }
8%, 33.9% { transform: scaleX(1); }
34%, 100% { transform: scaleX(0); }
}
@keyframes tmp-u1 {
0%, 33.9% { transform: scaleX(0); }
34%, 57.9% { transform: scaleX(1); }
58%, 100% { transform: scaleX(0); }
}
@keyframes tmp-u2 {
0%, 57.9% { transform: scaleX(0); }
58%, 100% { transform: scaleX(1); }
}
@keyframes tmp-card {
0% { opacity: 0; transform: translateY(8px); }
4%, 100% { opacity: 1; transform: translateY(0); }
}
@media (max-width: 340px) {
.tmp__bar { height: 32px; padding: 0 $sp-2; }
.tmp__brand { font-size: 12px; line-height: 16px; }
.tmp__trigger { padding: 0 $sp-1; font-size: 11px; line-height: 16px; }
.tmp__panel { top: 33px; padding: $sp-1 $sp-2 $sp-2; }
.tmp__tabs { height: 26px; gap: $sp-1; margin-bottom: $sp-1; }
.tmp__tab { padding: 0 $sp-1; font-size: 11px; line-height: 26px; }
.tmp__body { height: 76px; }
.tmp__card { gap: 0; padding: $sp-1; }
.tmp__block { height: 34px; }
.tmp__name { font-size: 10px; line-height: 13px; }
.tmp__price { font-size: 9px; line-height: 12px; }
.tmp__badge { font-size: 8px; line-height: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tmp.is-demo .tmp__panel,
.tmp.is-demo .tmp__caret,
.tmp.is-demo .tmp__set,
.tmp.is-demo .tmp__card,
.tmp.is-demo .tmp__tab,
.tmp.is-demo .tmp__tab::after { animation: none !important; }
.tmp.is-demo .tmp__panel { opacity: 1; visibility: visible; transform: none; }
.tmp.is-demo #set1,
.tmp.is-demo #set2 { opacity: 0; }
}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 {
align-items: flex-start;
}
.tmp {
position: relative;
width: 100%;
}
.tmp__bar {
position: relative;
z-index: 3;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 40px;
padding: 0 16px;
background: #ffffff;
border-bottom: 1px solid rgba(23, 20, 26, 0.14);
}
.tmp__brand {
font-size: 14px;
font-weight: 700;
line-height: 20px;
color: #17141a;
}
.tmp__trigger {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 0;
border-radius: 12px;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
font-weight: 700;
line-height: 18px;
color: #17141a;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tmp__trigger:hover {
background: rgba(47, 109, 246, 0.12);
}
.tmp__trigger:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tmp__caret {
display: block;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tmp__trigger[aria-expanded=true] .tmp__caret {
transform: rotate(180deg);
}
.tmp__panel {
position: absolute;
left: 0;
right: 0;
top: 41px;
z-index: 2;
box-sizing: border-box;
padding: 8px 12px 12px;
background: #ffffff;
border-radius: 0 0 16px 16px;
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;
}
.tmp:not(.is-demo).is-open .tmp__panel {
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;
}
.tmp__tabs {
display: flex;
align-items: stretch;
gap: 8px;
height: 34px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(23, 20, 26, 0.14);
}
.tmp__tab {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 8px;
border: 0;
background: transparent;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 34px;
color: rgba(23, 20, 26, 0.55);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tmp__tab:hover {
color: rgba(23, 20, 26, 0.7);
}
.tmp__tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tmp__tab[aria-selected=true] {
color: #17141a;
}
.tmp__tab::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 0;
height: 2px;
border-radius: 999px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tmp__tab[aria-selected=true]::after {
transform: scaleX(1);
}
.tmp__body {
position: relative;
height: 108px;
}
.tmp__set {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.tmp__set[hidden] {
display: none;
}
.tmp__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
background: #ffffff;
}
.tmp__block {
position: relative;
display: block;
height: 52px;
border-radius: 4px;
}
.tmp__block--0 {
background: #2f6df6;
}
.tmp__block--1 {
background: #ffd23f;
}
.tmp__block--2 {
background: #ff4d1f;
}
.tmp__name {
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: #17141a;
}
.tmp__price {
font-size: 11px;
font-weight: 400;
line-height: 14px;
color: rgba(23, 20, 26, 0.7);
}
.tmp__badge {
position: absolute;
top: 4px;
right: 4px;
box-sizing: border-box;
padding: 0 4px;
border-radius: 4px;
background: #17141a;
color: #fff7e6;
font-size: 9px;
font-weight: 700;
line-height: 12px;
}
.tmp__set.is-pop .tmp__card {
animation-name: tmp-card-pop;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.tmp__set.is-pop .tmp__card:nth-child(2) {
animation-delay: 40ms;
}
.tmp__set.is-pop .tmp__card:nth-child(3) {
animation-delay: 80ms;
}
@keyframes tmp-card-pop {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.tmp.is-demo .tmp__panel {
visibility: visible;
animation-name: tmp-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo .tmp__caret {
animation-name: tmp-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo .tmp__set,
.tmp.is-demo .tmp__set[hidden] {
display: grid;
}
.tmp.is-demo .tmp__set {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #set0 {
animation-name: tmp-set0;
}
.tmp.is-demo #set1 {
animation-name: tmp-set1;
}
.tmp.is-demo #set2 {
animation-name: tmp-set2;
}
.tmp.is-demo .tmp__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #tab0 {
animation-name: tmp-tab0;
}
.tmp.is-demo #tab1 {
animation-name: tmp-tab1;
}
.tmp.is-demo #tab2 {
animation-name: tmp-tab2;
}
.tmp.is-demo #tab0::after,
.tmp.is-demo #tab1::after,
.tmp.is-demo #tab2::after {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #tab0::after {
animation-name: tmp-u0;
}
.tmp.is-demo #tab1::after {
animation-name: tmp-u1;
}
.tmp.is-demo #tab2::after {
animation-name: tmp-u2;
}
.tmp.is-demo .tmp__card {
animation-name: tmp-card;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tmp.is-demo #set0 .tmp__card:nth-child(1) {
animation-delay: 220ms;
}
.tmp.is-demo #set0 .tmp__card:nth-child(2) {
animation-delay: 260ms;
}
.tmp.is-demo #set0 .tmp__card:nth-child(3) {
animation-delay: 300ms;
}
.tmp.is-demo #set1 .tmp__card:nth-child(1) {
animation-delay: 700ms;
}
.tmp.is-demo #set1 .tmp__card:nth-child(2) {
animation-delay: 740ms;
}
.tmp.is-demo #set1 .tmp__card:nth-child(3) {
animation-delay: 780ms;
}
.tmp.is-demo #set2 .tmp__card:nth-child(1) {
animation-delay: 1180ms;
}
.tmp.is-demo #set2 .tmp__card:nth-child(2) {
animation-delay: 1220ms;
}
.tmp.is-demo #set2 .tmp__card:nth-child(3) {
animation-delay: 1260ms;
}
@keyframes tmp-panel {
0%, 8.3% {
opacity: 0;
transform: translateY(-6px);
clip-path: inset(0 0 100% 0);
}
8.4%, 74% {
opacity: 1;
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
86%, 100% {
opacity: 1;
transform: translateY(0);
clip-path: inset(100% 0 0 0);
}
}
@keyframes tmp-caret {
0%, 8.3% {
transform: rotate(0deg);
}
8.4%, 74% {
transform: rotate(180deg);
}
86%, 100% {
transform: rotate(0deg);
}
}
@keyframes tmp-set0 {
0%, 7.9% {
opacity: 0;
}
8%, 33.9% {
opacity: 1;
}
34%, 100% {
opacity: 0;
}
}
@keyframes tmp-set1 {
0%, 33.9% {
opacity: 0;
}
34%, 57.9% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@keyframes tmp-set2 {
0%, 57.9% {
opacity: 0;
}
58%, 100% {
opacity: 1;
}
}
@keyframes tmp-tab0 {
0%, 7.9% {
color: rgba(23, 20, 26, 0.55);
}
8%, 33.9% {
color: #17141a;
}
34%, 100% {
color: rgba(23, 20, 26, 0.55);
}
}
@keyframes tmp-tab1 {
0%, 33.9% {
color: rgba(23, 20, 26, 0.55);
}
34%, 57.9% {
color: #17141a;
}
58%, 100% {
color: rgba(23, 20, 26, 0.55);
}
}
@keyframes tmp-tab2 {
0%, 57.9% {
color: rgba(23, 20, 26, 0.55);
}
58%, 100% {
color: #17141a;
}
}
@keyframes tmp-u0 {
0%, 7.9% {
transform: scaleX(0);
}
8%, 33.9% {
transform: scaleX(1);
}
34%, 100% {
transform: scaleX(0);
}
}
@keyframes tmp-u1 {
0%, 33.9% {
transform: scaleX(0);
}
34%, 57.9% {
transform: scaleX(1);
}
58%, 100% {
transform: scaleX(0);
}
}
@keyframes tmp-u2 {
0%, 57.9% {
transform: scaleX(0);
}
58%, 100% {
transform: scaleX(1);
}
}
@keyframes tmp-card {
0% {
opacity: 0;
transform: translateY(8px);
}
4%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.tmp__bar {
height: 32px;
padding: 0 8px;
}
.tmp__brand {
font-size: 12px;
line-height: 16px;
}
.tmp__trigger {
padding: 0 4px;
font-size: 11px;
line-height: 16px;
}
.tmp__panel {
top: 33px;
padding: 4px 8px 8px;
}
.tmp__tabs {
height: 26px;
gap: 4px;
margin-bottom: 4px;
}
.tmp__tab {
padding: 0 4px;
font-size: 11px;
line-height: 26px;
}
.tmp__body {
height: 76px;
}
.tmp__card {
gap: 0;
padding: 4px;
}
.tmp__block {
height: 34px;
}
.tmp__name {
font-size: 10px;
line-height: 13px;
}
.tmp__price {
font-size: 9px;
line-height: 12px;
}
.tmp__badge {
font-size: 8px;
line-height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tmp.is-demo .tmp__panel,
.tmp.is-demo .tmp__caret,
.tmp.is-demo .tmp__set,
.tmp.is-demo .tmp__card,
.tmp.is-demo .tmp__tab,
.tmp.is-demo .tmp__tab::after {
animation: none !important;
}
.tmp.is-demo .tmp__panel {
opacity: 1;
visibility: visible;
transform: none;
}
.tmp.is-demo #set1,
.tmp.is-demo #set2 {
opacity: 0;
}
}var root = document.getElementById('root');
var trigger = document.getElementById('trigger');
var tablist = document.getElementById('tablist');
var tabs = [0, 1, 2].map(function (i) { return document.getElementById('tab' + i); });
var sets = [0, 1, 2].map(function (i) { return document.getElementById('set' + i); });
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
function stopDemo() { root.classList.remove('is-demo'); }
function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }
function select(i) {
tabs.forEach(function (t, k) {
t.setAttribute('aria-selected', k === i ? 'true' : 'false');
t.tabIndex = k === i ? 0 : -1;
sets[k].hidden = k !== i;
if (k === i && !root.classList.contains('is-demo')) {
sets[k].classList.remove('is-pop');
void sets[k].offsetWidth;
sets[k].classList.add('is-pop');
}
});
}
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
tablist.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('button') : null;
if (t) { stopDemo(); select(tabs.indexOf(t)); }
});
tablist.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
var i = tabs.indexOf(tablist.querySelector('[aria-selected="true"]'));
var n = e.key === 'ArrowRight' ? (i + 1) % 3 : (i + 2) % 3;
stopDemo();
select(n);
tabs[n].focus({ preventScroll: true });
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.addEventListener('pointerdown', stopDemo, { once: true });
document.addEventListener('keydown', stopDemo, { once: true });
select(0);import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./TabbedMegaPanel.module.scss";
export type TabbedCard = { name: string; price: string; badge?: string };
export type TabbedMegaPanelLabels = {
brand: string;
trigger: string;
tabsLabel: string;
tabs: string[];
cards: TabbedCard[][];
};
export type TabbedMegaPanelProps = {
labels?: TabbedMegaPanelLabels;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type BtnRef = (el: HTMLButtonElement | null) => void;
const DEFAULT_LABELS: TabbedMegaPanelLabels = {
brand: "Homeware",
trigger: "Shop",
tabsLabel: "Shop menu",
tabs: ["Popular", "New", "Sale"],
cards: [
[
{ name: "Wall shelf", price: "24,000" },
{ name: "Mini planter", price: "9,800" },
{ name: "Mood lamp", price: "16,500" },
],
[
{ name: "Bamboo desk", price: "89,000" },
{ name: "Glass cup set", price: "19,900" },
{ name: "Drawer unit", price: "42,000" },
],
[
{ name: "Rug", price: "27,300", badge: "30%" },
{ name: "Hanger", price: "31,200", badge: "20%" },
{ name: "Mirror", price: "15,400", badge: "45%" },
],
],
};
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 CardView({ card, j }: { card: TabbedCard; j: number; key?: string }) {
return (
<li className={styles.card}>
<span className={[styles.block, styles["block" + (j % 3)]].join(" ")}>
{card.badge ? <span className={styles.badge}>{card.badge}</span> : null}
</span>
<strong className={styles.name}>{card.name}</strong>
<span className={styles.price}>{card.price}</span>
</li>
);
}
export default function TabbedMegaPanel({
labels = DEFAULT_LABELS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TabbedMegaPanelProps) {
const [live, setLive] = useState(true);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) as Box<(HTMLButtonElement | null)[]>;
function wake() {
setLive(false);
}
function openPanel() {
wake();
setOpen(true);
}
function closePanel() {
wake();
setOpen(false);
}
function select(i: number) {
wake();
setTab(i);
}
function setTabRef(i: number): BtnRef {
return (el: HTMLButtonElement | null) => {
tabRefs.current[i] = el;
};
}
function onTabsKey(e: KeyEvt) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const next = e.key === "ArrowRight" ? (tab + 1) % 3 : (tab + 2) % 3;
select(next);
const btn = tabRefs.current[next];
if (btn) btn.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={[styles.tmp, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")}
style={vars}
onPointerDownCapture={wake}
onKeyDownCapture={wake}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") closePanel();
}}
>
<header className={styles.tmpBar}>
<span className={styles.tmpBrand}>{labels.brand}</span>
<button
type="button"
className={styles.tmpTrigger}
aria-expanded={open}
aria-haspopup="true"
onClick={() => (open ? closePanel() : openPanel())}
>
{labels.trigger}
<span className={styles.tmpCaret}>
<CaretDown />
</span>
</button>
</header>
<div className={styles.tmpPanel}>
<div className={styles.tmpTabs} role="tablist" aria-label={labels.tabsLabel} onKeyDown={onTabsKey}>
{labels.tabs.map((t, i) => (
<button
key={t}
type="button"
role="tab"
id={"tmp-tab-" + i}
aria-controls={"tmp-set-" + i}
className={live ? [styles.tab, styles["tab" + i]].join(" ") : styles.tab}
aria-selected={live ? undefined : tab === i}
tabIndex={live ? -1 : tab === i ? 0 : -1}
ref={setTabRef(i)}
onClick={() => select(i)}
>
{t}
</button>
))}
</div>
<div className={styles.tmpBody}>
{live ? (
labels.cards.map((set, i) => (
<ul
key={i}
id={"tmp-set-" + i}
role="tabpanel"
aria-labelledby={"tmp-tab-" + i}
className={[styles.set, styles["set" + i]].join(" ")}
>
{set.map((c, j) => (
<CardView card={c} j={j} key={c.name} />
))}
</ul>
))
) : (
<ul
key={tab}
id={"tmp-set-" + tab}
role="tabpanel"
aria-labelledby={"tmp-tab-" + tab}
className={styles.set}
>
{labels.cards[tab].map((c, j) => (
<CardView card={c} j={j} key={c.name} />
))}
</ul>
)}
</div>
</div>
</div>
);
}03 · Mega menu that swaps panels by tab — Measured area 17.594% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It fits store navigation that must show one category from a few angles.
function select(i) {
tabs.forEach(function (t, k) {
t.setAttribute('aria-selected', k === i ? 'true' : 'false');
t.tabIndex = k === i ? 0 : -1;
sets[k].hidden = k !== i;
if (k === i && !root.classList.contains('is-demo')) {
sets[k].classList.remove('is-pop');
void sets[k].offsetWidth;
sets[k].classList.add('is-pop');
}
});
}
04Header that folds smaller as you scroll
What you see
As the page scrolls past, the empty bar background and its contents shrink together, and the page is pulled up by that much, then it all restores on the way back. Instead of animating height, the empty bar folds with scaleY, the contents with scale, and the page with translateY, so one compacting pass costs zero reflow, and a single sentinel at the top of the feed decides the flip.

<main class="stage">
<div class="ns is-demo">
<header class="ns__head">
<div class="ns__inner">
<p class="ns__brand">Newsroom</p>
<nav class="ns__nav">
<a class="ns__link" href="#">Home</a>
<a class="ns__link" href="#">Business</a>
<a class="ns__link" href="#">Culture</a>
<a class="ns__link" href="#">Sports</a>
</nav>
</div>
</header>
<div class="ns__main">
<div class="ns__feed" tabindex="0">
<div class="ns__sentinel" aria-hidden="true"></div>
<div class="ns__track">
<article class="ns__card">
<h3 class="ns__t">Morning brief</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">Weekend weather</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">New books shelf</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">Local football</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">Metro delays</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">Night opening</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 격자 미리보기 루프 한 바퀴 2초
$easing: $ease-out; // 스크롤 관성 — 빠르게 시작해 천천히 멈춘다
$color: $subject-ink; // 노랑 무대 위 브랜드·기사 제목 글자
@include stage($stage-yellow);
.ns, .ns * { box-sizing: border-box; }
.ns { width: min(432px, 94vw); }
// ── 헤더 ── 배경은 비어 있는 ::before 막대가 담당한다(absolute, 좌우 0 위 0).
// 축소는 막대 scaleY · 내용 scale · 본문 translateY 셋 다 transform 이라 reflow 가 0 — 이게 이 데모의 원리.
.ns__head { position: relative; height: 48px; }
.ns__head::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: $subject-cream;
border-radius: 0 0 $r-sm $r-sm;
transform: scaleY(1);
transform-origin: top; // 규칙8: 헤더배경 top에서 줄어듦
transition: transform 220ms $ease-out;
}
.ns__inner {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 $sp-4;
transform: scale(1);
transform-origin: left center; // 규칙8: 왼쪽 브랜드 고정 축소
transition: transform 220ms $ease-out;
}
.ns__brand { margin: 0; color: $color; font-size: 19px; line-height: 24px; font-weight: 800; }
.ns__nav { display: flex; gap: $sp-3; }
.ns__link { color: rgba(23, 20, 26, .72); font-size: 12px; line-height: 16px; font-weight: 600; text-decoration: none; }
// ── 실제 동작: 센티넬이 화면에서 벗어나면 헤더에 .is-compact 가 붙는다(JS) ──
.ns__head.is-compact::before { transform: scaleY(.62); }
.ns__head.is-compact .ns__inner { transform: scale(.78); }
.ns__head.is-compact + .ns__main { transform: translateY(-14px); }
.ns__main { transition: transform 220ms $ease-out; }
.ns__feed {
height: min(200px, calc(100vh - 104px));
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.ns__sentinel { height: 1px; }
.ns__track { display: grid; gap: $sp-2; padding: $sp-2; }
.ns__card { padding: $sp-2 $sp-3; background: $subject-cream; border-radius: $r-sm; }
.ns__t { margin: 0 0 $sp-1; color: $color; font-size: 13px; line-height: 18px; font-weight: 700; }
.ns__bar { display: block; height: 6px; margin-top: $sp-1; border-radius: $r-xs; background: rgba(23, 20, 26, .14); }
.ns__bar--s { width: 72%; }
// ── 격자 미리보기 루프 ──
// 움직이는 건 안쪽 트랙뿐 — 스크롤 상자(viewport)에 transform 을 걸면
// 100% 기준이 상자 폭이 되어 내용이 통째로 날아간다(051 실측).
// 헤더 축소는 steps(1, end) 컷 — 반투명하게 겹치는 중간 프레임이 없다.
// 0~36% 평화 → 40% 컷(축소가 최대 변화 프레임) → 74% 유지 → 78~88% 원상복귀.
.ns.is-demo .ns__track {
animation-name: ns-scroll;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ns.is-demo .ns__head::before,
.ns.is-demo .ns__inner,
.ns.is-demo .ns__main {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ns.is-demo .ns__head::before { animation-name: ns-cut-bar; }
.ns.is-demo .ns__inner { animation-name: ns-cut-inner; }
.ns.is-demo .ns__main { animation-name: ns-cut-main; }
@keyframes ns-scroll {
0%, 36% { transform: translateY(0); }
74%, 78% { transform: translateY(-132px); }
88%, 100% { transform: translateY(0); }
}
@keyframes ns-cut-bar {
0% { transform: scaleY(1); }
40% { transform: scaleY(.62); }
78%, 100% { transform: scaleY(1); }
}
@keyframes ns-cut-inner {
0% { transform: scale(1); }
40% { transform: scale(.78); }
78%, 100% { transform: scale(1); }
}
@keyframes ns-cut-main {
0% { transform: translateY(0); }
40% { transform: translateY(-14px); }
78%, 100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.ns__inner { padding: 0 $sp-3; }
.ns__brand { font-size: 17px; line-height: 22px; }
.ns__nav { gap: $sp-2; }
.ns__link { font-size: 11px; }
.ns__card { padding: $sp-2; }
.ns__t { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.ns.is-demo .ns__track,
.ns.is-demo .ns__head::before,
.ns.is-demo .ns__inner,
.ns.is-demo .ns__main { animation: none !important; }
.ns.is-demo .ns__track { transform: none !important; }
.ns__head::before, .ns__inner, .ns__main { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.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);
}
.ns, .ns * {
box-sizing: border-box;
}
.ns {
width: min(432px, 94vw);
}
.ns__head {
position: relative;
height: 48px;
}
.ns__head::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff7e6;
border-radius: 0 0 8px 8px;
transform: scaleY(1);
transform-origin: top;
transition: transform 220ms ease-out;
}
.ns__inner {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 16px;
transform: scale(1);
transform-origin: left center;
transition: transform 220ms ease-out;
}
.ns__brand {
margin: 0;
color: #17141a;
font-size: 19px;
line-height: 24px;
font-weight: 800;
}
.ns__nav {
display: flex;
gap: 12px;
}
.ns__link {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
line-height: 16px;
font-weight: 600;
text-decoration: none;
}
.ns__head.is-compact::before {
transform: scaleY(0.62);
}
.ns__head.is-compact .ns__inner {
transform: scale(0.78);
}
.ns__head.is-compact + .ns__main {
transform: translateY(-14px);
}
.ns__main {
transition: transform 220ms ease-out;
}
.ns__feed {
height: min(200px, 100vh - 104px);
overflow-y: auto;
scrollbar-width: none;
}
.ns__feed::-webkit-scrollbar {
display: none;
}
.ns__sentinel {
height: 1px;
}
.ns__track {
display: grid;
gap: 8px;
padding: 8px;
}
.ns__card {
padding: 8px 12px;
background: #fff7e6;
border-radius: 8px;
}
.ns__t {
margin: 0 0 4px;
color: #17141a;
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
.ns__bar {
display: block;
height: 6px;
margin-top: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.ns__bar--s {
width: 72%;
}
.ns.is-demo .ns__track {
animation-name: ns-scroll;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ns.is-demo .ns__head::before,
.ns.is-demo .ns__inner,
.ns.is-demo .ns__main {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ns.is-demo .ns__head::before {
animation-name: ns-cut-bar;
}
.ns.is-demo .ns__inner {
animation-name: ns-cut-inner;
}
.ns.is-demo .ns__main {
animation-name: ns-cut-main;
}
@keyframes ns-scroll {
0%, 36% {
transform: translateY(0);
}
74%, 78% {
transform: translateY(-132px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ns-cut-bar {
0% {
transform: scaleY(1);
}
40% {
transform: scaleY(0.62);
}
78%, 100% {
transform: scaleY(1);
}
}
@keyframes ns-cut-inner {
0% {
transform: scale(1);
}
40% {
transform: scale(0.78);
}
78%, 100% {
transform: scale(1);
}
}
@keyframes ns-cut-main {
0% {
transform: translateY(0);
}
40% {
transform: translateY(-14px);
}
78%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.ns__inner {
padding: 0 12px;
}
.ns__brand {
font-size: 17px;
line-height: 22px;
}
.ns__nav {
gap: 8px;
}
.ns__link {
font-size: 11px;
}
.ns__card {
padding: 8px;
}
.ns__t {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ns.is-demo .ns__track,
.ns.is-demo .ns__head::before,
.ns.is-demo .ns__inner,
.ns.is-demo .ns__main {
animation: none !important;
}
.ns.is-demo .ns__track {
transform: none !important;
}
.ns__head::before, .ns__inner, .ns__main {
transition: none !important;
}
}// 실제 동작: 스크롤 상자 맨 위의 센티넬이 화면에서 벗어나는 순간이 축소 신호다.
// 축소는 전부 transform 이라 리플로우가 0이다.
var ns = document.querySelector('.ns');
var feed = ns.querySelector('.ns__feed');
var head = ns.querySelector('.ns__head');
function stopDemo() { ns.classList.remove('is-demo'); } // 사람이 만졌으니 자동 루프를 끈다
ns.addEventListener('pointerdown', stopDemo);
feed.addEventListener('scroll', stopDemo, { passive: true });
new IntersectionObserver(function (entries) {
head.classList.toggle('is-compact', !entries[0].isIntersecting);
}, { root: feed }).observe(ns.querySelector('.ns__sentinel'));import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ShrinkScrollHeader.module.scss";
export type ShrinkScrollHeaderProps = {
labels?: { brand?: string; nav?: string[]; articles?: string[] };
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
const NAV = ["Home", "Business", "Culture", "Sports"];
const ARTICLES = ["Morning briefing", "Weekend weather", "New book shelf", "Neighborhood soccer", "Subway delays", "Night opening"];
export default function ShrinkScrollHeader({
labels,
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: ShrinkScrollHeaderProps) {
const brand = labels?.brand ?? "Newsroom";
const nav = labels?.nav ?? NAV;
const articles = labels?.articles ?? ARTICLES;
const [live, setLive] = useState(demo);
const [compact, setCompact] = useState(false);
const feedRef: Box<HTMLDivElement> = useRef(null);
const sentRef: Box<HTMLDivElement> = useRef(null);
// 센티넬이 스크롤 상자 밖으로 나가는 순간이 축소 신호다 — 축소는 전부 transform
useEffect(() => {
const feed = feedRef.current;
const sent = sentRef.current;
if (feed === null || sent === null) return;
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => setCompact(!e.isIntersecting)),
{ root: feed }
);
io.observe(sent);
return () => io.disconnect();
}, []);
function touch() {
setLive(false); // 사람이 만졌으니 자동 루프를 끈다
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ns, live ? styles.isDemo : "", compact ? styles.isCompact : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} onPointerDown={touch}>
<header className={styles.head}>
<div className={styles.inner}>
<p className={styles.brand}>{brand}</p>
<nav className={styles.nav}>
{nav.map((item) => (
<a className={styles.link} key={item} href="#">{item}</a>
))}
</nav>
</div>
</header>
<div className={styles.main}>
<div className={styles.feed} ref={feedRef} tabIndex={0} onScroll={touch}>
<div className={styles.sentinel} aria-hidden="true" ref={sentRef} />
<div className={styles.track}>
{articles.map((t) => (
<article className={styles.card} key={t}>
<h3 className={styles.t}>{t}</h3>
<i className={styles.bar} />
<i className={styles.barS} />
</article>
))}
</div>
</div>
</div>
</section>
);
}04 · Header that folds smaller as you scroll — Measured area 63.282% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It belongs on long-scrolling lists and magazine-style pages.
// The whole shrink is transforms, so reflow stays at zero.
var ns = document.querySelector('.ns');
var feed = ns.querySelector('.ns__feed');
var head = ns.querySelector('.ns__head');
new IntersectionObserver(function (entries) {
head.classList.toggle('is-compact', !entries[0].isIntersecting);
}, { root: feed }).observe(ns.querySelector('.ns__sentinel'));
05Sticky sub-nav that announces the current section
What happens on screen
While sections scroll past, the category bar stays pinned, and whenever the section on screen changes, the highlight moves to its chip. An IntersectionObserver rooted at the scroll box picks whichever section sits closest to the center of the view and rewrites the chips' aria-current.

<main class="stage">
<div class="ss is-demo">
<div class="ss__win">
<div class="ss__doc">
<div class="ss__docHead">
<h2 class="ss__title">Manual</h2>
</div>
<nav class="ss__bar">
<a class="ss__chip" href="#sec-1">Start</a>
<a class="ss__chip" href="#sec-2">Settings</a>
<a class="ss__chip" href="#sec-3">Alerts</a>
<a class="ss__chip" href="#sec-4">Share</a>
<a class="ss__chip" href="#sec-5">Extend</a>
</nav>
<div class="ss__track">
<section class="ss__sec" id="sec-1">
<h3 class="ss__h">Start</h3>
<i class="ss__line"></i><i class="ss__line ss__line--a"></i><i class="ss__line ss__line--b"></i><i class="ss__line ss__line--c"></i>
</section>
<section class="ss__sec" id="sec-2">
<h3 class="ss__h">Settings</h3>
<i class="ss__line"></i><i class="ss__line ss__line--a"></i><i class="ss__line ss__line--b"></i><i class="ss__line ss__line--c"></i>
</section>
<section class="ss__sec" id="sec-3">
<h3 class="ss__h">Alerts</h3>
<i class="ss__line"></i><i class="ss__line ss__line--a"></i><i class="ss__line ss__line--b"></i><i class="ss__line ss__line--c"></i>
</section>
<section class="ss__sec" id="sec-4">
<h3 class="ss__h">Share</h3>
<i class="ss__line"></i><i class="ss__line ss__line--a"></i><i class="ss__line ss__line--b"></i><i class="ss__line ss__line--c"></i>
</section>
<section class="ss__sec" id="sec-5">
<h3 class="ss__h">Extend</h3>
<i class="ss__line"></i><i class="ss__line ss__line--a"></i><i class="ss__line ss__line--b"></i><i class="ss__line ss__line--c"></i>
</section>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 격자 미리보기 루프 한 바퀴 2초
$easing: linear; // 칩 컷과 섹션이 같은 속도로 지나가야 하이라이트 타이밍이 맞는다
$color: $subject-ink; // 크림 카드 위 글자 · 현재 칩 배경
@include stage($stage-orange);
.ss, .ss * { box-sizing: border-box; }
.ss { width: min(420px, 94vw); }
// 스크롤 상자 = 크림 카드 자체. 안에서만 스크롤이 일어난다.
.ss__win {
height: clamp(160px, 84vh, 272px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
border-radius: $r-card;
background: $subject-cream;
color: $color;
scroll-behavior: smooth;
}
// sticky 바는 부모(.ss__doc) 높이만큼만 붙어 있을 수 있다 — 부모가 콘텐츠 전체를 감싼다(260 실측).
.ss__doc { position: relative; }
// 제목 영역 높이 46px(16 + 22 + 8) — 아래 루프 ss-bar 의 시작 오프셋과 같은 값
.ss__docHead { padding: $sp-4 $sp-4 $sp-2; }
.ss__title { margin: 0; font-size: 16px; line-height: 22px; font-weight: 800; }
.ss__bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
gap: $sp-1;
padding: $sp-2 $sp-4;
background: $subject-cream;
border-bottom: 1px solid rgba(23, 20, 26, .08);
}
.ss__chip {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
color: rgba(23, 20, 26, .7);
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-decoration: none;
transition: background-color 140ms $ease-out, color 140ms $ease-out;
}
.ss__chip[aria-current="true"] { background: $color; color: $subject-cream; }
.ss__track { display: grid; gap: $sp-3; padding: $sp-3 $sp-4 $sp-12; }
.ss__sec { min-height: 96px; padding: $sp-3; border: 1px solid rgba(23, 20, 26, .08); border-radius: $r-sm; background: rgba(255, 255, 255, .5); }
.ss__h { margin: 0 0 $sp-2; font-size: 13px; line-height: 18px; font-weight: 700; }
.ss__line { display: block; height: 6px; margin-top: $sp-1; border-radius: $r-xs; background: rgba(23, 20, 26, .12); }
.ss__line--a { width: 92%; }
.ss__line--b { width: 78%; }
.ss__line--c { width: 60%; }
// ── 격자 미리보기 루프 ──
// .is-demo 동안은 스크롤이 0 이라 sticky 가 안 걸린다 — 바를 absolute 로 무대 위에 고정하고
// 트랙(제목+섹션 묶음)을 translateY 로 흘려 스크롤을 흉내낸다(051: 상자가 아니라 트랙).
// 제목도 트랙과 함께 흘러가고 바만 남는다.
.ss.is-demo .ss__bar {
position: absolute;
top: 0; left: 0; right: 0;
animation-name: ss-bar;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ss.is-demo .ss__docHead,
.ss.is-demo .ss__track {
animation-name: ss-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 칩 하이라이트는 steps(1, end) 컷으로 옮겨간다(이동 아님) — 섹션이 중앙을 지나는 타이밍과 맞춤
.ss.is-demo .ss__chip {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__chip:nth-child(1) { animation-name: ss-cur-1; }
.ss.is-demo .ss__chip:nth-child(2) { animation-name: ss-cur-2; }
.ss.is-demo .ss__chip:nth-child(3) { animation-name: ss-cur-3; }
.ss.is-demo .ss__chip:nth-child(4) { animation-name: ss-cur-4; }
.ss.is-demo .ss__chip:nth-child(5) { animation-name: ss-cur-5; }
@keyframes ss-flow {
0%, 6% { transform: translateY(0); }
74%, 78% { transform: translateY(-420px); }
88%, 100% { transform: translateY(0); }
}
@keyframes ss-bar {
0%, 6% { transform: translateY(46px); } // 제목 아래 본래 위치
16% { transform: translateY(0); } // 제목이 흘러가면 무대 위에 붙는다
78% { transform: translateY(0); }
88%, 100% { transform: translateY(46px); }
}
// 시작→설정→알림→공유→확장 순서대로 하이라이트가 컷 이동, 88% 안에 복귀
@keyframes ss-cur-1 { 0% { background: $color; color: $subject-cream; } 18%, 100% { background: transparent; color: rgba(23, 20, 26, .7); } }
@keyframes ss-cur-2 { 0% { background: transparent; color: rgba(23, 20, 26, .7); } 18% { background: $color; color: $subject-cream; } 34%, 100% { background: transparent; color: rgba(23, 20, 26, .7); } }
@keyframes ss-cur-3 { 0% { background: transparent; color: rgba(23, 20, 26, .7); } 34% { background: $color; color: $subject-cream; } 50%, 100% { background: transparent; color: rgba(23, 20, 26, .7); } }
@keyframes ss-cur-4 { 0% { background: transparent; color: rgba(23, 20, 26, .7); } 50% { background: $color; color: $subject-cream; } 66%, 100% { background: transparent; color: rgba(23, 20, 26, .7); } }
@keyframes ss-cur-5 { 0% { background: transparent; color: rgba(23, 20, 26, .7); } 66% { background: $color; color: $subject-cream; } 80%, 100% { background: transparent; color: rgba(23, 20, 26, .7); } }
@media (max-width: 340px) {
.ss__bar { gap: 0; padding: $sp-2 $sp-3; }
.ss__chip { padding: $sp-1 $sp-2; font-size: 11px; }
.ss__sec { min-height: 84px; padding: $sp-2; }
.ss__track { gap: $sp-2; padding: $sp-2 $sp-3 $sp-10; }
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__bar,
.ss.is-demo .ss__docHead,
.ss.is-demo .ss__track,
.ss.is-demo .ss__chip { animation: none !important; }
// 정적 상태에서도 바가 제목 아래 붙어 있는 모습이 그대로 보인다(진짜 sticky 로 복귀)
.ss.is-demo .ss__bar { position: sticky; top: 0; transform: none !important; }
.ss.is-demo .ss__docHead, .ss.is-demo .ss__track { transform: none !important; }
.ss__win { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ss, .ss * {
box-sizing: border-box;
}
.ss {
width: min(420px, 94vw);
}
.ss__win {
height: clamp(160px, 84vh, 272px);
overflow-y: auto;
scrollbar-width: none;
}
.ss__win::-webkit-scrollbar {
display: none;
}
.ss__win {
border-radius: 16px;
background: #fff7e6;
color: #17141a;
scroll-behavior: smooth;
}
.ss__doc {
position: relative;
}
.ss__docHead {
padding: 16px 16px 8px;
}
.ss__title {
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 800;
}
.ss__bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
gap: 4px;
padding: 8px 16px;
background: #fff7e6;
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.ss__chip {
padding: 4px 12px;
border-radius: 999px;
color: rgba(23, 20, 26, 0.7);
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-decoration: none;
transition: background-color 140ms ease-out, color 140ms ease-out;
}
.ss__chip[aria-current=true] {
background: #17141a;
color: #fff7e6;
}
.ss__track {
display: grid;
gap: 12px;
padding: 12px 16px 48px;
}
.ss__sec {
min-height: 96px;
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
}
.ss__h {
margin: 0 0 8px;
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
.ss__line {
display: block;
height: 6px;
margin-top: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.ss__line--a {
width: 92%;
}
.ss__line--b {
width: 78%;
}
.ss__line--c {
width: 60%;
}
.ss.is-demo .ss__bar {
position: absolute;
top: 0;
left: 0;
right: 0;
animation-name: ss-bar;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ss.is-demo .ss__docHead,
.ss.is-demo .ss__track {
animation-name: ss-flow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ss.is-demo .ss__chip {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__chip:nth-child(1) {
animation-name: ss-cur-1;
}
.ss.is-demo .ss__chip:nth-child(2) {
animation-name: ss-cur-2;
}
.ss.is-demo .ss__chip:nth-child(3) {
animation-name: ss-cur-3;
}
.ss.is-demo .ss__chip:nth-child(4) {
animation-name: ss-cur-4;
}
.ss.is-demo .ss__chip:nth-child(5) {
animation-name: ss-cur-5;
}
@keyframes ss-flow {
0%, 6% {
transform: translateY(0);
}
74%, 78% {
transform: translateY(-420px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ss-bar {
0%, 6% {
transform: translateY(46px);
}
16% {
transform: translateY(0);
}
78% {
transform: translateY(0);
}
88%, 100% {
transform: translateY(46px);
}
}
@keyframes ss-cur-1 {
0% {
background: #17141a;
color: #fff7e6;
}
18%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes ss-cur-2 {
0% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
18% {
background: #17141a;
color: #fff7e6;
}
34%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes ss-cur-3 {
0% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
34% {
background: #17141a;
color: #fff7e6;
}
50%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes ss-cur-4 {
0% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
50% {
background: #17141a;
color: #fff7e6;
}
66%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes ss-cur-5 {
0% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
66% {
background: #17141a;
color: #fff7e6;
}
80%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.7);
}
}
@media (max-width: 340px) {
.ss__bar {
gap: 0;
padding: 8px 12px;
}
.ss__chip {
padding: 4px 8px;
font-size: 11px;
}
.ss__sec {
min-height: 84px;
padding: 8px;
}
.ss__track {
gap: 8px;
padding: 8px 12px 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__bar,
.ss.is-demo .ss__docHead,
.ss.is-demo .ss__track,
.ss.is-demo .ss__chip {
animation: none !important;
}
.ss.is-demo .ss__bar {
position: sticky;
top: 0;
transform: none !important;
}
.ss.is-demo .ss__docHead, .ss.is-demo .ss__track {
transform: none !important;
}
.ss__win {
scroll-behavior: auto;
}
}// 실제 동작: 화면(스크롤 상자) 중앙에 가장 가까운 섹션이 현재 칩이 된다.
var ss = document.querySelector('.ss');
var win = ss.querySelector('.ss__win');
var chips = Array.prototype.slice.call(ss.querySelectorAll('.ss__chip'));
var secs = Array.prototype.slice.call(ss.querySelectorAll('.ss__sec'));
function stopDemo() { ss.classList.remove('is-demo'); } // 사람이 만졌으니 자동 루프를 끈다
ss.addEventListener('pointerdown', stopDemo);
win.addEventListener('scroll', stopDemo, { passive: true });
function mark(el) {
chips.forEach(function (c) {
c.setAttribute('aria-current', c.getAttribute('href') === '#' + el.id ? 'true' : 'false');
});
}
var io = new IntersectionObserver(function (entries) {
var w = win.getBoundingClientRect();
var mid = w.top + w.height / 2;
var best = null, gap = Infinity;
entries.forEach(function (e) {
if (!e.isIntersecting) return;
var r = e.boundingClientRect;
var d = Math.abs((r.top + r.bottom) / 2 - mid);
if (d < gap) { gap = d; best = e.target; }
});
if (best) mark(best);
}, { root: win, threshold: 0.5 });
secs.forEach(function (s) { io.observe(s); });
mark(secs[0]);import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StickySubnav.module.scss";
export type StickySubnavProps = {
labels?: { title?: string; chips?: string[] };
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
const CHIPS = ["Start", "Settings", "Alerts", "Sharing", "Extend"];
export default function StickySubnav({
labels,
demo = true,
duration = 2000,
easing = "linear",
color = "#17141a",
}: StickySubnavProps) {
const title = labels?.title ?? "Manual";
const chips = labels?.chips ?? CHIPS;
const [live, setLive] = useState(demo);
const [cur, setCur] = useState(0);
const winRef: Box<HTMLDivElement> = useRef(null);
const trackRef: Box<HTMLDivElement> = useRef(null);
// 화면(스크롤 상자) 중앙에 가장 가까운 섹션이 현재 칩이 된다
useEffect(() => {
const win = winRef.current;
const track = trackRef.current;
if (win === null || track === null) return;
const secs = Array.from(track.children) as HTMLElement[];
const io = new IntersectionObserver(
(entries) => {
const r = win.getBoundingClientRect();
const mid = r.top + r.height / 2;
let best: HTMLElement | null = null;
let gap = Infinity;
entries.forEach((e) => {
if (!e.isIntersecting) return;
const b = e.boundingClientRect;
const d = Math.abs((b.top + b.bottom) / 2 - mid);
if (d < gap) {
gap = d;
best = e.target as HTMLElement;
}
});
if (best !== null) setCur(secs.indexOf(best));
},
{ root: win, threshold: 0.5 }
);
secs.forEach((s) => io.observe(s));
return () => io.disconnect();
}, []);
function touch() {
setLive(false); // 사람이 만졌으니 자동 루프를 끈다
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ss, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} onPointerDown={touch}>
<div className={styles.win} ref={winRef} onScroll={touch}>
<div className={styles.doc}>
<div className={styles.docHead}>
<h2 className={styles.title}>{title}</h2>
</div>
<nav className={styles.bar}>
{chips.map((c, i) => (
<a
className={styles.chip}
key={c}
href={`#sec-${i + 1}`}
aria-current={i === cur ? "true" : "false"}
>
{c}
</a>
))}
</nav>
<div className={styles.track} ref={trackRef}>
{chips.map((c, i) => (
<section className={styles.sec} key={c} id={`sec-${i + 1}`}>
<h3 className={styles.h}>{c}</h3>
<i className={styles.line} />
<i className={`${styles.line} ${styles.lineA}`} />
<i className={`${styles.line} ${styles.lineB}`} />
<i className={`${styles.line} ${styles.lineC}`} />
</section>
))}
</div>
</div>
</div>
</section>
);
}05 · Sticky sub-nav that announces the current section — Measured area 66.211% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Manuals and landing pages where one page holds several sections are its home.
var io = new IntersectionObserver(function (entries) {
var w = win.getBoundingClientRect();
var mid = w.top + w.height / 2;
var best = null, gap = Infinity;
entries.forEach(function (e) {
if (!e.isIntersecting) return;
var r = e.boundingClientRect;
var d = Math.abs((r.top + r.bottom) / 2 - mid);
if (d < gap) { gap = d; best = e.target; }
});
if (best) mark(best);
}, { root: win, threshold: 0.5 });
06Fullscreen overlay where the burger folds into an X
What moves
The three lines swing apart and fold into an X, and links climb one by one onto the sheet that covers the whole screen. While the sheet is open, the keyboard focus stays inside it.

<main class="stage">
<div class="fm is-demo">
<div class="fm__page" aria-hidden="true">
<i class="fm__block fm__block--a"></i>
<i class="fm__block fm__block--b"></i>
<i class="fm__rule"></i>
<i class="fm__rule fm__rule--s"></i>
<i class="fm__rule fm__rule--m"></i>
</div>
<header class="fm__bar">
<span class="fm__brand">Studio</span>
<button class="fm__btn" type="button" aria-controls="fm-menu">
<span class="fm__burger" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
</header>
<div class="fm__overlay" id="fm-menu" role="dialog" aria-modal="true" aria-label="Main menu">
<a class="fm__link" style="--i: 0" href="#">Work
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</a>
<a class="fm__link" style="--i: 1" href="#">About
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</a>
<a class="fm__link" style="--i: 2" href="#">Log
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</a>
<a class="fm__link" style="--i: 3" href="#">Contact
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</a>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 격자 미리보기 루프 한 바퀴 2초
$easing: $ease-pop; // 햄버거→X 모프와 오버레이 등장의 탄성
$color: $subject-cream; // 잉크 무대 위 브랜드·막대·큰 링크
@include stage($stage-ink);
.fm, .fm * { box-sizing: border-box; }
.fm {
position: relative;
width: min(440px, 94vw);
height: clamp(168px, 84vh, 264px);
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 247, 230, .04);
}
// ── 평소 화면: 상단 바 + 텍스트 없는 장식 조각 ──
.fm__bar {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3 $sp-4;
}
.fm__brand { color: $color; font-size: 16px; line-height: 22px; font-weight: 800; transition: opacity 200ms linear; }
.fm.is-open .fm__brand { opacity: 0; } // 열렸을 때는 X 닫기 버튼만 남는다
.fm__btn {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
background: rgba(255, 247, 230, .1);
cursor: pointer;
}
.fm__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 햄버거는 아이콘이 아니라 막대 3개 span — 그 자체가 X 로 모프한다
.fm__burger { display: grid; gap: $sp-1; }
.fm__burger span {
width: 20px;
height: 2px;
border-radius: $r-pill;
background: $color;
transition: transform 300ms $ease-pop, opacity 160ms linear;
}
.fm.is-open .fm__burger span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.fm.is-open .fm__burger span:nth-child(2) { transform: scaleX(0); opacity: 0; }
.fm.is-open .fm__burger span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
// 페이지 장식 — 블록 두 개와 회색 막대 세 줄
.fm__page { display: flex; flex-direction: column; gap: $sp-2; padding: 0 $sp-4 $sp-3; }
.fm__block { border-radius: $r-sm; background: rgba(255, 247, 230, .1); }
.fm__block--a { height: clamp(40px, 20vh, 84px); }
.fm__block--b { height: clamp(20px, 10vh, 44px); background: rgba(255, 247, 230, .06); }
.fm__rule { height: 8px; border-radius: $r-xs; background: rgba(255, 247, 230, .14); }
.fm__rule--s { width: 64%; }
.fm__rule--m { width: 44%; }
// ── 풀스크린 오버레이 ── 겹침 크로스페이드가 아니라 새 레이어 등장(scale 1.06→1 + opacity).
// 닫힌 상태는 visibility: hidden + inert(JS) 로 포커스·탐색에서도 밖으로 내보낸다.
.fm__overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-3;
padding: 0 $sp-8;
background: #241f2d; // 잉크 무대보다 살짝 밝은 오버레이
visibility: hidden;
opacity: 0;
transform: scale(1.06);
transition: opacity 260ms $ease-out, transform 320ms $ease-pop, visibility 0s linear 320ms;
}
.fm.is-open .fm__overlay {
visibility: visible;
opacity: 1;
transform: scale(1);
transition: opacity 260ms $ease-out, transform 320ms $ease-pop, visibility 0s;
}
.fm__link {
display: inline-flex;
align-items: center;
gap: $sp-2;
color: $color;
font-size: 24px;
line-height: 32px;
font-weight: 800;
text-decoration: none;
opacity: 0;
transform: translateY(18px);
transition: opacity 260ms $ease-out, transform 300ms $ease-pop;
}
.fm__link svg { opacity: .6; }
.fm__link:focus-visible { outline: 2px solid $color; outline-offset: 4px; border-radius: $r-xs; }
// 열릴 때만 시차 등장(--i × 80ms). 닫힘은 지연 없이 한꺼번에 — 퇴장은 짧게.
.fm.is-open .fm__link { opacity: 1; transform: translateY(0); transition-delay: calc(var(--i) * 80ms); }
// ── 격자 미리보기 루프 ──
// 0~7% 닫힘 → 8% 모프+오버레이 등장 → 12~48% 링크 시차 등장(등장이 주인공) →
// 72% 유지 → 76% 닫힘(짧게, 막대 역모프) → 84%부터 닫힌 채 대기.
.fm.is-demo .fm__overlay {
visibility: visible;
animation-name: fm-ov;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fm.is-demo .fm__burger span,
.fm.is-demo .fm__link {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fm.is-demo .fm__burger span:nth-child(1) { animation-name: fm-b1; }
.fm.is-demo .fm__burger span:nth-child(2) { animation-name: fm-b2; }
.fm.is-demo .fm__burger span:nth-child(3) { animation-name: fm-b3; }
.fm.is-demo .fm__link:nth-child(1) { animation-name: fm-l1; }
.fm.is-demo .fm__link:nth-child(2) { animation-name: fm-l2; }
.fm.is-demo .fm__link:nth-child(3) { animation-name: fm-l3; }
.fm.is-demo .fm__link:nth-child(4) { animation-name: fm-l4; }
@keyframes fm-ov {
0%, 8.3% { opacity: 0; transform: scale(1.06); }
8.4%, 74% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(1); }
}
@keyframes fm-b1 {
0%, 8.3% { transform: translateY(0) rotate(0deg); }
18%, 78% { transform: translateY(6px) rotate(45deg); }
88%, 100% { transform: translateY(0) rotate(0deg); }
}
@keyframes fm-b2 {
0%, 8.3% { opacity: 1; transform: scaleX(1); }
18%, 78% { opacity: 0; transform: scaleX(0); }
88%, 100% { opacity: 1; transform: scaleX(1); }
}
@keyframes fm-b3 {
0%, 8.3% { transform: translateY(0) rotate(0deg); }
18%, 78% { transform: translateY(-6px) rotate(-45deg); }
88%, 100% { transform: translateY(0) rotate(0deg); }
}
@keyframes fm-l1 { 0%, 12% { opacity: 0; transform: translateY(18px); } 24%, 100% { opacity: 1; transform: translateY(0); } }
@keyframes fm-l2 { 0%, 20% { opacity: 0; transform: translateY(18px); } 32%, 100% { opacity: 1; transform: translateY(0); } }
@keyframes fm-l3 { 0%, 28% { opacity: 0; transform: translateY(18px); } 40%, 100% { opacity: 1; transform: translateY(0); } }
@keyframes fm-l4 { 0%, 36% { opacity: 0; transform: translateY(18px); } 48%, 100% { opacity: 1; transform: translateY(0); } }
@media (max-width: 340px) {
.fm__bar { padding: $sp-2 $sp-3; }
.fm__brand { font-size: 14px; line-height: 20px; }
.fm__btn { width: 36px; height: 36px; }
.fm__page { gap: $sp-1; padding: 0 $sp-3 $sp-2; }
.fm__rule { height: 6px; }
.fm__overlay { gap: $sp-2; padding: 0 $sp-5; }
.fm__link { font-size: 19px; line-height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.fm.is-demo .fm__overlay,
.fm.is-demo .fm__burger span,
.fm.is-demo .fm__link { animation: none !important; }
// 정적 상태에서 주인공(열린 메뉴)이 보이도록 연 채 둔다
.fm.is-demo .fm__overlay { visibility: visible !important; opacity: 1 !important; transform: none !important; }
.fm.is-demo .fm__link { opacity: 1 !important; transform: none !important; }
.fm.is-demo .fm__burger span:nth-child(1) { transform: translateY(6px) rotate(45deg) !important; }
.fm.is-demo .fm__burger span:nth-child(2) { opacity: 0 !important; transform: scaleX(0) !important; }
.fm.is-demo .fm__burger span:nth-child(3) { transform: translateY(-6px) rotate(-45deg) !important; }
.fm.is-demo .fm__brand { opacity: 0 !important; }
.fm__burger span, .fm__overlay, .fm__link, .fm__brand { transition: none !important; transition-delay: 0ms !important; }
}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);
}
.fm, .fm * {
box-sizing: border-box;
}
.fm {
position: relative;
width: min(440px, 94vw);
height: clamp(168px, 84vh, 264px);
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
}
.fm__bar {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.fm__brand {
color: #fff7e6;
font-size: 16px;
line-height: 22px;
font-weight: 800;
transition: opacity 200ms linear;
}
.fm.is-open .fm__brand {
opacity: 0;
}
.fm__btn {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.1);
cursor: pointer;
}
.fm__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.fm__burger {
display: grid;
gap: 4px;
}
.fm__burger span {
width: 20px;
height: 2px;
border-radius: 999px;
background: #fff7e6;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 160ms linear;
}
.fm.is-open .fm__burger span:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
.fm.is-open .fm__burger span:nth-child(2) {
transform: scaleX(0);
opacity: 0;
}
.fm.is-open .fm__burger span:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
.fm__page {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 12px;
}
.fm__block {
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
}
.fm__block--a {
height: clamp(40px, 20vh, 84px);
}
.fm__block--b {
height: clamp(20px, 10vh, 44px);
background: rgba(255, 247, 230, 0.06);
}
.fm__rule {
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.14);
}
.fm__rule--s {
width: 64%;
}
.fm__rule--m {
width: 44%;
}
.fm__overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 0 32px;
background: #241f2d;
visibility: hidden;
opacity: 0;
transform: scale(1.06);
transition: opacity 260ms ease-out, transform 320ms cubic-bezier(0.2, 1.4, 0.4, 1), visibility 0s linear 320ms;
}
.fm.is-open .fm__overlay {
visibility: visible;
opacity: 1;
transform: scale(1);
transition: opacity 260ms ease-out, transform 320ms cubic-bezier(0.2, 1.4, 0.4, 1), visibility 0s;
}
.fm__link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 24px;
line-height: 32px;
font-weight: 800;
text-decoration: none;
opacity: 0;
transform: translateY(18px);
transition: opacity 260ms ease-out, transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fm__link svg {
opacity: 0.6;
}
.fm__link:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 4px;
border-radius: 4px;
}
.fm.is-open .fm__link {
opacity: 1;
transform: translateY(0);
transition-delay: calc(var(--i) * 80ms);
}
.fm.is-demo .fm__overlay {
visibility: visible;
animation-name: fm-ov;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fm.is-demo .fm__burger span,
.fm.is-demo .fm__link {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fm.is-demo .fm__burger span:nth-child(1) {
animation-name: fm-b1;
}
.fm.is-demo .fm__burger span:nth-child(2) {
animation-name: fm-b2;
}
.fm.is-demo .fm__burger span:nth-child(3) {
animation-name: fm-b3;
}
.fm.is-demo .fm__link:nth-child(1) {
animation-name: fm-l1;
}
.fm.is-demo .fm__link:nth-child(2) {
animation-name: fm-l2;
}
.fm.is-demo .fm__link:nth-child(3) {
animation-name: fm-l3;
}
.fm.is-demo .fm__link:nth-child(4) {
animation-name: fm-l4;
}
@keyframes fm-ov {
0%, 8.3% {
opacity: 0;
transform: scale(1.06);
}
8.4%, 74% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(1);
}
}
@keyframes fm-b1 {
0%, 8.3% {
transform: translateY(0) rotate(0deg);
}
18%, 78% {
transform: translateY(6px) rotate(45deg);
}
88%, 100% {
transform: translateY(0) rotate(0deg);
}
}
@keyframes fm-b2 {
0%, 8.3% {
opacity: 1;
transform: scaleX(1);
}
18%, 78% {
opacity: 0;
transform: scaleX(0);
}
88%, 100% {
opacity: 1;
transform: scaleX(1);
}
}
@keyframes fm-b3 {
0%, 8.3% {
transform: translateY(0) rotate(0deg);
}
18%, 78% {
transform: translateY(-6px) rotate(-45deg);
}
88%, 100% {
transform: translateY(0) rotate(0deg);
}
}
@keyframes fm-l1 {
0%, 12% {
opacity: 0;
transform: translateY(18px);
}
24%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fm-l2 {
0%, 20% {
opacity: 0;
transform: translateY(18px);
}
32%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fm-l3 {
0%, 28% {
opacity: 0;
transform: translateY(18px);
}
40%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fm-l4 {
0%, 36% {
opacity: 0;
transform: translateY(18px);
}
48%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.fm__bar {
padding: 8px 12px;
}
.fm__brand {
font-size: 14px;
line-height: 20px;
}
.fm__btn {
width: 36px;
height: 36px;
}
.fm__page {
gap: 4px;
padding: 0 12px 8px;
}
.fm__rule {
height: 6px;
}
.fm__overlay {
gap: 8px;
padding: 0 20px;
}
.fm__link {
font-size: 19px;
line-height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.fm.is-demo .fm__overlay,
.fm.is-demo .fm__burger span,
.fm.is-demo .fm__link {
animation: none !important;
}
.fm.is-demo .fm__overlay {
visibility: visible !important;
opacity: 1 !important;
transform: none !important;
}
.fm.is-demo .fm__link {
opacity: 1 !important;
transform: none !important;
}
.fm.is-demo .fm__burger span:nth-child(1) {
transform: translateY(6px) rotate(45deg) !important;
}
.fm.is-demo .fm__burger span:nth-child(2) {
opacity: 0 !important;
transform: scaleX(0) !important;
}
.fm.is-demo .fm__burger span:nth-child(3) {
transform: translateY(-6px) rotate(-45deg) !important;
}
.fm.is-demo .fm__brand {
opacity: 0 !important;
}
.fm__burger span, .fm__overlay, .fm__link, .fm__brand {
transition: none !important;
transition-delay: 0ms !important;
}
}// 실제 동작: 버튼 하나가 열기/닫기를 겸한다 — 햄버거 막대가 X 로 모프한다.
var fm = document.querySelector('.fm');
var btn = fm.querySelector('.fm__btn');
var ov = fm.querySelector('.fm__overlay');
var links = Array.prototype.slice.call(ov.querySelectorAll('.fm__link'));
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
ov.setAttribute('inert', '');
function stopDemo() { fm.classList.remove('is-demo'); } // 사람이 만졌으니 자동 루프를 끈다
fm.addEventListener('pointerdown', stopDemo);
function open() {
fm.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
ov.removeAttribute('inert');
btn.focus({ preventScroll: true }); // 같은 버튼이 X 닫기가 된다 — 초점 유지
}
function close() {
fm.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
ov.setAttribute('inert', '');
btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function () {
fm.classList.contains('is-open') ? close() : open();
});
// Tab 이 오버레이 밖으로 나가면 다시 안으로 돌린다(포커스 트랩)
ov.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || links.length === 0) return;
var first = links[0], last = links[links.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus({ preventScroll: true });
}
});
// Escape Close — 초점은 버튼으로
fm.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && fm.classList.contains('is-open')) close();
});import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenMorph.module.scss";
export type FullscreenMorphProps = {
labels?: { brand?: string; menuLabel?: string; links?: string[] };
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
const LINKS = ["Work", "About", "Log", "Contact"];
const ARROW = (
<g fill="currentColor">
<path d="M192 64v104L88 64Z" opacity=".2" />
<path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z" />
</g>
);
export default function FullscreenMorph({
labels,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#fff7e6",
}: FullscreenMorphProps) {
const brand = labels?.brand ?? "Studio";
const menuLabel = labels?.menuLabel ?? "Main menu";
const links = labels?.links ?? LINKS;
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const btnRef: Box<HTMLButtonElement> = useRef(null);
const ovRef: Box<HTMLDivElement> = useRef(null);
// 닫힌 오버레이는 포커스·탐색에서도 밖으로 내보낸다(inert)
useEffect(() => {
const ov = ovRef.current;
if (ov === null) return;
if (open) ov.removeAttribute("inert");
else ov.setAttribute("inert", "");
}, [open]);
function toggle() {
setLive(false); // 사람이 만졌으니 자동 루프를 끈다
setOpen(!open);
const b = btnRef.current; // 같은 버튼이 X 닫기가 된다 — 초점 유지
if (b !== null) b.focus({ preventScroll: true });
}
// Escape 닫기 + Tab 이 오버레이 밖으로 나가면 다시 안으로(포커스 트랩)
function keys(e: KeyEvt) {
setLive(false);
if (e.key === "Escape" && open) {
setOpen(false);
const b = btnRef.current;
if (b !== null) b.focus({ preventScroll: true });
return;
}
if (e.key !== "Tab" || !open) return;
const ov = ovRef.current;
if (ov === null) return;
const els = Array.from(ov.querySelectorAll("a")) as HTMLElement[];
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
const el = document.activeElement;
if (e.shiftKey && el === first) {
e.preventDefault();
last.focus({ preventScroll: true });
} else if (!e.shiftKey && el === last) {
e.preventDefault();
first.focus({ preventScroll: true });
}
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.fm, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} onPointerDown={() => setLive(false)} onKeyDown={keys}>
<div className={styles.page} aria-hidden="true">
<i className={`${styles.block} ${styles.blockA}`} />
<i className={`${styles.block} ${styles.blockB}`} />
<i className={styles.rule} />
<i className={`${styles.rule} ${styles.ruleS}`} />
<i className={`${styles.rule} ${styles.ruleM}`} />
</div>
<header className={styles.bar}>
<span className={styles.brand}>{brand}</span>
<button
className={styles.btn}
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-controls="fm-menu"
ref={btnRef}
onClick={toggle}
>
<span className={styles.burger} aria-hidden="true"><span /><span /><span /></span>
</button>
</header>
<div
className={styles.overlay}
id="fm-menu"
role="dialog"
aria-modal="true"
aria-label={menuLabel}
ref={ovRef}
>
{links.map((t, i) => (
<a className={styles.link} key={t} href="#" style={{ "--i": i } as CSSProperties}>
{t}
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">{ARROW}</svg>
</a>
))}
</div>
</section>
);
}06 · Fullscreen overlay where the burger folds into an X — Measured area 24.215% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The bar morph is a translateY plus rotate pair; each link arrives on its own delay; and the result reads as a signboard rather than a door, which suits the primary menu of mobile and portfolio sites.
ov.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || links.length === 0) return;
var first = links[0], last = links[links.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus({ preventScroll: true });
}
});
07Side drawer that slides in from the right
What you see
Press the button and a panel slides in from the right edge while the rest of the screen dims and goes untouchable; when it closes, focus returns to the button you pressed. The lock is a single inert attribute.

<main class="stage">
<section class="frame is-demo" id="sdr" aria-label="Drawer">
<div class="page" id="sdr-page">
<header class="bar">
<p class="brand">Drawer</p>
<button class="burger" id="sdr-open" type="button" aria-label="Open menu" aria-controls="sdr-drawer">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 64v128H40V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16"/></g></svg>
</button>
</header>
<div class="body">
<p class="skeleton" aria-hidden="true"></p>
<ul class="cards">
<li class="card"><span class="sw sw--a" aria-hidden="true"></span><span class="nm">Hat 12,000</span></li>
<li class="card"><span class="sw sw--b" aria-hidden="true"></span><span class="nm">Sock set 8,000</span></li>
<li class="card"><span class="sw sw--c" aria-hidden="true"></span><span class="nm">Mug 6,500</span></li>
</ul>
</div>
</div>
<div class="veil" id="sdr-veil"></div>
<aside class="drawer" id="sdr-drawer" aria-label="My drawer" aria-hidden="true">
<header class="head">
<p class="title">My drawer</p>
<button class="xbtn" id="sdr-close" type="button" aria-label="Close">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
</header>
<ul class="menu">
<li><button type="button"><span>Orders</span><svg viewBox="0 0 256 256" width="18" height="18" 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></li>
<li><button type="button"><span>Saved items</span><svg viewBox="0 0 256 256" width="18" height="18" 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></li>
<li><button type="button"><span>Addresses</span><svg viewBox="0 0 256 256" width="18" height="18" 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></li>
<li><button type="button"><span>Coupons</span><svg viewBox="0 0 256 256" width="18" height="18" 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></li>
<li><button type="button"><span>Settings</span><svg viewBox="0 0 256 256" width="18" height="18" 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></li>
</ul>
</aside>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-orange);
$ink: $subject-ink;
$cream: $subject-cream;
$line: rgba(23, 20, 26, .12);
$hover: rgba(23, 20, 26, .06);
// ── 프레임: 오렌지 무대 위 크림 카드 ────────────────
.frame {
position: relative;
overflow: hidden; // 드로어가 튀어나가지 않게 프레임에서 자른다
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border-radius: $r-card;
background: $cream;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * { box-sizing: border-box; }
.page { display: flex; flex-direction: column; height: 100%; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3;
}
.brand { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: .02em; }
.burger, .xbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid $line;
border-radius: $r-control;
background: transparent;
color: inherit;
cursor: pointer;
}
.burger:focus-visible, .xbtn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 본문 흉내 — 회색 막대 하나와 상품 카드 3장
.body {
display: grid;
align-content: start;
gap: $sp-2;
padding: 0 $sp-3 $sp-3;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
}
.cards { display: grid; gap: $sp-2; margin: 0; padding: 0; list-style: none; }
.card {
display: grid;
grid-template-columns: 28px 1fr;
align-items: center;
gap: $sp-2;
height: 40px;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
}
.sw { width: 20px; height: 20px; border-radius: $r-xs; }
.sw--a { background: rgba(255, 77, 31, .25); }
.sw--b { background: rgba(47, 109, 246, .22); }
.sw--c { background: rgba(76, 212, 166, .28); }
.nm { font-weight: 600; }
// ── 오버레이 + 드로어 ────────────────────────────
.veil {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, .45);
opacity: 0;
pointer-events: none;
transition: opacity $dur-base $easing;
}
.frame.is-open .veil { opacity: 1; pointer-events: auto; }
.drawer {
position: absolute;
top: 0;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(240px, 72%);
padding: $sp-2 $sp-3;
border-left: 1px solid $line;
background: $cream;
box-shadow: -18px 0 32px rgba(23, 20, 26, .28);
transform: translateX(100%); // 닫힘 — 프레임 오른쪽 밖
transition: transform $dur-base $easing;
}
.frame.is-open .drawer { transform: translateX(0); }
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 $sp-2;
}
.title { margin: 0; font-size: 13px; font-weight: 700; }
.menu { display: grid; gap: $sp-1; margin: 0; padding: 0; list-style: none; }
.menu button {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 32px;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.menu button:hover { background: $hover; }
.menu button:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 메뉴 시차 등장 — 정적 경로(사람이 열 때)는 transition 지연으로 배치
.menu li {
opacity: 0;
transform: translateY(8px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing;
}
.frame.is-open .menu li { opacity: 1; transform: translateY(0); }
.frame.is-open .menu li:nth-child(2) { transition-delay: 40ms, 40ms; }
.frame.is-open .menu li:nth-child(3) { transition-delay: 80ms, 80ms; }
.frame.is-open .menu li:nth-child(4) { transition-delay: 120ms, 120ms; }
.frame.is-open .menu li:nth-child(5) { transition-delay: 160ms, 160ms; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 0-8% 닫힘 → 10% 드로어 슬라이드 인 → 16-50% 메뉴 시차 등장 → 78% 유지 → 80-88% 짧은 닫힘.
// 등장과 목록이 주인공이라 닫힘은 8% 안에 끝낸다. 켜짐·꺼짐은 steps 컷, 자리잡기는 transform 짝.
@keyframes sdr-press {
0%, 4% { transform: translateY(0); }
6% { transform: translateY(2px); }
8%, 100% { transform: translateY(0); }
}
@keyframes sdr-xpress {
0%, 76% { transform: translateY(0); }
78% { transform: translateY(2px); }
80%, 100% { transform: translateY(0); }
}
@keyframes sdr-drawer {
0%, 10% { transform: translateX(100%); }
22%, 80% { transform: translateX(0); }
88%, 100% { transform: translateX(100%); }
}
@keyframes sdr-veil {
0%, 10% { opacity: 0; }
22%, 80% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes sdr-item-cut {
0%, 16% { opacity: 0; }
16.1%, 100% { opacity: 1; }
}
@keyframes sdr-item-rise {
0%, 16% { transform: translateY(8px); }
26%, 100% { transform: translateY(0); }
}
.frame.is-demo {
.burger {
animation-name: sdr-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.xbtn {
animation-name: sdr-xpress;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.drawer {
animation-name: sdr-drawer;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.veil {
animation-name: sdr-veil;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.menu li {
animation-name: sdr-item-cut, sdr-item-rise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
}
// 시차 — 같은 루프에 delay 만 더해 쓴다 (뒤 · 높은 특이도)
.frame.is-demo .menu li:nth-child(2) { animation-delay: 120ms, 120ms; }
.frame.is-demo .menu li:nth-child(3) { animation-delay: 240ms, 240ms; }
.frame.is-demo .menu li:nth-child(4) { animation-delay: 360ms, 360ms; }
.frame.is-demo .menu li:nth-child(5) { animation-delay: 480ms, 480ms; }
@media (max-width: 340px) {
.bar { padding: $sp-1 $sp-2; }
.brand { font-size: 13px; }
.burger, .xbtn { width: 28px; height: 28px; }
.body { gap: $sp-1; padding: 0 $sp-2 $sp-2; }
.card { height: 32px; gap: $sp-1; }
.drawer { padding: $sp-1 $sp-2; }
.head { margin-bottom: $sp-1; }
.menu { gap: 0; }
.menu button { height: 24px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .burger,
.frame.is-demo .xbtn,
.frame.is-demo .drawer,
.frame.is-demo .veil,
.frame.is-demo .menu li { animation: none !important; }
// 움직임만 끄고 「열린 드로어」 결과는 남긴다
.frame.is-demo .veil { opacity: 1 !important; }
.frame.is-demo .drawer { transform: translateX(0) !important; }
.frame.is-demo .menu li { opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.brand {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.burger, .xbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid rgba(23, 20, 26, 0.12);
border-radius: 12px;
background: transparent;
color: inherit;
cursor: pointer;
}
.burger:focus-visible, .xbtn:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.body {
display: grid;
align-content: start;
gap: 8px;
padding: 0 12px 12px;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.cards {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.card {
display: grid;
grid-template-columns: 28px 1fr;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.12);
border-radius: 8px;
background: #ffffff;
}
.sw {
width: 20px;
height: 20px;
border-radius: 4px;
}
.sw--a {
background: rgba(255, 77, 31, 0.25);
}
.sw--b {
background: rgba(47, 109, 246, 0.22);
}
.sw--c {
background: rgba(76, 212, 166, 0.28);
}
.nm {
font-weight: 600;
}
.veil {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .veil {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: absolute;
top: 0;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(240px, 72%);
padding: 8px 12px;
border-left: 1px solid rgba(23, 20, 26, 0.12);
background: #fff7e6;
box-shadow: -18px 0 32px rgba(23, 20, 26, 0.28);
transform: translateX(100%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .drawer {
transform: translateX(0);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.title {
margin: 0;
font-size: 13px;
font-weight: 700;
}
.menu {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.menu button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 32px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.menu button:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu button:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.menu li {
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .menu li {
opacity: 1;
transform: translateY(0);
}
.frame.is-open .menu li:nth-child(2) {
transition-delay: 40ms, 40ms;
}
.frame.is-open .menu li:nth-child(3) {
transition-delay: 80ms, 80ms;
}
.frame.is-open .menu li:nth-child(4) {
transition-delay: 120ms, 120ms;
}
.frame.is-open .menu li:nth-child(5) {
transition-delay: 160ms, 160ms;
}
@keyframes sdr-press {
0%, 4% {
transform: translateY(0);
}
6% {
transform: translateY(2px);
}
8%, 100% {
transform: translateY(0);
}
}
@keyframes sdr-xpress {
0%, 76% {
transform: translateY(0);
}
78% {
transform: translateY(2px);
}
80%, 100% {
transform: translateY(0);
}
}
@keyframes sdr-drawer {
0%, 10% {
transform: translateX(100%);
}
22%, 80% {
transform: translateX(0);
}
88%, 100% {
transform: translateX(100%);
}
}
@keyframes sdr-veil {
0%, 10% {
opacity: 0;
}
22%, 80% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes sdr-item-cut {
0%, 16% {
opacity: 0;
}
16.1%, 100% {
opacity: 1;
}
}
@keyframes sdr-item-rise {
0%, 16% {
transform: translateY(8px);
}
26%, 100% {
transform: translateY(0);
}
}
.frame.is-demo .burger {
animation-name: sdr-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .xbtn {
animation-name: sdr-xpress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .drawer {
animation-name: sdr-drawer;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .veil {
animation-name: sdr-veil;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .menu li {
animation-name: sdr-item-cut, sdr-item-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.frame.is-demo .menu li:nth-child(2) {
animation-delay: 120ms, 120ms;
}
.frame.is-demo .menu li:nth-child(3) {
animation-delay: 240ms, 240ms;
}
.frame.is-demo .menu li:nth-child(4) {
animation-delay: 360ms, 360ms;
}
.frame.is-demo .menu li:nth-child(5) {
animation-delay: 480ms, 480ms;
}
@media (max-width: 340px) {
.bar {
padding: 4px 8px;
}
.brand {
font-size: 13px;
}
.burger, .xbtn {
width: 28px;
height: 28px;
}
.body {
gap: 4px;
padding: 0 8px 8px;
}
.card {
height: 32px;
gap: 4px;
}
.drawer {
padding: 4px 8px;
}
.head {
margin-bottom: 4px;
}
.menu {
gap: 0;
}
.menu button {
height: 24px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .burger,
.frame.is-demo .xbtn,
.frame.is-demo .drawer,
.frame.is-demo .veil,
.frame.is-demo .menu li {
animation: none !important;
}
.frame.is-demo .veil {
opacity: 1 !important;
}
.frame.is-demo .drawer {
transform: translateX(0) !important;
}
.frame.is-demo .menu li {
opacity: 1 !important;
transform: none !important;
}
}// 실제 동작: 햄버거를 누르면 본문이 inert 로 잠기고 드로어가 열린다. 닫기는 x · Escape · 오버레이 클릭.
var root = document.getElementById('sdr');
var page = document.getElementById('sdr-page');
var drawer = document.getElementById('sdr-drawer');
var openBtn = document.getElementById('sdr-open');
var closeBtn = document.getElementById('sdr-close');
var veil = document.getElementById('sdr-veil');
var stops = [closeBtn].concat(Array.prototype.slice.call(drawer.querySelectorAll('.menu button')));
openBtn.setAttribute('aria-haspopup', 'true');
openBtn.setAttribute('aria-expanded', 'false');
drawer.inert = true; // 닫힌 동안 드로어 안 버튼은 탭 순서에서 빠진다
drawer.setAttribute('aria-hidden', 'true');
function setOpen(on) {
root.classList.remove('is-demo'); // 첫 조작에서 격자 루프를 끈다
root.classList.toggle('is-open', on);
page.inert = on; // 열린 동안 본문은 포커스를 잃는다
page.setAttribute('aria-hidden', on ? 'true' : 'false');
drawer.inert = !on;
drawer.setAttribute('aria-hidden', on ? 'false' : 'true');
openBtn.setAttribute('aria-expanded', on ? 'true' : 'false');
(on ? closeBtn : openBtn).focus({ preventScroll: true });
}
openBtn.addEventListener('click', function () { setOpen(true); });
closeBtn.addEventListener('click', function () { setOpen(false); });
veil.addEventListener('click', function () { setOpen(false); });
drawer.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault(); // 드로어 안에서만 Tab 이 돈다 — 짧은 포커스 트랩
var i = stops.indexOf(document.activeElement);
var d = e.shiftKey ? -1 : 1;
stops[(i + d + stops.length) % stops.length].focus({ preventScroll: true });
});import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SideDrawer.module.scss";
export type SideDrawerLabels = {
brand: string;
openLabel: string;
closeLabel: string;
drawerTitle: string;
menu: string[];
products: string[];
};
export type SideDrawerProps = {
labels?: Partial<SideDrawerLabels>;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
type PageEl = { inert?: boolean };
type Box<T> = { current: T | null };
const defaults: SideDrawerLabels = {
brand: "Drawer",
openLabel: "Open menu",
closeLabel: "Close",
drawerTitle: "My drawer",
menu: ["Orders", "Saved items", "Addresses", "Coupons", "Settings"],
products: ["Hat 12,000", "Socks set 8,000", "Mug 6,500"],
};
function ListIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 64v128H40V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16"/></g></svg>
);
}
function XIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
);
}
function CaretIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" 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 SideDrawer({
labels,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff4d1f",
}: SideDrawerProps) {
const L = { ...defaults, ...labels };
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const openBtn = useRef<Focusable | null>(null);
const closeBtn = useRef<Focusable | null>(null);
const menuBtns = useRef<Array<Focusable | null>>([]);
const pageRef = useRef<PageEl | null>(null);
// 열린 동안 본문은 포커스를 잃는다 (inert)
useEffect(() => { if (pageRef.current) pageRef.current.inert = open; }, [open]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function setDrawer(on: boolean) {
setLive(false);
setOpen(on);
(on ? closeBtn.current : openBtn.current)?.focus({ preventScroll: true });
}
function drawerKeys(e: KeyEvt) {
if (e.key === "Escape") { setDrawer(false); return; }
if (e.key !== "Tab") return;
e.preventDefault(); // 드로어 안에서만 Tab 이 돈다 — 짧은 포커스 트랩
if (!menuBtns.current) return; // Box 배열 ref null 가드
const stops = [closeBtn.current].concat(menuBtns.current.filter((b): b is Focusable => b !== null));
let i = 0;
for (let j = 0; j < stops.length; j++) { if (document.activeElement === stops[j]) i = j; }
const d = e.shiftKey ? -1 : 1;
stops[(i + d + stops.length) % stops.length]?.focus({ preventScroll: true });
}
const cls = [styles.frame, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
const sws = [styles.swA, styles.swB, styles.swC];
return (
<section className={cls} style={vars} aria-label={L.brand}>
<div className={styles.page} ref={(el: PageEl | null) => { pageRef.current = el; }} aria-hidden={open}>
<header className={styles.bar}>
<p className={styles.brand}>{L.brand}</p>
<button
className={styles.burger}
type="button"
aria-label={L.openLabel}
aria-controls="sdr-drawer"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setDrawer(true)}
ref={(el: Focusable | null) => { openBtn.current = el; }}
>
<ListIcon />
</button>
</header>
<div className={styles.body}>
<p className={styles.skeleton} aria-hidden="true" />
<ul className={styles.cards}>
{L.products.map((t, i) => (
<li className={styles.card} key={t}>
<span className={sws[i % 3]} aria-hidden="true" />
<span className={styles.nm}>{t}</span>
</li>
))}
</ul>
</div>
</div>
<div className={styles.veil} onClick={() => setDrawer(false)} />
<aside className={styles.drawer} id="sdr-drawer" aria-label={L.drawerTitle} aria-hidden={!open} onKeyDown={drawerKeys}>
<header className={styles.head}>
<p className={styles.title}>{L.drawerTitle}</p>
<button
className={styles.xbtn}
type="button"
aria-label={L.closeLabel}
onClick={() => setDrawer(false)}
ref={(el: Focusable | null) => { closeBtn.current = el; }}
>
<XIcon />
</button>
</header>
<ul className={styles.menu}>
{L.menu.map((m, i) => (
<li key={m}>
<button type="button" ref={(el: Focusable | null) => { if (menuBtns.current) menuBtns.current[i] = el; }}>
<span>{m}</span>
<CaretIcon />
</button>
</li>
))}
</ul>
</aside>
</section>
);
}07 · Side drawer that slides in from the right — Measured area 64.769% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
With inert on the page, links behind the veil stay visible yet Tab and the screen reader stop reaching them, and closing the drawer reverses both sides, which suits cart and account menus.
function setOpen(on) {
root.classList.toggle('is-open', on);
page.inert = on; // while open, the page loses focus
page.setAttribute('aria-hidden', on ? 'true' : 'false');
drawer.inert = !on;
drawer.setAttribute('aria-hidden', on ? 'false' : 'true');
openBtn.setAttribute('aria-expanded', on ? 'true' : 'false');
(on ? closeBtn : openBtn).focus({ preventScroll: true });
}
08Search that unfolds from an icon into an input
What happens on screen
Press the magnifier and a pill-shaped input unfolds from the icon's own spot, and after a few keystrokes, jump links appear just beneath it. Instead of growing wider, the input is always full width and simply stays covered from the right by clip-path: inset, so unfolding never triggers a layout pass.

<main class="stage">
<section class="frame is-demo" id="sch" aria-label="File depot">
<div class="page">
<header class="bar">
<p class="brand">File depot</p>
<div class="search" id="sch-box">
<button class="glass" id="sch-btn" type="button" aria-label="Open search" aria-controls="sch-box">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<input class="input" id="sch-in" type="text" placeholder="What are you looking for?" autocomplete="off">
</div>
</header>
<div class="body">
<p class="skeleton" aria-hidden="true"></p>
<p class="skeleton skeleton--short" aria-hidden="true"></p>
</div>
</div>
<ul class="skips" id="sch-skips" aria-hidden="true">
<li><button type="button"><span>Templates</span><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg></button></li>
<li><button type="button"><span>Icon sets</span><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg></button></li>
<li><button type="button"><span>Quick start</span><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg></button></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
$ink: $subject-ink;
$cream: $subject-cream;
$line: rgba(23, 20, 26, .12);
$hover: rgba(23, 20, 26, .06);
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px); // 아이콘 칸 40px 만 보임
$clip-open: inset(0 0 0 0 round 999px); // 전부 보임
// ── 프레임: 노랑 무대 위 크림 카드 ──────────────────
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border-radius: $r-card;
background: $cream;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * { box-sizing: border-box; }
.page { display: flex; flex-direction: column; height: 100%; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.brand { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: .02em; }
// 검색 자리 — 폭은 고정, clip-path 로 40px 아이콘 칸만 보이다가 펼쳐진다 (레이아웃 안 건드림)
.search {
position: relative;
width: min(220px, 60vw);
height: 36px;
border-radius: $r-pill;
background: $stage-paper;
clip-path: $clip-shut;
transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }
.search:focus-within { box-shadow: inset 0 0 0 2px $color; } // clip 밖으로 나가지 않는 초점 링
.glass {
position: absolute;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: $ink;
cursor: pointer;
}
.glass:focus-visible { box-shadow: inset 0 0 0 2px $color; }
.input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 36px;
margin: 0;
padding: 0 $sp-3 0 40px;
border: 0;
border-radius: $r-pill;
background: transparent;
color: $ink;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.input::placeholder { color: rgba(23, 20, 26, .45); }
// 본문 흉내 — 회색 막대
.body {
display: grid;
align-content: start;
gap: $sp-2;
padding: 0 $sp-3 $sp-3;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.skeleton--short { width: 62%; }
// ── 건너뛰기 목록 — input 값으로 걸러져 나타난다 ─────
.skips {
position: absolute; // 프레임 안에 완전히 들어간다
top: 56px;
right: $sp-3;
width: min(220px, 56vw);
margin: 0;
padding: $sp-1;
list-style: none;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity $dur-quick $easing, transform $dur-quick $easing;
}
.frame.is-listing .skips { opacity: 1; transform: translateY(0); pointer-events: auto; }
.skips li {
opacity: 0;
transform: translateY(6px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing;
}
.frame.is-listing .skips li { opacity: 1; transform: translateY(0); }
.frame.is-listing .skips li:nth-child(2) { transition-delay: 40ms, 40ms; }
.frame.is-listing .skips li:nth-child(3) { transition-delay: 80ms, 80ms; }
.skips button {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
width: 100%;
height: 34px;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.skips button:hover { background: $hover; }
.skips button:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 0-10% 닫힘(아이콘만) → 12-30% clip-path 펼쳐짐 → 36% 목록 steps 컷 등장 + 항목 팝 →
// 78% 유지 → 80-88% 접힘. 펼쳐진 인풋과 목록이 주인공이라 닫힘은 8% 안에 끝낸다.
@keyframes sch-press {
0%, 4% { transform: translateY(0); }
6% { transform: translateY(2px); }
8%, 100% { transform: translateY(0); }
}
@keyframes sch-clip {
0%, 12% { clip-path: $clip-shut; }
30%, 78% { clip-path: $clip-open; }
90%, 100% { clip-path: $clip-shut; }
}
@keyframes sch-list-cut {
0%, 36% { opacity: 0; }
36.1%, 74.9% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes sch-list-pop {
0%, 36% { transform: translateY(-4px); }
44%, 74.9% { transform: translateY(0); }
76%, 100% { transform: translateY(-4px); }
}
@keyframes sch-item-cut {
0%, 37% { opacity: 0; }
37.1%, 100% { opacity: 1; }
}
@keyframes sch-item-pop {
0%, 37% { transform: translateY(6px); }
45%, 100% { transform: translateY(0); }
}
.frame.is-demo {
.glass {
animation-name: sch-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.search {
animation-name: sch-clip;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.skips {
animation-name: sch-list-cut, sch-list-pop;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.skips li {
animation-name: sch-item-cut, sch-item-pop;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
}
// 시차 — 같은 루프에 delay 만 더해 쓴다 (뒤 · 높은 특이도)
.frame.is-demo .skips li:nth-child(2) { animation-delay: 40ms, 40ms; }
.frame.is-demo .skips li:nth-child(3) { animation-delay: 80ms, 80ms; }
@media (max-width: 340px) {
.bar { padding: $sp-1 $sp-2; }
.brand { font-size: 13px; }
.search { width: 56vw; height: 32px; }
.glass { height: 32px; }
.input { height: 32px; padding-right: $sp-2; font-size: 11px; }
.skips { top: 44px; width: min(168px, 56vw); }
.skips button { height: 30px; font-size: 11px; }
.body { gap: $sp-1; padding: 0 $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .glass,
.frame.is-demo .search,
.frame.is-demo .skips,
.frame.is-demo .skips li { animation: none !important; }
// 움직임만 끄고 「펼쳐진 검색 + 목록」 결과는 남긴다
.frame.is-demo .search { clip-path: $clip-open !important; }
.frame.is-demo .skips { opacity: 1 !important; transform: none !important; }
.frame.is-demo .skips li { opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.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);
}
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.brand {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.search {
position: relative;
width: min(220px, 60vw);
height: 36px;
border-radius: 999px;
background: #ffffff;
clip-path: inset(0 calc(100% - 40px) 0 0 round 999px);
transition: clip-path 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .search {
clip-path: inset(0 0 0 0 round 999px);
}
.search:focus-within {
box-shadow: inset 0 0 0 2px #17141a;
}
.glass {
position: absolute;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #17141a;
cursor: pointer;
}
.glass:focus-visible {
box-shadow: inset 0 0 0 2px #17141a;
}
.input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 36px;
margin: 0;
padding: 0 12px 0 40px;
border: 0;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.input::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.body {
display: grid;
align-content: start;
gap: 8px;
padding: 0 12px 12px;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.skeleton--short {
width: 62%;
}
.skips {
position: absolute;
top: 56px;
right: 12px;
width: min(220px, 56vw);
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid rgba(23, 20, 26, 0.12);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-listing .skips {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.skips li {
opacity: 0;
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);
}
.frame.is-listing .skips li {
opacity: 1;
transform: translateY(0);
}
.frame.is-listing .skips li:nth-child(2) {
transition-delay: 40ms, 40ms;
}
.frame.is-listing .skips li:nth-child(3) {
transition-delay: 80ms, 80ms;
}
.skips button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 34px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.skips button:hover {
background: rgba(23, 20, 26, 0.06);
}
.skips button:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes sch-press {
0%, 4% {
transform: translateY(0);
}
6% {
transform: translateY(2px);
}
8%, 100% {
transform: translateY(0);
}
}
@keyframes sch-clip {
0%, 12% {
clip-path: inset(0 calc(100% - 40px) 0 0 round 999px);
}
30%, 78% {
clip-path: inset(0 0 0 0 round 999px);
}
90%, 100% {
clip-path: inset(0 calc(100% - 40px) 0 0 round 999px);
}
}
@keyframes sch-list-cut {
0%, 36% {
opacity: 0;
}
36.1%, 74.9% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes sch-list-pop {
0%, 36% {
transform: translateY(-4px);
}
44%, 74.9% {
transform: translateY(0);
}
76%, 100% {
transform: translateY(-4px);
}
}
@keyframes sch-item-cut {
0%, 37% {
opacity: 0;
}
37.1%, 100% {
opacity: 1;
}
}
@keyframes sch-item-pop {
0%, 37% {
transform: translateY(6px);
}
45%, 100% {
transform: translateY(0);
}
}
.frame.is-demo .glass {
animation-name: sch-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .search {
animation-name: sch-clip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .skips {
animation-name: sch-list-cut, sch-list-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.frame.is-demo .skips li {
animation-name: sch-item-cut, sch-item-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.frame.is-demo .skips li:nth-child(2) {
animation-delay: 40ms, 40ms;
}
.frame.is-demo .skips li:nth-child(3) {
animation-delay: 80ms, 80ms;
}
@media (max-width: 340px) {
.bar {
padding: 4px 8px;
}
.brand {
font-size: 13px;
}
.search {
width: 56vw;
height: 32px;
}
.glass {
height: 32px;
}
.input {
height: 32px;
padding-right: 8px;
font-size: 11px;
}
.skips {
top: 44px;
width: min(168px, 56vw);
}
.skips button {
height: 30px;
font-size: 11px;
}
.body {
gap: 4px;
padding: 0 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .glass,
.frame.is-demo .search,
.frame.is-demo .skips,
.frame.is-demo .skips li {
animation: none !important;
}
.frame.is-demo .search {
clip-path: inset(0 0 0 0 round 999px) !important;
}
.frame.is-demo .skips {
opacity: 1 !important;
transform: none !important;
}
.frame.is-demo .skips li {
opacity: 1 !important;
transform: none !important;
}
}// 실제 동작: 버튼으로 펼치고 input 에 치면 목록이 includes 필터로 걸러진다. Escape 로 닫는다.
var root = document.getElementById('sch');
var btn = document.getElementById('sch-btn');
var input = document.getElementById('sch-in');
var skips = document.getElementById('sch-skips');
var items = Array.prototype.slice.call(skips.querySelectorAll('button'));
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
input.tabIndex = -1; // 닫힌 동안 input 은 탭 순서에서 빠진다
function hits() {
var q = input.value.trim();
if (q === '') return [];
return items.filter(function (b) { return b.textContent.includes(q); });
}
function render() {
var hit = hits();
root.classList.toggle('is-listing', hit.length > 0);
skips.setAttribute('aria-hidden', hit.length ? 'false' : 'true');
items.forEach(function (b) { b.parentElement.hidden = hit.indexOf(b) < 0; });
}
function setOpen(on) {
root.classList.remove('is-demo'); // 첫 조작에서 격자 루프를 끈다
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', on ? 'true' : 'false');
input.tabIndex = on ? 0 : -1;
if (on) { input.focus({ preventScroll: true }); render(); return; }
root.classList.remove('is-listing');
skips.setAttribute('aria-hidden', 'true');
btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { setOpen(!root.classList.contains('is-open')); });
input.addEventListener('input', function () { root.classList.remove('is-demo'); render(); });
items.forEach(function (b) { b.addEventListener('click', function () { setOpen(false); }); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) setOpen(false);
});import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchExpand.module.scss";
export type SearchExpandLabels = {
brand: string;
openLabel: string;
placeholder: string;
items: string[];
};
export type SearchExpandProps = {
labels?: Partial<SearchExpandLabels>;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: HTMLInputElement };
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
type Box<T> = { current: T | null };
const defaults: SearchExpandLabels = {
brand: "File depot",
openLabel: "Open search",
placeholder: "Search the site",
items: ["Templates", "Icon sets", "Quick start"],
};
function MagnifyIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
);
}
export default function SearchExpand({
labels,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: SearchExpandProps) {
const L = { ...defaults, ...labels };
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [value, setValue] = useState("");
const btnRef = useRef<Focusable | null>(null);
const inputRef = useRef<Focusable | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = value.trim();
const hits = q === "" ? [] : L.items.filter((t) => t.includes(q));
const listing = open && hits.length > 0;
function setSearch(on: boolean) {
setLive(false);
setOpen(on);
if (on) { inputRef.current?.focus({ preventScroll: true }); return; }
btnRef.current?.focus({ preventScroll: true });
}
function rootKeys(e: KeyEvt) {
if (e.key === "Escape" && open) setSearch(false);
}
const cls = [styles.frame, live ? styles.isDemo : "", open ? styles.isOpen : "", listing ? styles.isListing : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={L.brand} onKeyDown={rootKeys}>
<div className={styles.page}>
<header className={styles.bar}>
<p className={styles.brand}>{L.brand}</p>
<div className={styles.search}>
<button
className={styles.glass}
type="button"
aria-label={L.openLabel}
aria-haspopup="true"
aria-expanded={open}
onClick={() => setSearch(!open)}
ref={(el: Focusable | null) => { btnRef.current = el; }}
>
<MagnifyIcon />
</button>
<input
className={styles.input}
type="text"
placeholder={L.placeholder}
autoComplete="off"
tabIndex={open ? 0 : -1}
value={value}
onChange={(e: InputEvt) => { setLive(false); setValue(e.target.value); }}
ref={(el: Focusable | null) => { inputRef.current = el; }}
/>
</div>
</header>
<div className={styles.body}>
<p className={styles.skeleton} aria-hidden="true" />
<p className={styles.skeletonShort} aria-hidden="true" />
</div>
</div>
<ul className={styles.skips} aria-hidden={!listing}>
{L.items.map((t) => (
<li key={t} hidden={!hits.includes(t)}>
<button type="button" onClick={() => setSearch(false)}>
<span>{t}</span>
<ArrowIcon />
</button>
</li>
))}
</ul>
</section>
);
}08 · Search that unfolds from an icon into an input — Measured area 27.184% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Docs and blog headers where top space is scarce are its home.
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px); // only the 40px icon slot shows
$clip-open: inset(0 0 0 0 round 999px); // everything shows
.search {
clip-path: $clip-shut;
transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }
09Notification badge menu with live read state
What moves
The figure pinned to the corner of the bell counts unread items, and reading one in the panel wipes its dot while the figure drops that very instant. The badge stacks its three figures and swaps them with a steps(1, end) cut, so no frame ever shows two numbers overlapping, and an off-screen aria-live region speaks the new count.

<main class="stage">
<section class="frame is-demo" id="nbf" aria-label="Club">
<div class="page">
<header class="bar">
<p class="brand">Club</p>
<button class="bell" id="nbf-bell" type="button" aria-label="Alerts" aria-controls="nbf-panel">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="badge" id="nbf-badge" data-n="3" aria-hidden="true"><span class="n3">3</span><span class="n2">2</span><span class="n1">1</span></span>
</button>
</header>
<div class="body">
<p class="skeleton" aria-hidden="true"></p>
<p class="skeleton skeleton--short" aria-hidden="true"></p>
</div>
</div>
<aside class="panel" id="nbf-panel" aria-label="Alerts" aria-hidden="true">
<header class="phead">
<p class="ptitle">Alerts</p>
<button class="allbtn" id="nbf-all" type="button">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span>Mark all as read</span>
</button>
</header>
<ul class="rows">
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">Your order started shipping</span><span class="rtime">just now</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">2 coupons expiring soon</span><span class="rtime">2 hours ago</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">New comment posted</span><span class="rtime">yesterday</span></button></li>
</ul>
</aside>
<p class="sr" id="nbf-live" aria-live="polite" data-fmt="{n} unread notifications">3 unread notifications</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
$ink: $subject-ink;
$cream: $subject-cream;
$line: rgba(255, 247, 230, .16);
$hover: rgba(23, 20, 26, .06);
// ── 프레임: 잉크 무대 위 어두운 카드 ────────────────
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border: 1px solid $line;
border-radius: $r-card;
background: rgba(255, 247, 230, .04);
color: $cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * { box-sizing: border-box; }
.page { display: flex; flex-direction: column; height: 100%; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3;
}
.brand { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: .02em; }
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid $line;
border-radius: $r-control;
background: transparent;
color: $cream;
cursor: pointer;
}
.bell:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 배지 — 숫자는 겹친 span 을 컷으로 갈아끼운다 (transition 없음, 크로스페이드 금지)
.badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
border-radius: $r-pill;
background: $color;
color: $ink;
}
.badge span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
opacity: 0;
}
.badge[data-n="3"] .n3,
.badge[data-n="2"] .n2,
.badge[data-n="1"] .n1 { opacity: 1; }
.frame.is-clear .badge { opacity: 0; transform: scale(0); }
// 본문 흉내 — 회색 막대
.body {
display: grid;
align-content: start;
gap: $sp-2;
padding: 0 $sp-3 $sp-3;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: $r-pill;
background: rgba(255, 247, 230, .10);
}
.skeleton--short { width: 62%; }
// ── 알림 패널 — 종 아래 오른쪽 정렬 ─────────────────
.panel {
position: absolute; // 프레임 안에 완전히 들어간다
top: 52px;
right: $sp-3;
width: min(240px, 84vw);
padding: $sp-2;
border-radius: $r-card;
background: $cream;
color: $ink;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity $dur-quick $easing, transform $dur-quick $easing;
}
.frame.is-open .panel { opacity: 1; transform: translateY(0); pointer-events: auto; }
.phead {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 $sp-1;
}
.ptitle { margin: 0; font-size: 12px; font-weight: 700; line-height: 1.3; }
.allbtn {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, .55);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
text-decoration: underline;
cursor: pointer;
}
.allbtn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rows { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.row {
display: grid;
grid-template-columns: 8px 1fr;
column-gap: $sp-2;
align-items: start;
width: 100%;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-control;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.row:hover { background: $hover; }
.row:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dot {
grid-column: 1;
grid-row: 1;
width: 8px;
height: 8px;
margin-top: 4px;
border-radius: 50%;
background: $color;
}
.rt { grid-column: 2; grid-row: 1; font-size: 12px; font-weight: 600; line-height: 1.3; }
.rtime { grid-column: 2; grid-row: 2; font-size: 10px; line-height: 1.3; color: rgba(23, 20, 26, .5); }
// 읽음 — 점 소멸과 줄 어둡게짐은 컷으로 바뀐다 (transition 없음)
.row.is-read { opacity: .5; }
.row.is-read .dot { opacity: 0; transform: scale(0); }
// 화면 밖 스크린리더 영역 — 배지 수를 소리로 알린다
.sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 0-8% 닫힘 → 10% 패널 등장 → 34/48/62% 줄 하나씩 읽음(점 컷 소멸 + 배지 3→2→1→소멸) →
// 78% 유지 → 80-88% 짧은 닫힘. 읽음 처리 컷이 주인공이라 닫힘은 8% 안에 끝낸다.
@keyframes nbf-press {
0%, 4% { transform: translateY(0); }
6% { transform: translateY(2px); }
8%, 100% { transform: translateY(0); }
}
@keyframes nbf-panel-cut {
0%, 8.3% { opacity: 0; }
8.4%, 100% { opacity: 1; }
}
@keyframes nbf-panel-rise {
0%, 8.3% { transform: translateY(0); clip-path: inset(0 0 100% 0); }
8.4%, 84% { transform: translateY(0); clip-path: inset(0 0 0 0); }
90%, 100% { transform: translateY(0); clip-path: inset(100% 0 0 0); }
}
@keyframes nbf-dim1 { 0%, 34% { opacity: 1; } 34.1%, 100% { opacity: .5; } }
@keyframes nbf-dim2 { 0%, 48% { opacity: 1; } 48.1%, 100% { opacity: .5; } }
@keyframes nbf-dim3 { 0%, 62% { opacity: 1; } 62.1%, 100% { opacity: .5; } }
@keyframes nbf-dot1 { 0%, 34% { opacity: 1; transform: scale(1); } 34.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-dot2 { 0%, 48% { opacity: 1; transform: scale(1); } 48.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-dot3 { 0%, 62% { opacity: 1; transform: scale(1); } 62.1%, 100% { opacity: 0; transform: scale(0); } }
@keyframes nbf-n3 { 0%, 34% { opacity: 1; } 34.1%, 100% { opacity: 0; } }
@keyframes nbf-n2 { 0%, 34% { opacity: 0; } 34.1%, 48% { opacity: 1; } 48.1%, 100% { opacity: 0; } }
@keyframes nbf-n1 { 0%, 48% { opacity: 0; } 48.1%, 62% { opacity: 1; } 62.1%, 100% { opacity: 0; } }
@keyframes nbf-badge {
0%, 62% { opacity: 1; transform: scale(1); }
62.1%, 100% { opacity: 0; transform: scale(0); }
}
.frame.is-demo {
.bell {
animation-name: nbf-press;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.panel {
animation-name: nbf-panel-cut, nbf-panel-rise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.badge {
animation-name: nbf-badge;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.badge .n3 { animation-name: nbf-n3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.badge .n2 { animation-name: nbf-n2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.badge .n1 { animation-name: nbf-n1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(1) .row { animation-name: nbf-dim1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(1) .dot { animation-name: nbf-dot1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(2) .row { animation-name: nbf-dim2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(2) .dot { animation-name: nbf-dot2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(3) .row { animation-name: nbf-dim3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rows li:nth-child(3) .dot { animation-name: nbf-dot3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
}
@media (max-width: 340px) {
.bar { padding: $sp-1 $sp-2; }
.brand { font-size: 13px; }
.panel { top: 44px; width: min(216px, 84vw); padding: $sp-1; }
.phead { margin-bottom: 0; }
.ptitle { font-size: 11px; }
.row { padding: 0; column-gap: $sp-1; }
.rt { font-size: 11px; line-height: 1.25; }
.rtime { font-size: 10px; line-height: 1.2; }
.body { gap: $sp-1; padding: 0 $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .bell,
.frame.is-demo .panel,
.frame.is-demo .badge,
.frame.is-demo .badge .n3,
.frame.is-demo .badge .n2,
.frame.is-demo .badge .n1,
.frame.is-demo .rows li .row,
.frame.is-demo .rows li .dot { animation: none !important; }
// 움직임만 끄고 「열린 패널 + 안 읽은 알림 3개」 결과는 남긴다
.frame.is-demo .panel { opacity: 1 !important; transform: none !important; }
}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);
}
.frame {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(420px, 94vw);
height: min(260px, 84vh);
border: 1px solid rgba(255, 247, 230, 0.16);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.35;
}
.frame, .frame * {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.brand {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 247, 230, 0.16);
border-radius: 12px;
background: transparent;
color: #fff7e6;
cursor: pointer;
}
.bell:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
}
.badge span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
opacity: 0;
}
.badge[data-n="3"] .n3,
.badge[data-n="2"] .n2,
.badge[data-n="1"] .n1 {
opacity: 1;
}
.frame.is-clear .badge {
opacity: 0;
transform: scale(0);
}
.body {
display: grid;
align-content: start;
gap: 8px;
padding: 0 12px 12px;
}
.skeleton {
height: 10px;
margin: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
}
.skeleton--short {
width: 62%;
}
.panel {
position: absolute;
top: 52px;
right: 12px;
width: min(240px, 84vw);
padding: 8px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.frame.is-open .panel {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.phead {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 4px;
}
.ptitle {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.allbtn {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, 0.55);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
text-decoration: underline;
cursor: pointer;
}
.allbtn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.rows {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: grid;
grid-template-columns: 8px 1fr;
column-gap: 8px;
align-items: start;
width: 100%;
margin: 0;
padding: 4px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.row:hover {
background: rgba(23, 20, 26, 0.06);
}
.row:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.dot {
grid-column: 1;
grid-row: 1;
width: 8px;
height: 8px;
margin-top: 4px;
border-radius: 50%;
background: #ffd23f;
}
.rt {
grid-column: 2;
grid-row: 1;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
}
.rtime {
grid-column: 2;
grid-row: 2;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.5);
}
.row.is-read {
opacity: 0.5;
}
.row.is-read .dot {
opacity: 0;
transform: scale(0);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes nbf-press {
0%, 4% {
transform: translateY(0);
}
6% {
transform: translateY(2px);
}
8%, 100% {
transform: translateY(0);
}
}
@keyframes nbf-panel-cut {
0%, 8.3% {
opacity: 0;
}
8.4%, 100% {
opacity: 1;
}
}
@keyframes nbf-panel-rise {
0%, 8.3% {
transform: translateY(0);
clip-path: inset(0 0 100% 0);
}
8.4%, 84% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
90%, 100% {
transform: translateY(0);
clip-path: inset(100% 0 0 0);
}
}
@keyframes nbf-dim1 {
0%, 34% {
opacity: 1;
}
34.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dim2 {
0%, 48% {
opacity: 1;
}
48.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dim3 {
0%, 62% {
opacity: 1;
}
62.1%, 100% {
opacity: 0.5;
}
}
@keyframes nbf-dot1 {
0%, 34% {
opacity: 1;
transform: scale(1);
}
34.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-dot2 {
0%, 48% {
opacity: 1;
transform: scale(1);
}
48.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-dot3 {
0%, 62% {
opacity: 1;
transform: scale(1);
}
62.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
@keyframes nbf-n3 {
0%, 34% {
opacity: 1;
}
34.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-n2 {
0%, 34% {
opacity: 0;
}
34.1%, 48% {
opacity: 1;
}
48.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-n1 {
0%, 48% {
opacity: 0;
}
48.1%, 62% {
opacity: 1;
}
62.1%, 100% {
opacity: 0;
}
}
@keyframes nbf-badge {
0%, 62% {
opacity: 1;
transform: scale(1);
}
62.1%, 100% {
opacity: 0;
transform: scale(0);
}
}
.frame.is-demo .bell {
animation-name: nbf-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.frame.is-demo .panel {
animation-name: nbf-panel-cut, nbf-panel-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.frame.is-demo .badge {
animation-name: nbf-badge;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n3 {
animation-name: nbf-n3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n2 {
animation-name: nbf-n2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .badge .n1 {
animation-name: nbf-n1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(1) .row {
animation-name: nbf-dim1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(1) .dot {
animation-name: nbf-dot1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(2) .row {
animation-name: nbf-dim2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(2) .dot {
animation-name: nbf-dot2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(3) .row {
animation-name: nbf-dim3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.frame.is-demo .rows li:nth-child(3) .dot {
animation-name: nbf-dot3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.bar {
padding: 4px 8px;
}
.brand {
font-size: 13px;
}
.panel {
top: 44px;
width: min(216px, 84vw);
padding: 4px;
}
.phead {
margin-bottom: 0;
}
.ptitle {
font-size: 11px;
}
.row {
padding: 0;
column-gap: 4px;
}
.rt {
font-size: 11px;
line-height: 1.25;
}
.rtime {
font-size: 10px;
line-height: 1.2;
}
.body {
gap: 4px;
padding: 0 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .bell,
.frame.is-demo .panel,
.frame.is-demo .badge,
.frame.is-demo .badge .n3,
.frame.is-demo .badge .n2,
.frame.is-demo .badge .n1,
.frame.is-demo .rows li .row,
.frame.is-demo .rows li .dot {
animation: none !important;
}
.frame.is-demo .panel {
opacity: 1 !important;
transform: none !important;
}
}// 실제 동작: 줄을 누르면 그 줄만 읽음이 되고 배지 숫자가 줄어든다. 화면 밖 aria-live 영역이 수를 갱신한다.
var root = document.getElementById('nbf');
var bell = document.getElementById('nbf-bell');
var panel = document.getElementById('nbf-panel');
var live = document.getElementById('nbf-live');
var badge = document.getElementById('nbf-badge');
var allBtn = document.getElementById('nbf-all');
var rows = Array.prototype.slice.call(root.querySelectorAll('.row'));
bell.setAttribute('aria-haspopup', 'true');
bell.setAttribute('aria-expanded', 'false');
panel.inert = true; // 닫힌 동안 패널 안 버튼은 탭 순서에서 빠진다
function unread() {
return rows.filter(function (r) { return !r.classList.contains('is-read'); });
}
function sync() {
root.classList.remove('is-demo'); // 첫 조작에서 격자 루프를 끈다
var n = unread().length;
root.classList.toggle('is-clear', n === 0);
badge.setAttribute('data-n', String(n));
live.textContent = live.dataset.fmt.replace('{n}', String(n)); // 문구는 data-fmt 템플릿에서 — JS 에 하드코딩하면 언어팩을 못 탄다
}
function setOpen(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
bell.setAttribute('aria-expanded', on ? 'true' : 'false');
panel.setAttribute('aria-hidden', on ? 'false' : 'true');
panel.inert = !on;
var hit = unread();
(on ? (hit.length ? hit[0] : rows[0]) : bell).focus({ preventScroll: true });
}
bell.addEventListener('click', function () { setOpen(!root.classList.contains('is-open')); });
allBtn.addEventListener('click', function () {
rows.forEach(function (r) { r.classList.add('is-read'); });
sync();
});
rows.forEach(function (r) {
r.addEventListener('click', function () { r.classList.add('is-read'); sync(); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) setOpen(false);
});import { useState, useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./NotificationBell.module.scss";
export type NotificationBellLabels = {
brand: string;
bellLabel: string;
panelTitle: string;
markAll: string;
unreadText: string;
items: Array<{ title: string; time: string }>;
};
export type NotificationBellProps = {
labels?: Partial<NotificationBellLabels>;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string; preventDefault(): void };
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
type PanelEl = { inert?: boolean };
type Box<T> = { current: T | null };
const defaults: NotificationBellLabels = {
brand: "Club",
bellLabel: "Notifications",
panelTitle: "Notifications",
markAll: "Mark all as read",
unreadText: "{n} unread",
items: [
{ title: "Your order started shipping", time: "just now" },
{ title: "2 coupons expiring soon", time: "2 hours ago" },
{ title: "New comment posted", time: "yesterday" },
],
};
function BellIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
}
export default function NotificationBell({
labels,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: NotificationBellProps) {
const L = { ...defaults, ...labels };
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [read, setRead] = useState<boolean[]>(L.items.map(() => false));
const bellRef = useRef<Focusable | null>(null);
const panelRef = useRef<PanelEl | null>(null);
const rowRefs = useRef<Array<Focusable | null>>([]);
// 닫힌 동안 패널 안 버튼은 탭 순서에서 빠진다 (inert)
useEffect(() => { if (panelRef.current) panelRef.current.inert = !open; }, [open]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const unread = read.filter((r) => !r).length;
function firstUnread() {
const rows = rowRefs.current; // Box 배열 ref null 가드
if (!rows) return undefined;
for (let j = 0; j < rows.length; j++) {
if (!read[j] && rows[j]) return rows[j];
}
return rows[0];
}
function setBell(on: boolean) {
setLive(false);
setOpen(on);
(on ? firstUnread() : bellRef.current)?.focus({ preventScroll: true });
}
function markRead(i: number) {
setLive(false);
setRead(read.map((r, j) => (j === i ? true : r)));
}
function markAll() {
setLive(false);
setRead(L.items.map(() => true));
}
function panelKeys(e: KeyEvt) {
if (e.key === "Escape") setBell(false);
}
const cls = [styles.frame, live ? styles.isDemo : "", open ? styles.isOpen : "", unread === 0 ? styles.isClear : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={L.brand}>
<div className={styles.page}>
<header className={styles.bar}>
<p className={styles.brand}>{L.brand}</p>
<button
className={styles.bell}
type="button"
aria-label={L.bellLabel}
aria-controls="nbf-panel"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setBell(!open)}
ref={(el: Focusable | null) => { bellRef.current = el; }}
>
<BellIcon />
<span className={styles.badge} data-n={String(unread)} aria-hidden="true">
<span className={styles.n3}>3</span>
<span className={styles.n2}>2</span>
<span className={styles.n1}>1</span>
</span>
</button>
</header>
<div className={styles.body}>
<p className={styles.skeleton} aria-hidden="true" />
<p className={styles.skeletonShort} aria-hidden="true" />
</div>
</div>
<aside
className={styles.panel}
id="nbf-panel"
aria-label={L.panelTitle}
aria-hidden={!open}
onKeyDown={panelKeys}
ref={(el: PanelEl | null) => { panelRef.current = el; }}
>
<header className={styles.phead}>
<p className={styles.ptitle}>{L.panelTitle}</p>
<button className={styles.allbtn} type="button" onClick={markAll}>
<CheckIcon />
<span>{L.markAll}</span>
</button>
</header>
<ul className={styles.rows}>
{L.items.map((it, i) => (
<li key={it.title}>
<button
type="button"
className={[styles.row, read[i] ? styles.isRead : ""].filter(Boolean).join(" ")}
onClick={() => markRead(i)}
ref={(el: Focusable | null) => { if (rowRefs.current) rowRefs.current[i] = el; }}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.rt}>{it.title}</span>
<span className={styles.rtime}>{it.time}</span>
</button>
</li>
))}
</ul>
</aside>
<p className={styles.sr} aria-live="polite">{L.unreadText.replace("{n}", String(unread))}</p>
</section>
);
}09 · Notification badge menu with live read state — Measured area 24.846% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It is the notification center at the top of an app.
function sync() {
var n = unread().length;
root.classList.toggle('is-clear', n === 0);
badge.setAttribute('data-n', String(n));
live.textContent = live.dataset.fmt.replace('{n}', String(n));
}
Where it breaks — traps
Three places stopped me this time.
The first was the width of 02. Even with the three levels narrowed to 112, 96, and 84px, the phone render measured 329px wide. The culprit was the slide itself. Each submenu hangs at left: 100% of its parent item, and a sliding parent's transform: translateX(12px) applies to its absolutely positioned children too, so the moment L2 and L3 move together, the menu reaches 24px farther than its resting size. The media query now uses 108, 92, and 80px, which lands 12.8 + 280 + 24 at 316.8px. A value that only overflows in the first instant of opening is one you catch by looking, not by reading layout numbers. Absolutely positioned lists also drop out of the parent's height math, so reserving vertical room at the root is the safer habit.
The second was the poster frame. The gallery preview picks the frame that differs most from the one before it, and in any open-and-close panel the closing moment tends to beat the opening one, because the titles and cards that were absent at opening are present at closing. Fading the close out slowly changed nothing. This site's measurement counts a pixel as moved when its largest channel difference passes 12, and a cream panel over an ink stage stays above that bar even at half alpha, so the area refused to shrink. The close is now a clip-path: inset wipe instead of an opacity change, which splits what one frame has to absorb. I ran the wipe downward from the head row, so the panel leaves the screen in reading order. The posters of 01, 03, and 09 recovered their open scenes this way.
The third was the wipe refusing to move in 09 at first. One of the panel's animations runs on steps(1, end) timing, and a clip-path placed in the same keyframes got stair-stepped with it, collapsing the wipe into a single cut. Moving clip-path into the eased sibling animation fixed it frame by frame. Steps cut values apart; they do not sweep across them. For the same reason, stagger delays belong in individual animation-* properties, since the animation: shorthand resets them to zero. The password that opens these nine folders is kes5pp8b, and every number quoted above sits untouched in the 측정.json files of the run folder. The same stair-step eats frames in CSS parallax scrolling, the closest cousin to how 04 and 05 turn scroll position into a value.
Accessibility
All nine stop their auto loop under prefers-reduced-motion: reduce and keep a static scene instead. 01, 02, and 03 stand open, 04 returns to the tall header, and 05 sets the bar back on its post under the title. 06 shows the sheet open with the bars folded into an X, 07 keeps the drawer open, 08 keeps the input unfolded, and 09 keeps its three unread rows. The door is the animation; the state of the door is a value, so turning motion off loses no information. Every open verdict lands in aria-expanded, and aria-haspopup is attached once by script. Every demo that moves focus calls focus({ preventScroll: true }), because these demos sit in the home listing inside iframes and an unguarded focus call drags the parent page down.
Contrast figures below were computed by hand with the WCAG relative luminance formula for the exact pairs this piece uses.
| Where | Text | Background | Ratio |
|---|---|---|---|
| 01·03·08 body ink / white panel | #17141a |
#ffffff |
18.24:1 |
| 01 highlight card white / blue | #ffffff |
#2f6df6 |
4.53:1 |
| 02·07·09 ink / cream panel | #17141a |
#fff7e6 |
17.11:1 |
| 04 ink / yellow stage | #17141a |
#ffd23f |
12.63:1 |
| 05 highlight chip cream / ink | #fff7e6 |
#17141a |
17.11:1 |
| 06 big links cream / overlay | #fff7e6 |
#241f2d |
15.05:1 |
| Mouse | Keyboard | Where state lives | |
|---|---|---|---|
| 01 | Hover or press the trigger | Focus opens, Esc closes | trigger aria-expanded |
| 02 | Hover an arrowed item | Tab enters submenus, Esc closes all | each parent button aria-expanded |
| 03 | Press a tab | Arrow keys move tabs, Esc closes | tab aria-selected, panel hidden |
| 04 | Scroll the feed | The scroll box itself takes focus | the header's .is-compact class |
| 05 | Scroll past sections | Chip anchors jump to sections | chip aria-current |
| 06 | Press the button | Tab cycles inside the sheet, Esc closes | sheet aria-modal, button aria-expanded |
| 07 | Press button, overlay or close | Page is inert, Tab stays in the drawer, Esc closes |
drawer and page aria-hidden |
| 08 | Press the magnifier and type | Esc collapses, focus returns to the button | button aria-expanded |
| 09 | Press bell, rows or mark-all | Focus lands on the first unread row, Esc closes | the count sentence in aria-live |
The verdict rules follow the MDN page on aria-haspopup and the IntersectionObserver reference. Neighbors on the same axis include the floating dock navigation, the sticky header, and the sidebar drawer transitions.
FAQ
When should I not use a mega menu
When the item count will never pass six or seven. The panel's whole value is the map it shows at once, and a small map is slower than a list. Below that line, the 02 flyout, or a custom select dropdown inside a form, answers faster. Decide by the size of the reader's map, not by how much you have to say.
Hover or click — what decides it
If the panel will cover half the screen or more, open on click; below that, hover is fine. A hover-open panel borrows attention from anyone just passing by, and it must close the moment the pointer leaves the whole group, as 01 does, or half-open states linger. 01 also accepts focus and click as open triggers, which covers touch and keyboard without a second component.
How do these mega menu css patterns change on mobile
Unfolding changes into covering. The desktop mega panel usually becomes the 06 fullscreen sheet or the 07 drawer on a narrow screen, while the 08 search starts from an icon and already fits. What must survive the swap is not the motion but the state contract: aria-expanded, inert, and focus returning to the trigger. Keep the contract, and it is still the same menu on a smaller stage. To try the 07 drawer in more directions and speeds, 9 CSS sidebar animations collects nine of them.









