GODRICH

css ドロワー メニュー スライド9選 — JS最小・CSSだけ

css ドロワーは、画面の端に隠れたパネルがトリガー操作で滑り出したり、位置を変えずにその場で現れたりするナビゲーションUIです。この記事はtransformとopacityを軸に9種類の開き方を集め、うち01・03・08の3つは実際にinertで背景を封じるフォーカストラップ付きのJSも動かしています。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は必要なコードの量を基準にしています。01は最も定番のスライド構成で、02はネイティブのdialog要素にほとんどの仕事を任せます。03から07までは「開く」という動作自体の作りが違う項目(位置を動かさない、コンテンツごと動く、常に浮いている)を並べ、08は階層が増える場合、09はサイドバーという概念そのものがモバイルで別のUIに置き換わる場合です。

01左スライドドロワー

パネルは画面左外のtranslateX(-100%)からtranslateX(0)まで動いて開き、オーバーレイはopacityだけで暗くなります。開いている間は背後の#pageinertaria-hiddenが付き、Tabキーは最初と最後の要素で折り返されてドロワーの外に出ません。

translateX(-100%)포커스 트랩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の遷移が効くようにしています。

<dialog>::backdropESC 공짜
.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オーバーレイフェード

パネルは左の位置を保ったままopacitytransform: scaleだけで現れ、背後のオーバーレイはbackdrop-filterでぼかされます。01の単色オーバーレイと違い、パネル自体は移動せずその場で拡大するだけです。

opacity+scalebackdrop-filter위치 고정
.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ボトムシートスナップ

つまみを押すたびにdata-snap属性がpeek・half・fullの3値を巡り、それぞれに対応するtranslate(-50%, N%)の値ひとつだけが効きます。ドラッグの座標計算を一切せず、状態の切り替えだけで3段階のスナップを再現しています。

3단 스냅peek·half·full손잡이 클릭
.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でtranslateX(-100%→0)translateX(0→40%)を同時に動き、marginを使わずに場所を分け合います。オーバーレイがないためモーダルではなく常設のナビゲーションとして扱い、inertの代わりにリンクのaria-hiddentabIndexを開閉に合わせて切り替えます。

콘텐츠 동반 이동translateX 쌍오버레이 없음
.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ミニ→拡張レール

アイコンだけの細いレールをscaleXで縮めておき、内側の.rail__innerには正確に逆数のscaleXを掛けて、hover・focus-withinで広がっても文字が潰れないようにします。JSは使わずCSSのセレクタだけで開閉し、tabindex="0"を付けてキーボードフォーカスでも同じ拡張が起きるようにしています。

scaleX 확장역스케일 보정hover·focus
.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%))を付け外しします。見ているのはウィンドウ全体ではなくこのコンテンツパネルのスクロールだけなので、文書ビューアーの中でも独立して動きます。

scroll 방향 감지translateX 은닉내부 콘텐츠 스크롤
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入れ子メニュー

ドロワー自体は01と同じスライドで開き、下位メニューはgrid-template-rows0frから1frに変わることでheight:autoの遷移なしに広がります。シェブロンは1つの要素のborderの角を45度から225度まで回すだけで作り、2本の棒を別々に回す方式にありがちなズレが出ません。

grid-template-rows0fr→1fr쉐브론 회전
.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"が付きます。

탭 인디케이터translateX 슬라이드하단 고정
.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); }
}

どこで崩れるか — 落とし穴

9つの中で一番よく踏んだのは02のdialog要素です。ブラウザ既定のスタイルは要素を画面中央に内容分の大きさだけ浮かせるため、insetとmarginとborderを全部消さないと左の全高パネルではなく中央の小さな箱として出ます。06にも似た罠があり、レールをscaleXで縮めたあと内側の要素に逆数を掛け忘れると、広がる前の文字が押し潰されたまま見えてしまいます。どちらも「縮めた分をどこかで打ち消す」という発想を忘れると起きる罠でした。

2つ目は07のスクロール閾値です。値を0のままにするとトラックパッドの小さな揺り戻しでもレールがちらつき、4px程度の余裕を持たせてようやく震えが収まりました。04の3つのスナップ値はシート自身の高さに対する%で決めていて実際のコンテンツの高さは測っていないため、内容の長さが変わるとpeekの位置もずれてしまい、パーセントではなく固定pxでスナップさせたい場合はコンテンツの高さを実測する必要があります。

3つ目はフォーカス移動そのものです。このデモはサイト自身のトップページ一覧にiframeとして埋め込まれているため、開いた瞬間にfocus()を呼ぶと親ページ側のスクロール位置まで一緒に引っ張られる不具合を最初に踏みました。01・03・04・05・08のfocus()呼び出しを全部{ preventScroll: true }付きに直してから、一覧ページの見た目が動かなくなりました。こうした修正が反映済みの9本のファイルを開くパスワードは2g6qnjwnという一続きの文字列で、下のカードからzipを受け取ってください。

アクセシビリティ

01・03・08のように背景を覆うドロワーは、開くと同時に背景の#pageinertaria-hiddenを付けて塞ぎ、最初のフォーカス可能な要素に移してからTabキーが外へ出ないよう最初と最後の要素で折り返します。02はこの仕事をdialog要素がすべて肩代わりするため専用コードがなく、05はモーダルではなく常設ナビゲーションなのでinertの代わりにリンクのaria-hiddentabIndexだけを開閉に合わせます。

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(); }
});

上のコードにある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つは全部サイドバーで、開き方だけが違います。

モバイルではどれを使うべきですか?

画面が狭いなら09の下部タブバーのほうが指の届く範囲に収まります。どうしてもサイドバーの形が必要なら、04のボトムシートか01の左スライドがタッチ操作に向いています。

Reactでも同じ挙動になりますか?

はい、zipのreactフォルダの各コンポーネントはこれまでと同じクラス名やaria属性を付けているだけで、実際に動かしているのはCSSのtransitionです。フォーカス管理をuseEffectで行うのは01・02・03・08の4つで、01・03・08はinertの付け外しとTabキーの循環、02はshowModal()の呼び出しを担当します。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。