css ドロワー メニュー スライド9選 — JS最小・CSSだけ
css ドロワーは、画面の端に隠れたパネルがトリガー操作で滑り出したり、位置を変えずにその場で現れたりするナビゲーションUIです。この記事はtransformとopacityを軸に9種類の開き方を集め、うち01・03・08の3つは実際にinertで背景を封じるフォーカストラップ付きのJSも動かしています。
01 sidebar-drawer-left-slide
02 sidebar-drawer-dialog-drawer
03 sidebar-drawer-overlay-fade
04 sidebar-drawer-bottom-sheet-snap
05 sidebar-drawer-push-content
06 sidebar-drawer-mini-expand
07 sidebar-drawer-scroll-hide
08 sidebar-drawer-nested-menu
09 sidebar-drawer-tab-bar
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 左スライドドロワー
- 02 dialogベースのドロワー
- 03 オーバーレイフェード
- 04 ボトムシートスナップ
- 05 コンテンツを押し出す
- 06 ミニ→拡張
- 07 スクロールで隠れる
- 08 入れ子メニュー
- 09 下部タブバー
並び順は「css サイドバー 開閉」に必要なコードの量を基準にしています。01は最も定番のスライド構成で、02はネイティブのdialog要素にほとんどの仕事を任せます。03から07までは「開く」という動作自体の作りが違う項目(位置を動かさない、コンテンツごと動く、常に浮いている)を並べ、08は階層が増える場合、09はサイドバーという概念そのものがモバイルで別のUIに置き換わる場合です。短く出て自分で消える板ならトースト通知のほうがはるかに軽く、枠はそのままで中身だけ入れ替える方式はタブ切り替えアニメーションが担当します。
01左スライドドロワー
画面左外から滑り出す
パネルは画面左外のtranslateX(-100%)からtranslateX(0)まで動いて開き、オーバーレイはopacityだけで暗くなります。開いている間は背後の#pageにinertとaria-hiddenが付き、Tabキーは最初と最後の要素で折り返されてドロワーの外に出ません。

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer1">メニューを開く</button>
</div>
<div class="overlay"></div>
<nav class="drawer" id="drawer1" role="dialog" aria-modal="true" aria-labelledby="t1">
<h2 id="t1">メニュー</h2>
<a class="drawer__link" href="#">ホーム</a>
<a class="drawer__link" href="#">製品</a>
<a class="drawer__link" href="#">サポート</a>
<button class="drawer__close" type="button">閉じる</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 600ms — 왼쪽에서 밀려 들어오는 시간
$easing: $ease-spring;
$color: $subject-cream; // 트리거 버튼 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .55);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-6 $sp-4;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
}
.drawer__link {
padding: $sp-2 $sp-1;
border-radius: $r-sm;
color: $stage-ink; text-decoration: none; font-weight: 500; font-size: 13px; line-height: 1; font-family: inherit;
&:hover, &:focus-visible { background: rgba($stage-ink, .08); }
}
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $stage-ink; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
// 실측: 데모 루프는 열림→머묾→닫힘을 2초 안에 반복(1번 항목과 같은 구조)
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
15%, 70% { opacity: 1; }
85% { opacity: 0; }
}
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
15%, 70% { transform: translateX(0); }
85% { transform: translateX(-100%); }
}
// ── 접근성: 움직임 줄이기 설정이면 여닫힘 애니메이션만 제거 ──────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer { animation: none; }
.overlay, .drawer { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer__link {
padding: 8px 4px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.drawer__link:hover, .drawer__link:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
15%, 70% {
opacity: 1;
}
85% {
opacity: 0;
}
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
15%, 70% {
transform: translateX(0);
}
85% {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer {
animation: none;
}
.overlay, .drawer {
transition-duration: 1ms;
}
}// 열림: 배경(#page)을 inert 로 막고 첫 링크로 포커스. 닫힘: inert 해제, 트리거로 포커스 복귀.
// Tab 키다운마다 포커스 가능 목록의 처음/끝을 감시해 드로어 밖으로 못 나가게 한다(포커스 트랩).
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var drawer = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
function focusables() { return drawer.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LeftSlideDrawer.module.scss";
export type LeftSlideDrawerProps = {
/** 왼쪽에서 밀려 들어오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
export default function LeftSlideDrawer({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: LeftSlideDrawerProps) {
const [open, setOpen] = useState(false);
const drawerRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) {
triggerRef.current?.focus();
return;
}
const drawer = drawerRef.current;
if (!drawer) return;
const focusables = () => Array.from(drawer.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
메뉴 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<nav
ref={drawerRef}
className={styles.drawer}
data-open={open}
role="dialog"
aria-modal="true"
aria-label="메뉴"
>
<h2>메뉴</h2>
<a className={styles.link} href="#">홈</a>
<a className={styles.link} href="#">제품</a>
<a className={styles.link} href="#">고객센터</a>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</nav>
</div>
);
}01 · 左スライドドロワー — 計測 累積面積 48.566% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
背景をinertで塞ぐ
.drawer {
position: fixed; left: 0; top: 0; bottom: 0;
transform: translateX(-100%);
width: min(76vw, 240px);
transition: transform $duration $easing;
}
.overlay {
position: fixed; inset: 0;
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
02dialogベースのドロワー
ブラウザに任せられる仕事
ネイティブのdialog要素を画面左に固定し、showModal()で開くとフォーカス移動・ESC閉じ・外側クリックの遮断をブラウザが肩代わりします。実際の開閉には@starting-styleとallow-discreteを使い、[open]が付く瞬間・外れる瞬間にもtransformの遷移が効くようにしています。

<main class="stage is-demo">
<div class="page">
<button class="trigger" type="button">設定を開く</button>
</div>
<dialog class="drawer" id="d2" aria-labelledby="t2">
<h2 id="t2">設定</h2>
<p>このパネルはブラウザ標準のdialog要素だ。ESC・外側クリック・フォーカストラップは自前で書いていない。</p>
<button class="drawer__close" type="button">閉じる</button>
</dialog>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 실사용 여닫힘 속도
$easing: $ease-spring;
$color: $subject-cream; // 트리거·닫기 버튼 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-yellow);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-yellow; background: $stage-ink;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
// UA 기본(가운데 고정·테두리·max-width)을 지우고 좌측 전체 높이 패널로
.drawer {
position: fixed; inset: 0 auto 0 0; margin: 0;
width: min(76vw, 240px); height: 100%; max-height: none;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-6 $sp-4;
border: 0; border-radius: 0;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 $sp-4; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .7); }
&::backdrop { background: rgba($stage-ink, .55); }
}
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $color; color: $stage-ink;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
// 실사용(모던 브라우저): [open] 이 붙는 순간·떨어지는 순간에도 트랜지션이 걸리도록
// @starting-style + transition-behavior: allow-discrete. 값은 transform 만 움직인다.
.drawer {
transition: transform $duration $easing, overlay $duration allow-discrete, display $duration allow-discrete;
transform: translateX(-100%);
}
.drawer[open] { transform: translateX(0); }
@starting-style {
.drawer[open] { transform: translateX(-100%); }
}
// 데모 루프(.is-demo): showModal 로 이미 열려 있는 상태에서 keyframes 로 2초 주기 재생
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
.stage.is-demo .drawer::backdrop { animation: backdrop-loop $dur-loop ease-in-out infinite; }
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
15%, 70% { transform: translateX(0); }
85% { transform: translateX(-100%); }
}
@keyframes backdrop-loop {
0%, 100% { opacity: 0; }
15%, 70% { opacity: 1; }
85% { opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 애니메이션 제거. ESC·포커스 트랩은 브라우저 기본이라 그대로 남는다 ──
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .drawer, .stage.is-demo .drawer::backdrop { animation: none; }
.drawer { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffd23f;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.drawer {
position: fixed;
inset: 0 auto 0 0;
margin: 0;
width: min(76vw, 240px);
height: 100%;
max-height: none;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 16px;
border: 0;
border-radius: 0;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer p {
margin: 0 0 16px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.7);
}
.drawer::backdrop {
background: rgba(23, 20, 26, 0.55);
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.drawer {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), overlay 300ms allow-discrete, display 300ms allow-discrete;
transform: translateX(-100%);
}
.drawer[open] {
transform: translateX(0);
}
@starting-style {
.drawer[open] {
transform: translateX(-100%);
}
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .drawer::backdrop {
animation: backdrop-loop 2s ease-in-out infinite;
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
15%, 70% {
transform: translateX(0);
}
85% {
transform: translateX(-100%);
}
}
@keyframes backdrop-loop {
0%, 100% {
opacity: 0;
}
15%, 70% {
opacity: 1;
}
85% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .drawer, .stage.is-demo .drawer::backdrop {
animation: none;
}
.drawer {
transition-duration: 1ms;
}
}// showModal 한 줄로 열면 포커스 이동·ESC 閉じる·바깥 클릭 차단을 브라우저가 대신 한다.
// 로드 시엔 is-demo 를 그대로 두고 열어 데모 루프(keyframes)가 재생되게 한다.
// 실제 클릭(閉じる·바깥·재오픈)이 오면 그때 is-demo 를 떼 실제 상호작용으로 넘긴다.
(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var dlg = document.getElementById('d2');
var closeBtn = document.querySelector('.drawer__close');
function realOpen() { stage.classList.remove('is-demo'); dlg.showModal(); }
dlg.addEventListener('close', function () { stage.classList.remove('is-demo'); });
closeBtn.addEventListener('click', function () { dlg.close(); });
dlg.addEventListener('click', function (e) { if (e.target === dlg) dlg.close(); }); // ::backdrop 클릭과 같은 효과
trigger.addEventListener('click', realOpen);
dlg.showModal();
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./DialogDrawer.module.scss";
export type DialogDrawerProps = {
duration?: number;
easing?: string;
color?: string;
/** 마운트되면 바로 열어 보여줄지(데모용) */
defaultOpen?: boolean;
};
export default function DialogDrawer({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
defaultOpen = false,
}: DialogDrawerProps) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
if (open && !dlg.open) dlg.showModal();
if (!open && dlg.open) dlg.close();
}, [open]);
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClose = () => setOpen(false);
dlg.addEventListener("close", onClose);
return () => dlg.removeEventListener("close", onClose);
}, []);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button className={styles.trigger} type="button" onClick={() => setOpen(true)}>설정 열기</button>
<dialog
ref={dialogRef}
className={styles.drawer}
aria-label="설정"
onClick={(e: MouseEvent<HTMLDialogElement>) => { if (e.target === dialogRef.current) setOpen(false); }}
>
<h2>설정</h2>
<p>브라우저의 dialog 요소가 ESC·포커스 가두기를 대신 관리한다.</p>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</dialog>
</div>
);
}02 · dialogベースのドロワー — 計測 累積面積 98.965% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
開く瞬間にも遷移を効かせる
.drawer {
position: fixed; inset: 0 auto 0 0; margin: 0;
width: min(76vw, 240px); height: 100%;
transition: transform $duration $easing,
overlay $duration allow-discrete, display $duration allow-discrete;
transform: translateX(-100%);
}
.drawer[open] { transform: translateX(0); }
@starting-style {
.drawer[open] { transform: translateX(-100%); }
}
03オーバーレイフェード
位置を保ったまま現れる
パネルは左の位置を保ったままopacityとtransform: scaleだけで現れ、背後のオーバーレイはbackdrop-filterでぼかされます。01の単色オーバーレイと違い、パネル自体は移動せずその場で拡大するだけです。

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer3">フィルターを開く</button>
</div>
<div class="overlay"></div>
<div class="drawer" id="drawer3" role="dialog" aria-modal="true" aria-labelledby="t3">
<h2 id="t3">フィルター</h2>
<p>価格・配送・カラーをここで選ぶ。</p>
<button class="drawer__close" type="button">閉じる</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 자리에서 나타나는 시간
$easing: $ease-out;
$color: $subject-cream; // 트리거 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-orange);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $subject-orange-deep; background: $color; // AA: 크림 면 위 주황 글자 5.04:1
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
// 프로스트 유리 느낌: opacity + backdrop-filter 로 흐려진다(01 의 단색 어둡기와 다르다)
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .28);
backdrop-filter: blur(0px);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
// 패널은 왼쪽에서 움직이지 않는다 — opacity·transform:scale 만으로 나타난다
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: scale(.96);
transform-origin: left center; // 규칙8: 왼쪽 붙은 패널 좌변 기준
opacity: 0;
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-6 $sp-4;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 $sp-4; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .7); }
}
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $stage-orange; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(6px); pointer-events: auto; }
.stage.is-open .drawer { opacity: 1; transform: scale(1); pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; backdrop-filter: blur(0px); }
15%, 70% { opacity: 1; backdrop-filter: blur(6px); }
85% { opacity: 0; backdrop-filter: blur(0px); }
}
@keyframes drawer-loop {
0%, 100% { opacity: 0; transform: scale(.96); }
15%, 70% { opacity: 1; transform: scale(1); }
85% { opacity: 0; transform: scale(.96); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer { animation: none; }
.overlay, .drawer { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #c93000;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.28);
backdrop-filter: blur(0px);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out, backdrop-filter 300ms ease-out;
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: scale(0.96);
transform-origin: left center;
opacity: 0;
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer p {
margin: 0 0 16px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.7);
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #ff4d1f;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
backdrop-filter: blur(6px);
pointer-events: auto;
}
.stage.is-open .drawer {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
backdrop-filter: blur(0px);
}
15%, 70% {
opacity: 1;
backdrop-filter: blur(6px);
}
85% {
opacity: 0;
backdrop-filter: blur(0px);
}
}
@keyframes drawer-loop {
0%, 100% {
opacity: 0;
transform: scale(0.96);
}
15%, 70% {
opacity: 1;
transform: scale(1);
}
85% {
opacity: 0;
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer {
animation: none;
}
.overlay, .drawer {
transition-duration: 1ms;
}
}// 패널은 이동하지 않고 자리에서 opacity·scale 로만 나타난다. 열림 시 #page 를 inert 로 막는다.
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var panel = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
function focusables() { return panel.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OverlayFadeDrawer.module.scss";
export type OverlayFadeDrawerProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function OverlayFadeDrawer({
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: OverlayFadeDrawerProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) { triggerRef.current?.focus({ preventScroll: true }); return; }
const panel = panelRef.current;
if (!panel) return;
const focusables = () => Array.from(panel.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
필터 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<div ref={panelRef} className={styles.drawer} data-open={open} role="dialog" aria-modal="true" aria-label="필터">
<h2>필터</h2>
<p>가격·배송·색상 옵션을 여기서 고른다.</p>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</div>
</div>
);
}03 · オーバーレイフェード — 計測 累積面積 100.0% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
単色オーバーレイとの違い
.overlay {
opacity: 0; backdrop-filter: blur(0px);
transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
.drawer {
transform: scale(.96); transform-origin: left center; opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(6px); }
.stage.is-open .drawer { opacity: 1; transform: scale(1); }
04ボトムシートスナップ
3値を巡るdata-snap
つまみを押すたびにdata-snap属性がpeek・half・fullの3値を巡り、それぞれに対応するtranslate(-50%, N%)の値ひとつだけが効きます。ドラッグの座標計算を一切せず、状態の切り替えだけで3段階のスナップを再現しています。

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="sheet4">リストを開く</button>
</div>
<div class="overlay"></div>
<div class="sheet" id="sheet4" role="dialog" aria-modal="true" aria-labelledby="t4" data-snap="half">
<button class="sheet__handle" type="button" aria-label="シートサイズ切替(peek・half・full)"></button>
<h2 id="t4">周辺のスポット12件</h2>
<p>ハンドルを押すたびに peek → half → full の順に変わる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 스냅마다 딱 멈추는 시간
$easing: $ease-out; // 관성 없이 멈춘다(스프링 아님)
$color: $subject-ink; // 손잡이·강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .45);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
// 세 스냅 값은 시트 자기 높이 기준 %(transform 만 사용)
.sheet {
position: fixed; left: 50%; bottom: 0; top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 300px);
box-sizing: border-box;
padding: $sp-2 $sp-4 $sp-6;
border-radius: $r-card $r-card 0 0;
background: $subject-cream; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: $sp-2 0 $sp-1; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .7); }
}
.sheet__handle {
display: block;
width: 36px; height: $sp-1; margin: 0 auto;
border: 0; border-radius: $r-pill; background: rgba($stage-ink, .28);
cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .sheet { pointer-events: auto; }
.stage.is-open .sheet[data-snap="peek"] { transform: translate(-50%, 78%); }
.stage.is-open .sheet[data-snap="half"] { transform: translate(-50%, 40%); }
.stage.is-open .sheet[data-snap="full"] { transform: translate(-50%, 0); }
// 데모 루프: 닫힘 → peek → half → full → peek 순으로 2초 안에 한 바퀴
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .sheet { animation: sheet-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
10%, 90% { opacity: 1; }
95% { opacity: 0; }
}
@keyframes sheet-loop {
0% { transform: translate(-50%, 100%); }
10% { transform: translate(-50%, 78%); }
40% { transform: translate(-50%, 40%); }
70% { transform: translate(-50%, 0); }
90% { transform: translate(-50%, 78%); }
100% { transform: translate(-50%, 100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet { animation: none; }
.overlay, .sheet { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.sheet {
position: fixed;
left: 50%;
bottom: 0;
top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 300px);
box-sizing: border-box;
padding: 8px 16px 24px;
border-radius: 16px 16px 0 0;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 300ms ease-out;
}
.sheet h2 {
margin: 8px 0 4px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.sheet p {
margin: 0;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.7);
}
.sheet__handle {
display: block;
width: 36px;
height: 4px;
margin: 0 auto;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.28);
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .sheet {
pointer-events: auto;
}
.stage.is-open .sheet[data-snap=peek] {
transform: translate(-50%, 78%);
}
.stage.is-open .sheet[data-snap=half] {
transform: translate(-50%, 40%);
}
.stage.is-open .sheet[data-snap=full] {
transform: translate(-50%, 0);
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .sheet {
animation: sheet-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
10%, 90% {
opacity: 1;
}
95% {
opacity: 0;
}
}
@keyframes sheet-loop {
0% {
transform: translate(-50%, 100%);
}
10% {
transform: translate(-50%, 78%);
}
40% {
transform: translate(-50%, 40%);
}
70% {
transform: translate(-50%, 0);
}
90% {
transform: translate(-50%, 78%);
}
100% {
transform: translate(-50%, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet {
animation: none;
}
.overlay, .sheet {
transition-duration: 1ms;
}
}// 손잡이를 누를 때마다 data-snap 을 세 값 사이로 순환시킨다. CSS 는 그 값에 맞는 translateY 로만 반응.
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var sheet = document.querySelector('.sheet');
var handle = document.querySelector('.sheet__handle');
var steps = ['peek', 'half', 'full'];
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
handle.focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open'); sheet.dataset.snap = 'half';
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
handle.addEventListener('click', function () {
var i = steps.indexOf(sheet.dataset.snap);
sheet.dataset.snap = steps[(i + 1) % steps.length];
});
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (stage.classList.contains('is-open') && e.key === 'Escape') shut();
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomSheetSnap.module.scss";
export type SnapStep = "closed" | "peek" | "half" | "full";
export type BottomSheetSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
const STEPS: SnapStep[] = ["peek", "half", "full"];
export default function BottomSheetSnap({
duration = 300,
easing = "ease-out",
color = "#17141a",
}: BottomSheetSnapProps) {
const [snap, setSnap] = useState<SnapStep>("closed");
const open = snap !== "closed";
function cycle() {
const i = STEPS.indexOf(snap === "closed" ? "peek" : snap);
setSnap(STEPS[(i + 1) % STEPS.length]);
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button className={styles.trigger} type="button" onClick={() => setSnap("half")}>목록 열기</button>
<div className={styles.overlay} data-open={open} onClick={() => setSnap("closed")} />
<div className={styles.sheet} data-snap={snap} role="dialog" aria-modal="true" aria-label="주변 장소 12곳">
<button
className={styles.handle}
type="button"
aria-label="시트 크기 전환(peek·half·full)"
onClick={cycle}
/>
<h2>주변 장소 12곳</h2>
<p>손잡이를 누를 때마다 peek → half → full 순으로 바뀐다.</p>
</div>
</div>
);
}04 · ボトムシートスナップ — 計測 累積面積 97.949% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
座標計算をしないスナップ
.sheet {
transform: translate(-50%, 100%);
transition: transform $duration $easing;
}
.stage.is-open .sheet[data-snap="peek"] { transform: translate(-50%, 78%); }
.stage.is-open .sheet[data-snap="half"] { transform: translate(-50%, 40%); }
.stage.is-open .sheet[data-snap="full"] { transform: translate(-50%, 0); }
05コンテンツを押し出す
同じdurationで場所を分け合う
サイドバーと隣のコンテンツが同じdurationでtranslateX(-100%→0)とtranslateX(0→40%)を同時に動き、marginを使わずに場所を分け合います。オーバーレイがないためモーダルではなく常設のナビゲーションとして扱い、inertの代わりにリンクのaria-hiddenとtabIndexを開閉に合わせて切り替えます。

<main class="stage is-demo">
<div class="layout">
<nav class="drawer" aria-label="サイドナビゲーション" aria-hidden="true">
<a class="drawer__link" href="#" tabindex="-1">ダッシュボード</a>
<a class="drawer__link" href="#" tabindex="-1">レポート</a>
<a class="drawer__link" href="#" tabindex="-1">設定</a>
</nav>
<div class="content">
<button class="trigger" type="button" aria-expanded="false">サイドバーを開く</button>
<p class="content__line">本文エリアはオーバーレイなしで横にずれる。</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 600ms — 사이드바·콘텐츠가 함께 이동하는 시간
$easing: $ease-spring;
$color: $subject-blue; // 링크 hover 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.layout {
position: relative;
overflow: hidden;
width: min(88vw, 320px);
height: min(50vw, 150px);
border-radius: $r-card;
background: $stage-paper;
}
// 드로어는 콘텐츠 밑에 고정, 처음엔 왼쪽 밖으로 밀려 있다
.drawer {
position: absolute; inset: 0 auto 0 0;
width: 40%;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-4 $sp-2;
background: $stage-ink; color: $stage-paper;
transform: translateX(-100%);
transition: transform $duration $easing;
z-index: 1;
}
.drawer__link {
padding: $sp-1;
border-radius: $r-xs;
color: $stage-paper; text-decoration: none; font-weight: 500; font-size: 12px; line-height: 1; font-family: inherit;
&:hover, &:focus-visible { background: rgba($stage-paper, .16); color: $color; }
}
// 콘텐츠는 항상 레이아웃 전체 폭 — 오른쪽으로 밀려 드로어를 드러낸다(오버레이 없음)
.content {
position: relative;
height: 100%;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2; justify-content: center;
padding: $sp-4;
background: $stage-paper; color: $stage-ink;
transform: translateX(0);
transition: transform $duration $easing;
z-index: 2;
}
.content__line { margin: 0; font-size: clamp(10px, 3vmin, 12px); color: rgba($stage-ink, .65); }
.trigger {
align-self: flex-start;
border: 0; border-radius: $r-pill;
padding: $sp-2 $sp-4;
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $stage-ink;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.stage.is-open .drawer { transform: translateX(0); }
.stage.is-open .content { transform: translateX(40%); }
// 데모 루프: 닫힘 → 밀려남 → 닫힘 순으로 2초에 한 바퀴
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
.stage.is-demo .content { animation: content-loop $dur-loop $easing infinite; }
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
15%, 70% { transform: translateX(0); }
85% { transform: translateX(-100%); }
}
@keyframes content-loop {
0%, 100% { transform: translateX(0); }
15%, 70% { transform: translateX(40%); }
85% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .drawer, .stage.is-demo .content { animation: none; }
.drawer, .content { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.layout {
position: relative;
overflow: hidden;
width: min(88vw, 320px);
height: min(50vw, 150px);
border-radius: 16px;
background: #ffffff;
}
.drawer {
position: absolute;
inset: 0 auto 0 0;
width: 40%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 8px;
background: #17141a;
color: #ffffff;
transform: translateX(-100%);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
.drawer__link {
padding: 4px;
border-radius: 4px;
color: #ffffff;
text-decoration: none;
font-weight: 500;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.drawer__link:hover, .drawer__link:focus-visible {
background: rgba(255, 255, 255, 0.16);
color: #2f6df6;
}
.content {
position: relative;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
padding: 16px;
background: #ffffff;
color: #17141a;
transform: translateX(0);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 2;
}
.content__line {
margin: 0;
font-size: clamp(10px, 3vmin, 12px);
color: rgba(23, 20, 26, 0.65);
}
.trigger {
align-self: flex-start;
border: 0;
border-radius: 999px;
padding: 8px 16px;
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.stage.is-open .drawer {
transform: translateX(0);
}
.stage.is-open .content {
transform: translateX(40%);
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .content {
animation: content-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
15%, 70% {
transform: translateX(0);
}
85% {
transform: translateX(-100%);
}
}
@keyframes content-loop {
0%, 100% {
transform: translateX(0);
}
15%, 70% {
transform: translateX(40%);
}
85% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .drawer, .stage.is-demo .content {
animation: none;
}
.drawer, .content {
transition-duration: 1ms;
}
}// 모달이 아니라 상시 내비게이션이라 inert 대신 aria-hidden·tabindex 로만 여닫힘을 알린다.
(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var drawer = document.querySelector('.drawer');
var links = drawer.querySelectorAll('a');
function set(open) {
stage.classList.remove('is-demo');
stage.classList.toggle('is-open', open);
trigger.setAttribute('aria-expanded', String(open));
drawer.setAttribute('aria-hidden', String(!open));
links.forEach(function (a) { a.tabIndex = open ? 0 : -1; });
}
trigger.addEventListener('click', function () { set(!stage.classList.contains('is-open')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && stage.classList.contains('is-open')) { set(false); trigger.focus({ preventScroll: true }); }
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PushContentDrawer.module.scss";
export type PushContentDrawerProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function PushContentDrawer({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PushContentDrawerProps) {
const [open, setOpen] = useState(false);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.layout}>
<nav className={styles.drawer} data-open={open} aria-label="사이드 내비게이션" aria-hidden={!open}>
<a className={styles.link} href="#" tabIndex={open ? 0 : -1}>대시보드</a>
<a className={styles.link} href="#" tabIndex={open ? 0 : -1}>보고서</a>
<a className={styles.link} href="#" tabIndex={open ? 0 : -1}>설정</a>
</nav>
<div className={styles.content} data-open={open}>
<button className={styles.trigger} type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
사이드바 열기
</button>
<p className={styles.line}>본문 영역은 오버레이 없이 옆으로 자리를 내준다.</p>
</div>
</div>
</div>
);
}05 · コンテンツを押し出す — 計測 累積面積 15.844% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
モーダルではない常設ナビ
.drawer {
transform: translateX(-100%);
transition: transform $duration $easing;
}
.content {
transform: translateX(0);
transition: transform $duration $easing;
}
.stage.is-open .drawer { transform: translateX(0); }
.stage.is-open .content { transform: translateX(40%); }
06ミニ→拡張レール
「css サイドバー 折りたたみ」の逆数スケール
アイコンだけの細いレールをscaleXで縮めておき、内側の.rail__innerには正確に逆数のscaleXを掛けて、hover・focus-withinで広がっても文字が潰れないようにします。「ドロワー メニュー css のみ」で探す形どおりJSは使わずCSSのセレクタだけで開閉し、tabindex="0"を付けてキーボードフォーカスでも同じ拡張が起きるようにしています。

<main class="stage is-demo">
<nav class="rail" tabindex="0" aria-label="サイドナビゲーション">
<div class="rail__inner">
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">ホーム</span></a>
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">検索</span></a>
<a class="rail__item" href="#"><span class="rail__ic" aria-hidden="true"></span><span class="rail__label">設定</span></a>
</div>
</nav>
</main>@use "sass:math";
@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 넓어지는 시간
$easing: $ease-out;
$color: $stage-yellow; // 활성 아이콘 강조색(배경 대비용, 아래서 재사용)
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-yellow);
$collapsed: 56px;
$expanded: 200px;
$ratio: math.div($collapsed, $expanded);
.rail {
position: relative;
overflow: hidden;
width: $expanded;
height: min(50vw, 150px);
border-radius: $r-card;
background: $stage-ink;
transform: scaleX($ratio);
transform-origin: left center; // 규칙8: 레일 좌변 scaleX 접힘·펼침
transition: transform $duration $easing;
cursor: pointer;
}
// 안쪽은 바깥의 scaleX 를 역으로 상쇄해, 글자·아이콘이 눌리지 않는다
.rail__inner {
width: $expanded;
height: 100%;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-4 $sp-3;
transform: scaleX(math.div(1, $ratio));
transform-origin: left center; // 규칙8: 역상쇄 scaleX 좌변 축
transition: transform $duration $easing;
}
.rail__item {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-2;
border-radius: $r-sm;
color: $stage-paper; text-decoration: none;
&:hover, &:focus-visible { background: rgba($stage-paper, .12); }
}
.rail__ic {
flex: none;
width: $sp-4; height: $sp-4;
border-radius: 50%;
background: $stage-yellow;
}
.rail__label {
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: .9;
}
.rail:hover, .rail:focus-within {
transform: scaleX(1);
.rail__inner { transform: scaleX(1); }
}
// 데모 루프: 좁음 → 넓음 → 좁음, 안팎이 반대 배율로 같이 애니메이션
.stage.is-demo .rail { animation: rail-loop $dur-loop $easing infinite; }
.stage.is-demo .rail__inner { animation: rail-inner-loop $dur-loop $easing infinite; }
@keyframes rail-loop {
0%, 100% { transform: scaleX(#{$ratio}); }
30%, 75% { transform: scaleX(1); }
}
@keyframes rail-inner-loop {
0%, 100% { transform: scaleX(#{math.div(1, $ratio)}); }
30%, 75% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail, .stage.is-demo .rail__inner { animation: none; }
.rail, .rail__inner { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rail {
position: relative;
overflow: hidden;
width: 200px;
height: min(50vw, 150px);
border-radius: 16px;
background: #17141a;
transform: scaleX(0.28);
transform-origin: left center;
transition: transform 300ms ease-out;
cursor: pointer;
}
.rail__inner {
width: 200px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 12px;
transform: scaleX(3.5714285714);
transform-origin: left center;
transition: transform 300ms ease-out;
}
.rail__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 8px;
color: #ffffff;
text-decoration: none;
}
.rail__item:hover, .rail__item:focus-visible {
background: rgba(255, 255, 255, 0.12);
}
.rail__ic {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffd23f;
}
.rail__label {
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0.9;
}
.rail:hover, .rail:focus-within {
transform: scaleX(1);
}
.rail:hover .rail__inner, .rail:focus-within .rail__inner {
transform: scaleX(1);
}
.stage.is-demo .rail {
animation: rail-loop 2s ease-out infinite;
}
.stage.is-demo .rail__inner {
animation: rail-inner-loop 2s ease-out infinite;
}
@keyframes rail-loop {
0%, 100% {
transform: scaleX(0.28);
}
30%, 75% {
transform: scaleX(1);
}
}
@keyframes rail-inner-loop {
0%, 100% {
transform: scaleX(3.5714285714);
}
30%, 75% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail, .stage.is-demo .rail__inner {
animation: none;
}
.rail, .rail__inner {
transition-duration: 1ms;
}
}// 실제 hover·focus 는 CSS 만으로 동작한다. 이 한 줄은 데모 루프를 실제 상호작용으로 넘겨주는 역할뿐이다.
document.querySelector('.rail').addEventListener('pointerenter', function () {
document.querySelector('.stage').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./MiniExpandRail.module.scss";
export type MiniExpandRailProps = {
duration?: number;
easing?: string;
color?: string;
};
const ITEMS = ["홈", "검색", "설정"];
export default function MiniExpandRail({
duration = 300,
easing = "ease-out",
color = "#ffd23f",
}: MiniExpandRailProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<nav className={styles.rail} tabIndex={0} aria-label="사이드 내비게이션">
<div className={styles.inner}>
{ITEMS.map((label) => (
<a key={label} className={styles.item} href="#">
<span className={styles.ic} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</a>
))}
</div>
</nav>
</div>
);
}06 · ミニ→拡張 — 計測 累積面積 16.075% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
キーボードでも同じ拡張
.rail {
transform: scaleX(.28);
transform-origin: left center;
transition: transform $duration $easing;
&:hover, &:focus-within { transform: scaleX(1); }
}
.rail__inner {
transform: scaleX(3.57);
transform-origin: left center;
.rail:hover &, .rail:focus-within & { transform: scaleX(1); }
}
07スクロールで隠れる
ウィンドウではなくパネルのスクロール
隣のコンテンツ領域のscrollイベントでscrollTopの増減を比べ、4pxの閾値を超えたときだけレールにis-hidden(translateX(-100%))を付け外しします。見ているのはウィンドウ全体ではなくこのコンテンツパネルのスクロールだけなので、文書ビューアーの中でも独立して動きます。

<main class="stage is-demo">
<div class="layout">
<nav class="rail" aria-label="目次">
<a class="rail__link" href="#">概要</a>
<a class="rail__link" href="#">インストール</a>
<a class="rail__link" href="#">使い方</a>
<a class="rail__link" href="#">トラブルシューティング</a>
</nav>
<div class="content" id="content">
<p>このエリアを下にスクロールすると左のレールが退く。</p>
<p>上にスクロールし直すとレールがすぐ戻る。</p>
<p>本文が長いドキュメントで目次が画面を占領し続けないようにする。</p>
<p>レールはウィンドウ全体ではなくこのコンテンツエリアのスクロールだけを見る。</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 220ms — 실제 스크롤 시 숨는 속도
$easing: $ease-out;
$color: $subject-cream; // 레일 링크 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-orange);
.layout {
display: flex;
width: min(90vw, 340px);
height: min(50vw, 150px);
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.rail {
flex: none;
width: 96px;
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-3 $sp-2;
background: $stage-ink;
transform: translateX(0);
transition: transform $duration $easing;
}
.rail__link {
padding: $sp-1 $sp-2;
border-radius: $r-xs;
color: $stage-paper; text-decoration: none; font-weight: 500; font-size: 11px; line-height: 1.2; font-family: inherit;
&:hover, &:focus-visible { color: $color; background: rgba($stage-paper, .12); }
}
.rail.is-hidden { transform: translateX(-100%); }
.content {
flex: 1;
overflow-y: auto;
box-sizing: border-box;
padding: $sp-3;
display: flex; flex-direction: column; gap: $sp-4;
color: $stage-ink;
p { margin: 0; font-size: clamp(10px, 3vmin, 12px); line-height: 1.5; opacity: .8; }
}
// 데모 루프: 레일이 나갔다 들어왔다를 2초에 한 바퀴(실제 스크롤 여부와 무관)
.stage.is-demo .rail { animation: rail-loop $dur-loop ease-in-out infinite; }
@keyframes rail-loop {
0%, 100% { transform: translateX(0); }
40%, 70% { transform: translateX(-100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail { animation: none; }
.rail { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.layout {
display: flex;
width: min(90vw, 340px);
height: min(50vw, 150px);
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rail {
flex: none;
width: 96px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 8px;
background: #17141a;
transform: translateX(0);
transition: transform 220ms ease-out;
}
.rail__link {
padding: 4px 8px;
border-radius: 4px;
color: #ffffff;
text-decoration: none;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
}
.rail__link:hover, .rail__link:focus-visible {
color: #fff7e6;
background: rgba(255, 255, 255, 0.12);
}
.rail.is-hidden {
transform: translateX(-100%);
}
.content {
flex: 1;
overflow-y: auto;
box-sizing: border-box;
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
color: #17141a;
}
.content p {
margin: 0;
font-size: clamp(10px, 3vmin, 12px);
line-height: 1.5;
opacity: 0.8;
}
.stage.is-demo .rail {
animation: rail-loop 2s ease-in-out infinite;
}
@keyframes rail-loop {
0%, 100% {
transform: translateX(0);
}
40%, 70% {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .rail {
animation: none;
}
.rail {
transition-duration: 1ms;
}
}// 콘텐츠 스크롤 방향을 비교해 레일을 translateX 로 숨기고 보인다. 실제 스크롤이 오면 데모 루프를 넘긴다.
(function () {
var stage = document.querySelector('.stage');
var rail = document.querySelector('.rail');
var content = document.getElementById('content');
var last = 0;
content.addEventListener('scroll', function () {
stage.classList.remove('is-demo');
var y = content.scrollTop;
if (y > last + 4) rail.classList.add('is-hidden');
else if (y < last - 4) rail.classList.remove('is-hidden');
last = y;
});
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollHideRail.module.scss";
type ScrollEv = { currentTarget: HTMLDivElement };
export type ScrollHideRailProps = {
duration?: number;
easing?: string;
color?: string;
};
const LINKS = ["개요", "설치", "사용법", "문제 해결"];
export default function ScrollHideRail({
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: ScrollHideRailProps) {
const [hidden, setHidden] = useState(false);
const lastY = useRef(0);
function onScroll(e: ScrollEv) {
const y = e.currentTarget.scrollTop;
const last = lastY.current ?? 0;
if (y > last + 4) setHidden(true);
else if (y < last - 4) setHidden(false);
lastY.current = y;
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.layout}>
<nav className={styles.rail} data-hidden={hidden} aria-label="목차">
{LINKS.map((label) => (
<a key={label} className={styles.link} href="#">{label}</a>
))}
</nav>
<div className={styles.content} onScroll={onScroll}>
<p>이 영역을 아래로 스크롤하면 왼쪽 레일이 비켜난다.</p>
<p>다시 위로 스크롤하면 레일이 즉시 돌아온다.</p>
<p>본문이 긴 문서에서 목차가 화면을 계속 차지하지 않게 한다.</p>
<p>레일은 창 전체가 아니라 이 콘텐츠 영역의 스크롤만 본다.</p>
</div>
</div>
</div>
);
}07 · スクロールで隠れる — 計測 累積面積 9.937% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
文書ビューアーの中でも
content.addEventListener('scroll', function () {
var y = content.scrollTop;
if (y > last + 4) rail.classList.add('is-hidden');
else if (y < last - 4) rail.classList.remove('is-hidden');
last = y;
});
08入れ子メニュー
0frから1frへ広げる
ドロワー自体は01と同じスライドで開き、下位メニューはgrid-template-rowsが0frから1frに変わることでheight:autoの遷移なしに広がります。シェブロンは1つの要素のborderの角を45度から225度まで回すだけで作り、2本の棒を別々に回す方式にありがちなズレが出ません。

<main class="stage is-demo">
<div class="page" id="page8">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer8">メニューを開く</button>
</div>
<div class="overlay"></div>
<nav class="drawer" id="drawer8" role="dialog" aria-modal="true" aria-labelledby="t8">
<h2 id="t8">カテゴリー</h2>
<a class="drawer__link" href="#">すべて</a>
<div class="group">
<button class="group__toggle" type="button" aria-expanded="false" aria-controls="sub8">
レディース <span class="chevron" aria-hidden="true"></span>
</button>
<div class="submenu" id="sub8">
<div class="submenu__inner">
<a href="#">トップス</a>
<a href="#">ボトムス</a>
<a href="#">シューズ</a>
</div>
</div>
</div>
<a class="drawer__link" href="#">ブランド</a>
<button class="drawer__close" type="button">閉じる</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 600ms — 드로어가 밀려 들어오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 하위 메뉴 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .5);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-6 $sp-4;
background: $stage-ink; color: $stage-paper;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
}
.drawer__link, .group__toggle {
padding: $sp-2 $sp-1;
border-radius: $r-sm;
color: $stage-paper; text-decoration: none; font-weight: 500; font-size: 13px; line-height: 1; font-family: inherit;
&:hover, &:focus-visible { background: rgba($stage-paper, .1); }
}
.group__toggle {
border: 0; background: transparent;
display: flex; align-items: center; justify-content: space-between;
width: 100%; cursor: pointer;
font-family: inherit;
}
// 체크와 같은 원리: 한 요소의 border 코너를 45° 돌려 화살표를 만든다(BD16)
.chevron {
width: $sp-2; height: $sp-2;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform $dur-base $ease-out;
}
.group.is-open .chevron { transform: rotate(225deg); }
// height:auto 트랜지션 — grid-template-rows 0fr → 1fr (017-01 과 같은 방식)
.submenu {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $dur-base $ease-out;
}
.submenu__inner {
overflow: hidden;
display: flex; flex-direction: column; gap: $sp-1;
padding-left: $sp-4;
a { color: $stage-paper; opacity: .8; text-decoration: none; font-weight: 400; font-size: 12px; line-height: 1.8; font-family: inherit; }
a:hover, a:focus-visible { opacity: 1; color: $color; }
}
.group.is-open .submenu { grid-template-rows: 1fr; }
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $color; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
// 데모 루프: 드로어가 들어온 뒤 하위 메뉴가 펼쳐졌다 접히고, 드로어가 다시 나간다
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
.stage.is-demo .submenu { animation: submenu-loop $dur-loop $ease-out infinite; }
.stage.is-demo .chevron { animation: chevron-loop $dur-loop $ease-out infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
12%, 90% { opacity: 1; }
96% { opacity: 0; }
}
@keyframes drawer-loop {
0%, 100% { transform: translateX(-100%); }
12%, 90% { transform: translateX(0); }
96% { transform: translateX(-100%); }
}
@keyframes submenu-loop {
0%, 25%, 100% { grid-template-rows: 0fr; }
45%, 75% { grid-template-rows: 1fr; }
}
@keyframes chevron-loop {
0%, 25%, 100% { transform: rotate(45deg); }
45%, 75% { transform: rotate(225deg); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer, .stage.is-demo .submenu, .stage.is-demo .chevron { animation: none; }
.overlay, .drawer, .submenu, .chevron { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #2f6df6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: translateX(-100%);
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 24px 16px;
background: #17141a;
color: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer__link, .group__toggle {
padding: 8px 4px;
border-radius: 8px;
color: #ffffff;
text-decoration: none;
font-weight: 500;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.drawer__link:hover, .drawer__link:focus-visible, .group__toggle:hover, .group__toggle:focus-visible {
background: rgba(255, 255, 255, 0.1);
}
.group__toggle {
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
font-family: inherit;
}
.chevron {
width: 8px;
height: 8px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform 300ms ease-out;
}
.group.is-open .chevron {
transform: rotate(225deg);
}
.submenu {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease-out;
}
.submenu__inner {
overflow: hidden;
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 16px;
}
.submenu__inner a {
color: #ffffff;
opacity: 0.8;
text-decoration: none;
font-weight: 400;
font-size: 12px;
line-height: 1.8;
font-family: inherit;
}
.submenu__inner a:hover, .submenu__inner a:focus-visible {
opacity: 1;
color: #2f6df6;
}
.group.is-open .submenu {
grid-template-rows: 1fr;
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .submenu {
animation: submenu-loop 2s ease-out infinite;
}
.stage.is-demo .chevron {
animation: chevron-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
12%, 90% {
opacity: 1;
}
96% {
opacity: 0;
}
}
@keyframes drawer-loop {
0%, 100% {
transform: translateX(-100%);
}
12%, 90% {
transform: translateX(0);
}
96% {
transform: translateX(-100%);
}
}
@keyframes submenu-loop {
0%, 25%, 100% {
grid-template-rows: 0fr;
}
45%, 75% {
grid-template-rows: 1fr;
}
}
@keyframes chevron-loop {
0%, 25%, 100% {
transform: rotate(45deg);
}
45%, 75% {
transform: rotate(225deg);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer, .stage.is-demo .submenu, .stage.is-demo .chevron {
animation: none;
}
.overlay, .drawer, .submenu, .chevron {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page8');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var drawer = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
var group = document.querySelector('.group');
var groupToggle = document.querySelector('.group__toggle');
function focusables() { return drawer.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
groupToggle.addEventListener('click', function () {
var open = group.classList.toggle('is-open');
groupToggle.setAttribute('aria-expanded', String(open));
});
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NestedMenuDrawer.module.scss";
export type NestedMenuDrawerProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function NestedMenuDrawer({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: NestedMenuDrawerProps) {
const [open, setOpen] = useState(false);
const [groupOpen, setGroupOpen] = useState(false);
const drawerRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) { triggerRef.current?.focus(); return; }
const drawer = drawerRef.current;
if (!drawer) return;
const focusables = () => Array.from(drawer.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
메뉴 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<nav ref={drawerRef} className={styles.drawer} data-open={open} role="dialog" aria-modal="true" aria-label="카테고리">
<h2>카테고리</h2>
<a className={styles.link} href="#">전체</a>
<div className={styles.group}>
<button
className={styles.groupToggle}
type="button"
aria-expanded={groupOpen}
onClick={() => setGroupOpen((v) => !v)}
>
여성 <span className={styles.chevron} data-open={groupOpen} aria-hidden="true" />
</button>
<div className={styles.submenu} data-open={groupOpen}>
<div className={styles.submenuInner}>
<a href="#">상의</a>
<a href="#">하의</a>
<a href="#">신발</a>
</div>
</div>
</div>
<a className={styles.link} href="#">브랜드</a>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</nav>
</div>
);
}08 · 入れ子メニュー — 計測 累積面積 100.0% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
シェブロンを1要素で作る
.chevron {
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform $dur-base $ease-out;
}
.group.is-open .chevron { transform: rotate(225deg); }
.submenu {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $dur-base $ease-out;
}
.group.is-open .submenu { grid-template-rows: 1fr; }
09下部タブバー
インデックスから位置を計算する
タブを押すとJSが押された位置のインデックスからtranslateX(N * 100%)を計算し、インジケーターのtransformに直接書き込みます。各タブは独立したボタンで、アクティブなものだけにaria-current="page"が付きます。

<main class="stage is-demo">
<nav class="tabbar" aria-label="下部ナビゲーション">
<button class="tab is-active" type="button" aria-current="page"><span class="tab__ic" aria-hidden="true"></span><span class="tab__label">ホーム</span></button>
<button class="tab" type="button"><span class="tab__ic" aria-hidden="true"></span><span class="tab__label">検索</span></button>
<button class="tab" type="button"><span class="tab__ic" aria-hidden="true"></span><span class="tab__label">お気に入り</span></button>
<button class="tab" type="button"><span class="tab__ic" aria-hidden="true"></span><span class="tab__label">マイページ</span></button>
<span class="indicator" aria-hidden="true"></span>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 220ms — 인디케이터가 옆 탭으로 미끄러지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 활성 탭 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-ink);
.tabbar {
position: relative;
display: flex;
width: min(92vw, 320px);
border-radius: $r-card;
background: rgba($stage-paper, .06);
padding: $sp-1;
box-sizing: border-box;
}
.tab {
flex: 1;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: 0; background: transparent; cursor: pointer;
padding: $sp-2 $sp-1;
color: $stage-paper;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
}
.tab__ic {
width: $sp-4; height: $sp-4;
border-radius: 50%;
background: currentColor;
opacity: .5;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.tab__label {
font-weight: 500;
font-size: 10px;
line-height: 1;
font-family: inherit;
opacity: .55;
transform: translateY(0);
transition: opacity $duration $easing, transform $duration $easing;
}
.tab.is-active {
color: $color;
.tab__ic { opacity: 1; transform: scale(1.15); }
.tab__label { opacity: 1; transform: translateY(-2px); }
}
.indicator {
position: absolute; left: $sp-1; bottom: $sp-1;
width: calc(25% - #{$sp-1});
height: 3px;
border-radius: $r-xs;
background: $color;
transform: translateX(0);
transition: transform $duration $easing;
}
// 데모 루프: 인디케이터가 네 탭을 순서대로 돌고, 각 탭은 도착하는 구간에서만 밝아진다
.stage.is-demo .indicator { animation: indicator-loop $dur-loop $easing infinite; }
.stage.is-demo .tab:nth-child(1) .tab__ic { animation: tab-ic-loop $dur-loop $easing infinite; animation-delay: 0ms; }
.stage.is-demo .tab:nth-child(2) .tab__ic { animation: tab-ic-loop $dur-loop $easing infinite; animation-delay: 500ms; }
.stage.is-demo .tab:nth-child(3) .tab__ic { animation: tab-ic-loop $dur-loop $easing infinite; animation-delay: 1000ms; }
.stage.is-demo .tab:nth-child(4) .tab__ic { animation: tab-ic-loop $dur-loop $easing infinite; animation-delay: 1500ms; }
.stage.is-demo .tab:nth-child(1) .tab__label { animation: tab-label-loop $dur-loop $easing infinite; animation-delay: 0ms; }
.stage.is-demo .tab:nth-child(2) .tab__label { animation: tab-label-loop $dur-loop $easing infinite; animation-delay: 500ms; }
.stage.is-demo .tab:nth-child(3) .tab__label { animation: tab-label-loop $dur-loop $easing infinite; animation-delay: 1000ms; }
.stage.is-demo .tab:nth-child(4) .tab__label { animation: tab-label-loop $dur-loop $easing infinite; animation-delay: 1500ms; }
@keyframes indicator-loop {
0%, 20% { transform: translateX(0); }
25%, 45% { transform: translateX(100%); }
50%, 70% { transform: translateX(200%); }
75%, 95% { transform: translateX(300%); }
100% { transform: translateX(0); }
}
@keyframes tab-ic-loop {
0%, 20% { opacity: 1; transform: scale(1.15); }
25%, 100% { opacity: .5; transform: scale(1); }
}
@keyframes tab-label-loop {
0%, 20% { opacity: 1; transform: translateY(-2px); }
25%, 100% { opacity: .55; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .indicator,
.stage.is-demo .tab .tab__ic,
.stage.is-demo .tab .tab__label { animation: none; }
.indicator, .tab__ic, .tab__label { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tabbar {
position: relative;
display: flex;
width: min(92vw, 320px);
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 4px;
box-sizing: border-box;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
cursor: pointer;
padding: 8px 4px;
color: #ffffff;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
}
.tab__ic {
width: 16px;
height: 16px;
border-radius: 50%;
background: currentColor;
opacity: 0.5;
transform: scale(1);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tab__label {
font-weight: 500;
font-size: 10px;
line-height: 1;
font-family: inherit;
opacity: 0.55;
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);
}
.tab.is-active {
color: #fff7e6;
}
.tab.is-active .tab__ic {
opacity: 1;
transform: scale(1.15);
}
.tab.is-active .tab__label {
opacity: 1;
transform: translateY(-2px);
}
.indicator {
position: absolute;
left: 4px;
bottom: 4px;
width: calc(25% - 4px);
height: 3px;
border-radius: 4px;
background: #fff7e6;
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stage.is-demo .indicator {
animation: indicator-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .tab:nth-child(1) .tab__ic {
animation: tab-ic-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0ms;
}
.stage.is-demo .tab:nth-child(2) .tab__ic {
animation: tab-ic-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 500ms;
}
.stage.is-demo .tab:nth-child(3) .tab__ic {
animation: tab-ic-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1000ms;
}
.stage.is-demo .tab:nth-child(4) .tab__ic {
animation: tab-ic-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1500ms;
}
.stage.is-demo .tab:nth-child(1) .tab__label {
animation: tab-label-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0ms;
}
.stage.is-demo .tab:nth-child(2) .tab__label {
animation: tab-label-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 500ms;
}
.stage.is-demo .tab:nth-child(3) .tab__label {
animation: tab-label-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1000ms;
}
.stage.is-demo .tab:nth-child(4) .tab__label {
animation: tab-label-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1500ms;
}
@keyframes indicator-loop {
0%, 20% {
transform: translateX(0);
}
25%, 45% {
transform: translateX(100%);
}
50%, 70% {
transform: translateX(200%);
}
75%, 95% {
transform: translateX(300%);
}
100% {
transform: translateX(0);
}
}
@keyframes tab-ic-loop {
0%, 20% {
opacity: 1;
transform: scale(1.15);
}
25%, 100% {
opacity: 0.5;
transform: scale(1);
}
}
@keyframes tab-label-loop {
0%, 20% {
opacity: 1;
transform: translateY(-2px);
}
25%, 100% {
opacity: 0.55;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .indicator,
.stage.is-demo .tab .tab__ic,
.stage.is-demo .tab .tab__label {
animation: none;
}
.indicator, .tab__ic, .tab__label {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var tabs = document.querySelectorAll('.tab');
var indicator = document.querySelector('.indicator');
tabs.forEach(function (tab, i) {
tab.addEventListener('click', function () {
stage.classList.remove('is-demo');
tabs.forEach(function (t) { t.classList.remove('is-active'); t.removeAttribute('aria-current'); });
tab.classList.add('is-active'); tab.setAttribute('aria-current', 'page');
indicator.style.transform = 'translateX(' + (i * 100) + '%)';
});
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomTabBar.module.scss";
export type BottomTabBarProps = {
duration?: number;
easing?: string;
color?: string;
};
const TABS = ["홈", "검색", "찜", "마이"];
export default function BottomTabBar({
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: BottomTabBarProps) {
const [active, setActive] = useState(0);
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--i": active,
};
return (
<div className={styles.stage} style={vars}>
<nav className={styles.tabbar} aria-label="하단 내비게이션">
{TABS.map((label, i) => (
<button
key={label}
className={styles.tab}
type="button"
data-active={i === active}
aria-current={i === active ? "page" : undefined}
onClick={() => setActive(i)}
>
<span className={styles.ic} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</button>
))}
<span className={styles.indicator} aria-hidden="true" />
</nav>
</div>
);
}09 · 下部タブバー — 計測 累積面積 1.886% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
アクティブなタブの印
.indicator {
position: absolute; left: $sp-1; bottom: $sp-1;
width: calc(25% - #{$sp-1});
transform: translateX(0);
transition: transform $duration $easing;
}
.tab.is-active {
.tab__ic { opacity: 1; transform: scale(1.15); }
.tab__label { opacity: 1; transform: translateY(-2px); }
}
どこで崩れるか — 落とし穴
dialogの既定は中央の小さな箱
9つの中で一番よく踏んだのは02のdialog要素です。ブラウザ既定のスタイルは要素を画面中央に内容分の大きさだけ浮かせるため、insetとmarginとborderを全部消さないと左の全高パネルではなく中央の小さな箱として出ます。06にも似た罠があり、レールをscaleXで縮めたあと内側の要素に逆数を掛け忘れると、広がる前の文字が押し潰されたまま見えてしまいます。どちらも「縮めた分をどこかで打ち消す」という発想を忘れると起きる罠でした。
閾値0が生むちらつき
2つ目は07のスクロール閾値です。値を0のままにするとトラックパッドの小さな揺り戻しでもレールがちらつき、4px程度の余裕を持たせてようやく震えが収まりました。04の3つのスナップ値はシート自身の高さに対する%で決めていて実際のコンテンツの高さは測っていないため、内容の長さが変わるとpeekの位置もずれてしまい、パーセントではなく固定pxでスナップさせたい場合はコンテンツの高さを実測する必要があります。
focus()が親のスクロールを引っ張る
ドロワーを開いたまま背後のページまで一緒にスクロールしてしまう問題は、スクロール位置で現れたり隠れたりするトップへ戻るボタンにも同じロック規則を求めます。
3つ目はフォーカス移動そのものです。このデモはサイト自身のトップページ一覧にiframeとして埋め込まれているため、開いた瞬間にfocus()を呼ぶと親ページ側のスクロール位置まで一緒に引っ張られる不具合を最初に踏みました。01・03・04・05・08のfocus()呼び出しを全部{ preventScroll: true }付きに直してから、一覧ページの見た目が動かなくなりました。こうした修正が反映済みの9本のファイルを開くパスワードは2g6qnjwnという一続きの文字列で、下のカードからzipを受け取ってください。
アクセシビリティ
背景を塞いでフォーカスを閉じ込める
01・03・08のように背景を覆うドロワーは、開くと同時に背景の#pageへinertとaria-hiddenを付けて塞ぎ、最初のフォーカス可能な要素に移してからTabキーが外へ出ないよう最初と最後の要素で折り返します。02はこの仕事をdialog要素がすべて肩代わりするため専用コードがなく、05はモーダルではなく常設ナビゲーションなのでinertの代わりにリンクのaria-hiddenとtabIndexだけを開閉に合わせます。
function open() {
stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
preventScrollを付ける理由
上のコードにあるfocus()の呼び出しがどれも{ preventScroll: true }付きなのは、前の落とし穴で触れたとおり親ページのスクロールを引っ張らないためです。inert属性の対応状況はMDNで確認できます。
prefers-reduced-motion: reduceでは9つとも進入・退出のアニメーションを1msに縮めて最終状態へ即座に切り替えますが、04のスナップ位置が変わる操作のように状態そのものが変化する遷移は残します。dialog要素のフォーカス管理はブラウザ標準なので、02はreduced-motionの設定と関係なくESCもフォーカス管理もそのまま働きます。02が使う@starting-styleは書く前にcaniuseで対応状況を確認してください。
次に読む
ドロワーのアイコン自体の開閉方式はハンバーガーメニュー9選に譲り、この記事はパネルの移動方式だけに絞っています。右から出る買い物カゴ用のモーダルとの違いはモーダルアニメーション9選にあるとおり、フォーカストラップの有無です。ほかのCSSアニメーションはCSSの一覧ページに、このサイトの成り立ちはサイトについてにまとめています。
FAQ
サイドバーとドロワーは同じですか?
おおむね同じ意味で使われます。サイドバーは画面の端に固定されるパネルという広い呼び方で、ドロワーはそのパネルが引き出しのように動いて現れる開閉の仕方を指します。この記事の9つは全部サイドバーで、開き方だけが違います。
「css サイドバー レスポンシブ」ではどれを使うべきですか?
画面が狭いなら09の下部タブバーのほうが指の届く範囲に収まります。どうしてもサイドバーの形が必要なら、04のボトムシートか01の左スライドがタッチ操作に向いています。
Reactでも同じ挙動になりますか?
はい、zipのreactフォルダの各コンポーネントはこれまでと同じクラス名やaria属性を付けているだけで、実際に動かしているのはCSSのtransitionです。フォーカス管理をuseEffectで行うのは01・02・03・08の4つで、01・03・08はinertの付け外しとTabキーの循環、02はshowModal()の呼び出しを担当します。ドロワーの中身を写真で埋めて送る予定なら、CSS画像スライドアニメーションがその中身を引き受けてくれます。









