デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
子を持つ項目を押すと、同じ枠の中で次の階層が右から滑り込み、親の階層は少し左へ下がって沈みます。見出しの名前は深さに合わせて縦に入れ替わり、「戻る」が現れます。階層ごとのリストを同じ枠に重ねて置き、「今どのくらい深いか」を表す数ひとつから、それぞれの位置と濃さを決めています。ドロワーが開くところではなく、開いたあとの中身が階層で入れ替わるところが見どころです。
階層の入れ替えは 0.55 秒の power3.inOut で、親は枠の幅の 28% だけ左へ下がります。全部を送り出さずに少しだけ残すので、どこから来たのかが伝わります。行は上の段から順に着くように、下の行ほど余分に右へずらし、薄くしてあります。見出しの名前は、前の名前が消えきってから次の名前を出すので、途中で 2 つの語が重なって読めなくなりません。
深さを「今どの階層か」の整数だけで持つと、動いている途中に押されたときに表示が飛びます。深さを小数の数ひとつにして、そこから全部の位置を描く形にすると、途中から押し直されても続きから動きます。もうひとつ気をつけるのは、重ねた階層の当たり判定です。見えていない階層が上に残ると下の行が押せなくなるので、pointer-events は今の階層だけに付けます。
階層のリストは nav の中の箇条書きで書き、子を持つ項目はボタンにして、開く先のリストを指させます。深くなったら新しい階層の先頭へ焦点を移し、戻ったら押したボタンへ返します。今いる階層より外のリストは inert にして、Tab と読み上げから外します。Esc は、深い階層にいれば 1 つ戻り、いちばん上にいれば閉じます。
滑り込む向き、親が下がる量、行の遅れの間隔が主なつまみです。階層は何段でも増やせて、いちばん下だけを商品の一覧のように中身の違う行にすることもできます。項目の多いネットショップや、スマートフォンでの深いナビゲーションに向いています。
コード
<div class="page">
<div class="page__bar">
<p class="page__logo">Hi! I'm HIGASHIYUKI®</p>
<button class="page__open" type="button" aria-expanded="false" aria-controls="site-drawer">メニュー</button>
</div>
<h1 class="page__title">季節の定番が<br>そろいました。</h1>
<p class="page__lead">ここに説明文が入ります。</p>
</div>
<dialog class="drawer" id="site-drawer">
<div class="drawer__scrim" data-close></div>
<nav class="drawer__panel" aria-label="メニュー">
<div class="drawer__head">
<!-- 「戻る」は、根より下の階層にいるあいだだけ出す -->
<button class="drawer__back" type="button" hidden>戻る</button>
<p class="drawer__title">メニュー</p>
<button class="drawer__close" type="button" data-close aria-label="メニューを閉じる">
<span aria-hidden="true">×</span>
</button>
</div>
<!-- 階層は同じ枠に重ねて置く。子を持つ項目はボタンにし、開く階層を aria-controls で指す -->
<div class="drawer__stack">
<ul class="drawer__level" data-title="メニュー">
<li><button class="drawer__row drawer__row--more" type="button"
aria-expanded="false" aria-controls="drawer-shop">オンラインストア</button></li>
<li><a class="drawer__row" href="/journal/">読みもの</a></li>
<li><a class="drawer__row" href="/about/">私たちについて</a></li>
<li><a class="drawer__row" href="/stores/">店舗一覧</a></li>
<li><a class="drawer__row" href="/contact/">お問い合わせ</a></li>
</ul>
<ul class="drawer__level" id="drawer-shop" data-title="オンラインストア" hidden>
<li><a class="drawer__row" href="/shop/lighting/">照明</a></li>
<li><a class="drawer__row" href="/shop/textiles/">ファブリック</a></li>
<li><button class="drawer__row drawer__row--more" type="button"
aria-expanded="false" aria-controls="drawer-furniture">家具</button></li>
<li><a class="drawer__row" href="/shop/sale/">セール</a></li>
<li><a class="drawer__row" href="/shop/new/">新着アイテム</a></li>
</ul>
<ul class="drawer__level" id="drawer-furniture" data-title="家具" hidden>
<li><a class="drawer__row drawer__row--item" href="/shop/furniture/oak-lounge-chair/">
<span class="drawer__thumb"></span>
<span><span class="drawer__name">オークのラウンジチェア</span><span class="drawer__price">¥68,000</span></span>
</a></li>
<li><a class="drawer__row drawer__row--item" href="/shop/furniture/bookshelf/">
<span class="drawer__thumb"></span>
<span><span class="drawer__name">組み立て式の本棚</span><span class="drawer__price">¥42,000</span></span>
</a></li>
<li><a class="drawer__row drawer__row--item" href="/shop/furniture/round-table/">
<span class="drawer__thumb"></span>
<span><span class="drawer__name">丸いローテーブル</span><span class="drawer__price">¥96,000</span></span>
</a></li>
<li><a class="drawer__row drawer__row--item" href="/shop/furniture/stool/">
<span class="drawer__thumb"></span>
<span><span class="drawer__name">木のスツール</span><span class="drawer__price">¥24,000</span></span>
</a></li>
</ul>
</div>
<p class="drawer__foot">営業時間 10:00〜19:00<br><a href="mailto:sample@higashiyuki.com">sample@higashiyuki.com</a></p>
</nav>
</dialog>
.page,
.drawer {
--ink: #262626; /* 文字と主ボタンの地 */
--mute: #6b6b6b; /* 補足の文字と山形 */
--surface: #ffffff; /* ドロワーの面 */
--ground: #f2f1ed; /* ページの地 */
--line: rgba(38, 38, 38, 0.12); /* 罫線 */
--panel: min(420px, 86vw); /* ドロワーの幅 */
--sink: 0.28; /* 親の階層が左へ下がる量(枠の幅に対する比。JS が数として読む) */
color: var(--ink);
font: 400 15px/1.5 system-ui, sans-serif;
}
.page { box-sizing: border-box; min-height: 100vh; padding: 32px clamp(24px, 7vw, 96px) 96px; background-color: var(--ground); }
.page__bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; height: 48px; }
.page__logo { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap; }
.page__open { flex: none; white-space: nowrap; height: 48px; padding: 0 32px; border: 0; border-radius: 999px; background-color: var(--ink); color: var(--ground); font: inherit; font-weight: 600; font-size: 15px; line-height: 1; letter-spacing: 0.04em; cursor: pointer; }
.page__title { margin: clamp(64px, 14vw, 136px) 0 0; font-size: clamp(40px, 8vw, 64px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.page__lead { max-width: 30em; margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
/* ダイアログで開くと、焦点の閉じ込め・後ろのページの締め出し・閉じたあとの焦点の戻しを任せられる */
.drawer { position: fixed; inset: 0; box-sizing: border-box; width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0; border: 0; background: none; overflow: hidden; }
.drawer::backdrop { background: none; }
.drawer__scrim { position: absolute; inset: 0; background-color: rgba(0, 0, 0, 0.42); }
.drawer__panel { position: absolute; top: 0; right: 0; display: flex; flex-direction: column; box-sizing: border-box; width: var(--panel); height: 100%; background-color: var(--surface); }
.drawer__head { position: relative; flex: none; padding: 24px 24px 14px; border-bottom: 1px solid var(--line); }
.drawer__back, .drawer__close { border: 0; background: none; font: inherit; cursor: pointer; }
.drawer__back { display: flex; align-items: center; gap: 8px; padding: 0; color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; }
.drawer__back[hidden] { display: none; }
/* 「‹」は線 2 本で描く(文字で置くと、書体ごとに大きさと位置が変わる) */
.drawer__back::before { content: ""; width: 8px; height: 8px; border-left: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg) translate(1px, -1px); }
.drawer__title { margin: 6px 0 0; font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.drawer__close { position: absolute; top: 18px; right: 14px; width: 44px; height: 44px; color: var(--ink); font-size: 24px; line-height: 1; }
/* 階層を重ねる枠。はみ出した階層はここで切る */
.drawer__stack { position: relative; flex: 1; overflow: hidden; }
.drawer__level { position: absolute; inset: 0; margin: 0; padding: 12px 0 0; list-style: none; background-color: var(--surface); }
.drawer__level[hidden] { display: none; }
.drawer__row { display: flex; align-items: center; gap: 16px; box-sizing: border-box; width: 100%; height: 72px; padding: 0 24px; border: 0; background: none; color: inherit; font: inherit; font-weight: 600; font-size: 17px; line-height: 1.4; text-align: left; text-decoration: none; white-space: nowrap; cursor: pointer; }
.drawer__row--more::after { content: ""; flex: none; width: 8px; height: 8px; margin-left: auto; border-top: 2px solid var(--mute); border-right: 2px solid var(--mute); transform: rotate(45deg); }
.drawer__row--item { height: 96px; white-space: normal; }
.drawer__thumb { flex: none; width: 64px; height: 64px; border-radius: 4px; background-color: var(--tone); }
/* 商品の画像が届くまでの地の色(パレットの色を、明るさの段が離れるように並べる) */
li:nth-child(1) > .drawer__row--item { --tone: #a98860; }
li:nth-child(2) > .drawer__row--item { --tone: #475c6a; }
li:nth-child(3) > .drawer__row--item { --tone: #d9ba92; }
li:nth-child(4) > .drawer__row--item { --tone: #6f9770; }
.drawer__name { display: block; font-size: 16px; word-break: auto-phrase; }
.drawer__price { display: block; margin-top: 6px; color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.drawer__foot { flex: none; margin: 0; padding: 20px 24px; border-top: 1px solid var(--line); color: var(--mute); font-size: 13px; line-height: 1.9; letter-spacing: 0.04em; }
.drawer__foot a { color: var(--ink); font-weight: 600; }
/* 焦点の枠は内側に描く(行が枠いっぱいなので、外側だと階層を切る枠に切られる) */
.drawer :focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; }
.page__open:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 狭い画面では、ロゴとボタンが 1 行に並ぶ大きさにする */
@media (max-width: 480px) {
.page__bar { gap: 16px; }
.page__logo { font-size: 15px; }
.page__open { padding: 0 20px; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const SPREAD = 26; // 入ってくる行が、階層より余分にずれる量(px。下の行ほど遅れて着く)
document.querySelectorAll(".drawer").forEach((drawer) => {
const opener = document.querySelector(`[aria-controls="${drawer.id}"]`);
const scrim = drawer.querySelector(".drawer__scrim");
const panel = drawer.querySelector(".drawer__panel");
const stack = drawer.querySelector(".drawer__stack");
const title = drawer.querySelector(".drawer__title");
const back = drawer.querySelector(".drawer__back");
const levels = [...stack.children];
const trail = [levels[0]]; // 根から今いる階層まで
const opened = []; // その階層を開いたボタン(戻るときに焦点を返す)
const state = { pos: 0 }; // 深さの位置。0 = 根、1 = 1 つ下
const sink = parseFloat(getComputedStyle(drawer).getPropertyValue("--sink"));
let move = null;
// 深さの位置ひとつから、階層と行の位置・濃さを決める
const place = () => {
const width = stack.clientWidth;
levels.forEach((level) => {
const index = trail.indexOf(level);
const d = index < 0 ? -1 : state.pos - index; // 正 = 親として左へ下がった、負 = まだ右の外
const a = d < 0 ? -d : 0;
level.style.transform = `translateX(${Math.round(d >= 0 ? -d * width * sink : -d * width)}px)`;
level.style.opacity = d >= 0 ? Math.max(0, 1 - d * 0.65) : 1;
level.style.visibility = d >= 0.999 ? "hidden" : "";
[...level.children].forEach((row, j) => {
row.style.transform = `translateX(${Math.round(a * SPREAD * (1 + j * 0.6))}px)`;
row.style.opacity = Math.max(0, 1 - a * (0.7 + j * 0.25));
});
});
back.hidden = state.pos < 0.001;
};
// 動き終わったら、外れた階層を閉じる。開いている階層だけが焦点とクリックを受ける
const settle = () => {
trail.length = Math.round(state.pos) + 1;
levels.forEach((level) => ((level.hidden = !trail.includes(level)), (level.inert = level !== trail.at(-1))));
place();
};
const go = (next, focusTo) => {
const dir = next > state.pos ? 1 : -1;
// 見出しは、前の名前が消えきってから次の名前を出す(重なると 2 つの語が混ざって読めない)
const swap = gsap.timeline().to(title, { y: -10 * dir, opacity: 0, duration: 0.18 / SPEED, ease: "power2.in" });
swap.set(title, { textContent: trail[next].dataset.title, y: 10 * dir }).to(title, { y: 0, opacity: 1, duration: 0.24 / SPEED, ease: "power2.out" });
levels.forEach((level) => (level.inert = true)); // 動いているあいだは、どの行にも移らない
move?.kill();
const done = () => (settle(), focusTo?.focus());
move = gsap.to(state, { pos: next, duration: 0.55 / SPEED, ease: "power3.inOut", onUpdate: place, onComplete: done });
};
stack.addEventListener("click", (event) => {
const button = event.target.closest(".drawer__row--more");
if (!button || move?.isActive()) return;
const level = document.getElementById(button.getAttribute("aria-controls"));
button.setAttribute("aria-expanded", "true");
level.hidden = false;
trail[trail.length] = level;
opened[trail.length - 1] = button;
go(trail.length - 1, level.querySelector(".drawer__row"));
});
back.addEventListener("click", () => {
const depth = trail.length - 1;
if (depth < 1 || move?.isActive()) return;
opened[depth]?.setAttribute("aria-expanded", "false");
go(depth - 1, opened[depth]);
});
const close = () => {
gsap.to(panel, { xPercent: 100, duration: 0.4 / SPEED, ease: "power3.in" });
gsap.to(scrim, { opacity: 0, duration: 0.4 / SPEED, onComplete: () => drawer.close() });
};
opener.addEventListener("click", () => {
drawer.showModal(); // 焦点が中に閉じ込められ、閉じると押したボタンへ戻る
document.body.style.overflow = "hidden";
opener.setAttribute("aria-expanded", "true");
settle();
gsap.fromTo(panel, { xPercent: 100 }, { xPercent: 0, duration: 0.5 / SPEED, ease: "power4.out" });
gsap.fromTo(scrim, { opacity: 0 }, { opacity: 1, duration: 0.4 / SPEED });
trail.at(-1).querySelector(".drawer__row").focus();
});
drawer.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", close));
// Esc は、深い階層にいれば 1 つ戻り、いちばん上にいれば閉じる(既定のその場で消える閉じ方は止める)
drawer.addEventListener("cancel", (event) => {
event.preventDefault();
if (trail.length > 1) back.click();
else close();
});
// Esc を続けて押すと cancel を出さずに閉じることがあるので、後片付けは close でも行う
drawer.addEventListener("close", () => {
opener.setAttribute("aria-expanded", "false");
document.body.style.overflow = "";
gsap.set([panel, scrim], { clearProps: "transform,opacity" });
});
});