メガメニュー css 9選 — リンクの羅列からサイトの地図へ
メガメニュー css とは、上部ナビの項目ひとつから画面いっぱいの幅のパネルを開き、サイト全体の構造を一目で渡すメニューの作り方のことです。ここに置いた九つは、そのパネルだけでなく、多段展開・スクロールでの縮小・モバイルへの切り替え・検索・通知まで、メニューがサイトで担う仕事九つを全部、動くコードで並べました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ホバーで開く全幅メガパネル
- 02 サブが右に滑る多段ドロップダウン
- 03 タブで切り替えるメガメニュー
- 04 スクロールで小さく折りたたむヘッダー
- 05 今の区画を知らせるスティッキーなサブナビ
- 06 ハンバーガーがXに折れるフルスクリーンオーバーレイ
- 07 右から滑り込むサイドドロワー
- 08 アイコンから入力欄へ広がる検索
- 09 既読が生きている通知バッジメニュー
並び順は人気順ではなく、メニューが背負う荷物が重くなる順です。最初の三つはデスクトップで情報を広げる方法です。行ひとつに吊るして丸ごと開き(01)、階層を横へ押し出し(02)、同じパネルの中で入れ替えます(03)。次の二つはスクロールと出会います。流れたら折りたたまれるヘッダー(04)と、区画について回り、今いる場所を告げるサブナビ(05)です。六番目と七番目は画面が狭くなったときの答えです。ハンバーガーが画面全体を覆う板へ折り(06)、引き出しが右から滑り込みます(07)。最後の二つはメニューに残った仕事、探すことと知らせることです。アイコンが入力欄へ広がり(08)、鐘がまだ読んでいない知らせを数えます(09)。最初の枠から最後の枠へ進むにつれ、メニューはリンクの目録からサイトの操縦席へ変わります。同じ軸の隣人はハンバーガーメニューのアニメーションとタブメニューの切り替えに、選択枠系はカスタムセレクトのドロップダウンにまとめてあります。ナビゲーションメニュー デザイン css で難しいのは並べ方ではなく、開いている間フォーカスとスクロールをどちらが持つかを決めることです。04 の縮むヘッダーだけを深く追うならCSS 固定ヘッダー 9選を、上端ではなく画面の下側に浮かべる形はフローティングメニュー デザイン9選を先に読むと、この並び順がより自然に見えます。どちらもヘッダー一行とスクロールの関係を扱っていますが、ここではその一行が、パネルやドロワーや検索まで連れて歩く九つの枠の最初の一枠にすぎません。
01ホバーで開く全幅メガパネル
何が見えるか
上部の大メニューにポインターを乗せると画面いっぱいの幅のパネルが降りてきて、カテゴリ列3つと注目カード1枚を一緒に見せます。開く動作は6px の translateY と一コマのカットで終わり、列の見出しがその後で時差を持って浮かぶので、2秒のループで24コマ中18コマが動きます。

<main class="stage">
<div class="fwp is-demo" id="root">
<header class="fwp__bar">
<span class="fwp__brand">ギフト箱</span>
<nav class="fwp__nav" aria-label="メインメニュー">
<a class="fwp__link" href="#">すべて</a>
<button class="fwp__trigger" id="trigger" type="button" aria-controls="panel">ギフト
<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="#">家具</a>
<a class="fwp__link" href="#">ヘルプ</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>季節のギフト</h3>
<ul><li>新年セット</li><li>夏の保冷</li><li>冬のブランケット</li></ul>
</section>
<section class="fwp__col">
<h3>価格帯</h3>
<ul><li>2万円台</li><li>5万円台</li><li>10万円以上</li></ul>
</section>
<section class="fwp__col">
<h3>贈り相手</h3>
<ul><li>家族</li><li>職場の同僚</li><li>友だち</li></ul>
</section>
<aside class="fwp__promo">
<span class="fwp__promo-label">今週のおすすめ</span>
<strong class="fwp__promo-title">夏の保冷セット</strong>
<a class="fwp__promo-link" href="#">詳しく見る
<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 · ホバーで開く全幅メガパネル — 計測 累積面積 12.979% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
製品群が3つ以上あるショッピングや SaaS の上部ナビに合います。
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(); });
02サブが右に滑る多段ドロップダウン
メガメニュー 作り方 の多段サブは画面でどう滑るか
第一段のリストで矢印付きの項目に触れると第二段が右から押し出され、その中の矢印付き項目が第三段まで続きます。下位の展開は CSS の :hover と :focus-within だけで決まるため、Tab キーで入っても同じ瞬間に開き、JS は最上位の開閉と aria-expanded だけを担います。

<main class="stage">
<div class="mls is-demo" id="root">
<button class="mls__trigger" id="trigger" type="button" aria-controls="l1">ライブラリ
<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="ライブラリメニュー">
<ul class="mls__u">
<li class="mls__i"><a class="mls__a" href="#">はじめる</a></li>
<li class="mls__i mls__i--parent mls__i--feat">
<button class="mls__b" type="button" aria-controls="l2">機能
<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="#">リストを作る</a></li>
<li class="mls__i mls__i--parent mls__i--share">
<button class="mls__b" type="button" aria-controls="l3">共有
<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="#">リンクをコピー</a></li>
<li class="mls__i"><a class="mls__a" href="#">コードを埋め込む</a></li>
</ul>
</li>
<li class="mls__i"><a class="mls__a" href="#">インポート</a></li>
</ul>
</li>
<li class="mls__i"><a class="mls__a" href="#">料金</a></li>
<li class="mls__i"><a class="mls__a" href="#">お問い合わせ</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 · サブが右に滑る多段ドロップダウン — 計測 累積面積 16.116% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
カテゴリが2〜3層になるドキュメント・ヘルプサイトのナビに使います。
// 下位段は親項目(li)のすぐ右 — hover/:focus-within だけでも開く
.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);
}
03タブで切り替えるメガメニュー
どんな動きか
パネル内のタブを押すと同じ場所でカードの束が一コマで入れ替わり、押したタブだけが選択された状態で読まれます。入れ替えは二つの束を混ぜない steps(1, end) のカットで切り分け、直後に新しいカードが translateY で時差ポップするので、カットだけの場合より動くコマ数が半分以上増えます。

<main class="stage">
<div class="tmp is-demo" id="root">
<header class="tmp__bar">
<span class="tmp__brand">生活雑貨</span>
<button class="tmp__trigger" id="trigger" type="button" aria-controls="panel">ショップ
<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="ショップメニュー" id="tablist">
<button class="tmp__tab" id="tab0" type="button" role="tab" aria-controls="set0" data-i="0">人気</button>
<button class="tmp__tab" id="tab1" type="button" role="tab" aria-controls="set1" data-i="1">新着</button>
<button class="tmp__tab" id="tab2" type="button" role="tab" aria-controls="set2" data-i="2">セール</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">壁掛けシェルフ</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">ミニ鉢</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">ムードライト</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">竹のデスク</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">グラスセット</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">チェスト</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%オフ</span></span>
<strong class="tmp__name">ラグ</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%オフ</span></span>
<strong class="tmp__name">ハンガーラック</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%オフ</span></span>
<strong class="tmp__name">ミラー</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 · タブで切り替えるメガメニュー — 計測 累積面積 17.594% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
同じカテゴリをいくつかの角度で見せるストアのナビに合います。
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');
}
});
}
04スクロールで小さく折りたたむヘッダー
何が見えるか
本文がスクロールして流れると、空のバーの背景とロゴが一緒に小さくなり、その分だけ本文が上へ引き寄せられ、戻れば元の大きさに戻ります。高さを変える代わりに、空のバーは scaleY、中身は scale、本文は translateY で折るので、縮小一回のリフローは0で、判定は本文の一番上に置いた番兵が画面から外れたかどうかだけで済みます。

<main class="stage">
<div class="ns is-demo">
<header class="ns__head">
<div class="ns__inner">
<p class="ns__brand">ニュースルーム</p>
<nav class="ns__nav">
<a class="ns__link" href="#">ホーム</a>
<a class="ns__link" href="#">経済</a>
<a class="ns__link" href="#">文化</a>
<a class="ns__link" href="#">スポーツ</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">朝のブリーフィング</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">週末の天気</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">新刊の本棚</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">町のサッカー</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">地下鉄の遅延</h3>
<i class="ns__bar"></i><i class="ns__bar ns__bar--s"></i>
</article>
<article class="ns__card">
<h3 class="ns__t">夜間開場</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 · スクロールで小さく折りたたむヘッダー — 計測 累積面積 63.282% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
一覧やマガジンのようにスクロールが長いページの上部に使います。
// 縮小は全部 transform なのでリフローは0のまま。
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'));
05今の区画を知らせるスティッキーなサブナビ
画面で起きること
セクションが流れる間はカテゴリバーが所定位置に張り付き、画面にかかったセクションが変わると強調がその枠へ移ります。現在のセクションは、スクロール箱をルートにした IntersectionObserver が画面の中心に最も近い区画を選び、チップの 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">マニュアル</h2>
</div>
<nav class="ss__bar">
<a class="ss__chip" href="#sec-1">はじめに</a>
<a class="ss__chip" href="#sec-2">設定</a>
<a class="ss__chip" href="#sec-3">通知</a>
<a class="ss__chip" href="#sec-4">共有</a>
<a class="ss__chip" href="#sec-5">拡張</a>
</nav>
<div class="ss__track">
<section class="ss__sec" id="sec-1">
<h3 class="ss__h">はじめに</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">設定</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">通知</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">共有</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">拡張</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 · 今の区画を知らせるスティッキーなサブナビ — 計測 累積面積 66.211% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
マニュアルやランディングのように1ページに複数セクションがある場所に合います。
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 });
06ハンバーガーがXに折れるフルスクリーンオーバーレイ
どんな動きか
3本の線が開いて X 字に折りたたまれ、画面全体を覆う板の上へリンクが1つずつ押し上がって現れます。板が開いている間、キーボードの焦点は板の中にだけ留まります。棒の変形は translateY と rotate の組で、リンクの到着はそれぞれ時差を持つので、第一印象が扉ではなく案内板になります。

<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">工房</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="メインメニュー">
<a class="fm__link" style="--i: 0" href="#">作品
<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="#">紹介
<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="#">記録
<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="#">お問い合わせ
<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 閉じる — 초점은 버튼으로
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 · ハンバーガーがXに折れるフルスクリーンオーバーレイ — 計測 累積面積 24.215% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
モバイルやポートフォリオのメインメニューに使います。
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 });
}
});
07右から滑り込むサイドドロワー
何が見えるか
ボタンを押すと右端からパネルが滑り込み、残りの画面は暗くなって触れなくなり、閉じると焦点は押したボタンへ戻ります。施錠は inert 属性ひとつです。本文に inert を付けると、後ろのリンクは目には残っても Tab とスクリーンリーダーが届かなくなり、引き出しを閉めれば両側が元に戻ります。

<main class="stage">
<section class="frame is-demo" id="sdr" aria-label="ドロワー">
<div class="page" id="sdr-page">
<header class="bar">
<p class="brand">ドロワー</p>
<button class="burger" id="sdr-open" type="button" aria-label="メニューを開く" 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">帽子 12,000円</span></li>
<li class="card"><span class="sw sw--b" aria-hidden="true"></span><span class="nm">靴下セット 8,000円</span></li>
<li class="card"><span class="sw sw--c" aria-hidden="true"></span><span class="nm">マグカップ 6,500円</span></li>
</ul>
</div>
</div>
<div class="veil" id="sdr-veil"></div>
<aside class="drawer" id="sdr-drawer" aria-label="マイドロワー" aria-hidden="true">
<header class="head">
<p class="title">マイドロワー</p>
<button class="xbtn" id="sdr-close" type="button" aria-label="閉じる">
<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>注文履歴</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>保存した商品</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>配送先</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>クーポン</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>設定</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 · 右から滑り込むサイドドロワー — 計測 累積面積 64.769% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
カートやアカウントメニューのように引き出しで開く画面に合います。
function setOpen(on) {
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 });
}
08アイコンから入力欄へ広がる検索
画面で起きること
虫眼鏡を押すとカプセル型の入力欄がアイコンの場所から広がり、数文字入れるとジャンプリンクがその下へ細く続きます。広げるときに幅を伸ばす代わりに、入力欄は常に全幅のまま clip-path: inset で右から覆われており、開いてもレイアウトの計算は起きません。

<main class="stage">
<section class="frame is-demo" id="sch" aria-label="資料倉庫">
<div class="page">
<header class="bar">
<p class="brand">資料倉庫</p>
<div class="search" id="sch-box">
<button class="glass" id="sch-btn" type="button" aria-label="検索を開く" 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="何をお探しですか" 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>テンプレート集</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>アイコンセット</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>入門ガイド</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 · アイコンから入力欄へ広がる検索 — 計測 累積面積 27.184% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
上部の余白が惜しいドキュメント・ブログのヘッダーに合います。
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px); // アイコンの40px だけ見える
$clip-open: inset(0 0 0 0 round 999px); // 全部見える
.search {
clip-path: $clip-shut;
transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }
09既読が生きている通知バッジメニュー
どんな動きか
鐘に付いた数字が未読の通知数を数え、パネルで項目を読むと点が消え、数字もその瞬間に減っていきます。バッジの数字は三つ重ねて置き steps(1, end) で一コマに入れ替えるので、数字が半透明に重なる瞬間はなく、画面の外の aria-live 領域が新しい数を読み上げます。

<main class="stage">
<section class="frame is-demo" id="nbf" aria-label="クラブ">
<div class="page">
<header class="bar">
<p class="brand">クラブ</p>
<button class="bell" id="nbf-bell" type="button" aria-label="通知" 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="通知" aria-hidden="true">
<header class="phead">
<p class="ptitle">通知</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>すべて既読にする</span>
</button>
</header>
<ul class="rows">
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">注文が出荷を始めました</span><span class="rtime">たった今</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">クーポン2枚がまもなく失効します</span><span class="rtime">2時間前</span></button></li>
<li><button class="row" type="button"><span class="dot" aria-hidden="true"></span><span class="rt">新しいコメントが付きました</span><span class="rtime">昨日</span></button></li>
</ul>
</aside>
<p class="sr" id="nbf-live" aria-live="polite" data-fmt="未読の通知{n}件">未読の通知3件</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 · 既読が生きている通知バッジメニュー — 計測 累積面積 24.846% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
アプリ上部の通知センターに使います。
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));
}
どこで壊れるか — 落とし穴
今回は三か所で手が止まりました。
一つ目は 02 の幅です。320px の電話で三つの段を 112・96・84px に縮めても、本文の幅は 329px と測られました。原因はスライドそのものでした。下位の段は親項目の left: 100% の位置に吊るされ、スライド中の親の transform: translateX(12px) は絶対配置の子にも掛かるため、L2 と L3 が同時に動く瞬間、静止位置より最大 24px 余分に外へ出ます。メディアクエリで幅を 108・92・80px に下げ、12.8 + 280 + 24 = 316.8px に収めました。開いた一瞬だけはみ出す値は、レイアウトの数字を読むより目で一度見なければ捕まえられません。絶対配置のリストは親の高さ計算からも外れるので、縦の余白も根元に確保しておくのが安全です。同じ確認は 480px の舞台でも 320px の電話でも別々に走らせました。舞台で大丈夫でも電話で壊れることがこの稿で二度ありました。
二つ目は代表場面です。一覧の poster は前のコマと最も違うコマを選ぶので、開いて閉じるパネルでは、開いた瞬間になかった見出しやカードが閉じる瞬間には入っている分、閉じる側が勝ちやすくなります。最初に閉じをゆっくりフェードさせても変わりませんでした。このサイトの測定は、画素の最大チャンネル差が 12 を超えたら動いたと数えるのであって、クリーム色のパネルがインクの舞台の上では、アルファが半分に下がっても差がその敷居を超え続け、面積が半分に縮まらなかったのです。そこで閉じを opacity ではなく clip-path: inset のワイプに変え、一コマが受け持つ面積を割りました。折る方向は上から下に決めました。パネルが上部の行に吊るされているので、吊られた側から順に下へ沈み込む見た目が自然だったためです。01・03・09 の poster はこの方法で開いた場面を取り戻しました。九枚の poster は最後まで必ず目で確かめます。数値が全部揃っていても、選ばれた一枚が主役を写しているかどうかは機械には測れないからです。実際、修正の前は 02 の poster が閉じた後の空っぽの背景を写していました。
三つ目は、そのワイプが 09 で最初まったく動かなかったことです。パネルに掛かったアニメーションのひとつが steps(1, end) のタイミングを使っており、同じキーフレームに置いた clip-path まで階段に切られて、ワイプが一コマのカットに縮んでいました。clip-path をイージングの掛かった別のアニメーションへ移して初めて、コマごとに少しずつ折りたたまれました。階段は値を切り分ける道具であって、値を撫でる道具ではありません。同じ理由で、時差を付ける遅延は animation: の一括指定が消してしまうので、個別の animation-* プロパティに書く習慣が今回も答えになりました。コマのタイミングを決めるときは、24コマの標本が 4.1667% 刻みで降ってくることも頭に置きます。キーフレームの区切り目をこの刻みの間に置くか、刻みの上に置くかで、一コマが受け持つ変化の大きさが変わるからです。この稿の 8.4% や 74.9% といった端数はすべて、その刻みの上に区切り目を載せるために選んだ値です。九つのフォルダを開く圧縮のパスワードは kes5pp8b で、上に挙げた数値はすべて run フォルダの 측정.json にそのまま残っています。スクロール位置を値に変える 04・05 の従兄弟にあたるCSS パララックス スクロール 9選でも、階段のタイミングは同じやり方で場面を食べてしまいます。
アクセシビリティ
九つとも prefers-reduced-motion: reduce では自動再生を止め、それぞれの静止した場面を残します。01・02・03 はパネルが開いたまま立ち、04 は大きなヘッダーに戻り、05 はカテゴリバーが見出しの下の定位置に戻って止まります。06 は板が開いて棒が X 字に折れた一枚を見せ、07 は引き出しが開いた場面を、08 は入力欄が広がった場面を、09 は未読の三行をそのまま残します。扉はアニメーションですが、扉の状態は値なので、動きを消しても情報は減りません。開閉の判定はすべて aria-expanded に書かれ、aria-haspopup はスクリプトが最初に一度だけ付けます。焦点を戻す五つのデモはどれも focus({ preventScroll: true }) を使います。このデモたちはホームの一覧に iframe で埋め込まれているため、ガードのない focus が親ページまでスクロールを引っ張るからです。
文字のコントラストは、WCAG の相対輝度の式にこの稿の組み合わせを入れて、それぞれ手計算で求めました。
| 使う場所 | 文字 | 背景 | 比率 |
|---|---|---|---|
| 01・03・08 本文のインク / 白いパネル | #17141a |
#ffffff |
18.24:1 |
| 01 注目カードの白 / 青 | #ffffff |
#2f6df6 |
4.53:1 |
| 02・07・09 インク / クリームのパネル | #17141a |
#fff7e6 |
17.11:1 |
| 04 インク / 黄色の舞台 | #17141a |
#ffd23f |
12.63:1 |
| 05 強調チップのクリーム / インク | #fff7e6 |
#17141a |
17.11:1 |
| 06 大きなリンクのクリーム / オーバーレイ | #fff7e6 |
#241f2d |
15.05:1 |
| マウス | キーボード | 状態の居場所 | |
|---|---|---|---|
| 01 | トリガーに乗せる・押す | フォーカスで開き、Esc で閉じる | トリガーの aria-expanded |
| 02 | 矢印付きの項目に乗せる | Tab で下位に入り、Esc で全部閉じる | 各親ボタンの aria-expanded |
| 03 | タブを押す | 左右矢印でタブ移動、Esc で閉じる | タブの aria-selected、パネルの hidden |
| 04 | 本文をスクロールする | スクロール箱そのものがフォーカスを持つ | ヘッダーの .is-compact クラス |
| 05 | セクションを流す | チップのアンカーで区画へ移動 | チップの aria-current |
| 06 | ボタンを押す | Tab が板の中を巡り、Esc で閉じる | 板の aria-modal、ボタンの aria-expanded |
| 07 | ボタン・オーバーレイ・閉じるを押す | 本文は inert、Tab は引き出しの中、Esc で閉じる |
引き出しと本文の aria-hidden |
| 08 | 虫眼鏡を押して入力する | Esc で畳み、焦点はボタンへ戻る | ボタンの aria-expanded |
| 09 | 鐘・行・すべて既読を押す | 最初の未読行へ焦点、Esc で閉じる | aria-live 領域の数の一文 |
判定の基準はMDN の aria-haspopupとIntersectionObserver の文書に従いました。同じ軸の隣人には浮かぶドックナビゲーションとスティッキーヘッダー、サイドドロワーの切り替えがあります。
FAQ
メガメニューを使うべきでないときはどんなときですか
項目が七つを超えないと分かっているときです。パネルの利点は一度に見せる地図ですが、項目が少ないと地図は目録より遅くなります。その場合は 02 の多段ドロップダウン、フォームの中ならカスタムセレクトのドロップダウンのほうが早い答えです。語りたい量ではなく、読む人の地図の大きさで決めてください。
ホバーとクリックは何で決めればいいですか
パネルが開いたときに画面の半分以上を覆うならクリック、それより小さければホバーで構いません。ホバーは通りすがりにも開く分、他人の操作を盗みやすく、01 のように閉じを、ポインターが画面の外へ出る瞬間に結び付けないと、半開きの状態が残ります。01 はフォーカスとクリックも開く条件に入れており、タッチとキーボードを別の部品なしで支えています。
メガメニュー css レスポンシブ ではこのパターンをどう変えますか
広げることを、覆うことに着せ替えます。デスクトップのメガパネルは、狭い画面では 06 のフルスクリーンか 07 のドロワーに変わるのが普通で、08 の検索はアイコンから始まる構造のまま狭い幅とよく合います。付け替えても残すのは動きではなく、aria-expanded・inert・トリガーへの焦点復帰という状態の約束です。約束が同じなら、画面が狭くなっても同じメニューです。07 の引き出しだけを向きや速さを変えて試したい方には、css ドロワー メニュー スライド9選に九つ揃えてあります。









