CSSとpopover属性で、画面の端からメニューを出す方法

​​CSS​HTML,Playwright,Popover API

画面の端からすべり出るメニューは、HTML の popover 属性と、CSS の transform と transition で作れます。開け閉めはブラウザが受け持つので、JavaScript は書きません。メニューの外側をクリックしたときや Esc キーを押したときにも、メニューは閉じます。スマートフォン向けのページで、ハンバーガーメニューのボタンを押すとリンクの一覧が画面の横から出てくる、いわゆるドロワーを作りたい場面で使えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、主に幅390px・高さ700pxの画面で、getBoundingClientRect() でメニューの位置を測りました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでは試していません。

上下に2つの画面が並んでいる。上の「閉じているとき」は、水色のヘッダーに「メニュー」ボタンと「サイト名」があり、その下に本文の段落とリンクがある。下の『「メニュー」を押した後』は、画面の左から白いメニューが出ていて、「閉じる」ボタンと「ホーム」「このサイトについて」「お問い合わせ」のリンクが縦に並ぶ。メニューの右側に見えるページは灰色に暗くなっている
幅360pxの画面で、メニューを閉じているときと開いたときを上下に並べた様子

コード全体

HTML は、開くボタンと、メニューの nav を書きます。nav に popover 属性を付け、ボタンの popovertarget に nav の id を書きます。

html
<header>
  <button type="button" popovertarget="menu">メニュー</button>
  <span>サイト名</span>
</header>
<nav id="menu" class="drawer" popover>
  <button type="button" popovertarget="menu" popovertargetaction="hide">閉じる</button>
  <ul>
    <li><a href="#home">ホーム</a></li>
    <li><a href="#about">このサイトについて</a></li>
    <li><a href="#contact">お問い合わせ</a></li>
  </ul>
</nav>
<main>
  <p>本文の段落です。左上のボタンを押すと、画面の左端からメニューが出てきます。</p>
  <p><a href="#body-link">本文の中のリンク</a></p>
</main>

CSS は次のとおりです。色や影などの見た目の CSS は省いています。

css
.drawer {
  position: fixed;
  inset: 0 auto 0 0;
  width: min(80vw, 300px);
  height: 100dvh;
  max-height: none;
  margin: 0;
  border: none;
  padding: 16px;
  box-sizing: border-box;
  transform: translateX(-100%);
  transition:
    transform 0.3s ease,
    overlay 0.3s allow-discrete,
    display 0.3s allow-discrete;
}

.drawer:popover-open {
  transform: translateX(0);
}

@starting-style {
  .drawer:popover-open {
    transform: translateX(-100%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .drawer {
    transition: none;
  }
}

後ろのページを暗くしたいときは、.drawer::backdrop { background: rgba(0, 0, 0, 0.4); } のように、::backdrop に色を付けます。上の画像では、この指定でページを暗くしています。

「メニュー」ボタンを押して開き、メニューの外側を押して閉じる様子が、次の画像です。

上の帯に「クリック 0回目/2回」と「メニュー:閉じている」が出ている。マウスポインターが「メニュー」ボタンへ動いて押すと、帯の2つの表示がピンクの地になり、「クリック 1回目/2回」と「メニュー:開いている」に変わる。白いメニューが画面の左端からすべり出て、後ろのページが灰色に暗くなる。次にポインターが右下の暗い部分へ動いて押すと、帯が「クリック 2回目/2回」「メニュー:閉じている」に変わり、メニューは左へすべって消え、ページの暗さも消える
メニューを開いて、外側を押して閉じるまでの動き

GIF の上の帯とマウスポインターは、画像を作るときに後から重ねたものです。すべる0.3秒の動きは、transition を止めて25ミリ秒ずつ進めながら撮ったので、GIF では実際よりゆっくり動きます。

popover 属性で、開け閉めをブラウザに任せる

ポップオーバー API の使い方の説明にあるとおり、popover 属性を付けた要素は、ページを開いた時点では display: none で隠れています。popovertarget を書いたボタンを押すと、表示と非表示が切り替わります。popovertargetaction="hide" を書いた「閉じる」ボタンは、閉じるだけのボタンになります。

値を書かない popover は popover="auto" と同じで、メニューの外側をクリックしたときや Esc キーを押したときにも閉じます。検証環境で試したところ、メニューの外側を押したとき、Esc キーを押したとき、「閉じる」ボタンを押したときのどれでも、メニューは閉じて display: none に戻りました。

開いたメニューは、最上位レイヤーという、ページのほかの要素よりも上の層に出ます。ヘッダーに position: sticky と z-index: 9999 を付けたページで開いても、ヘッダーの上(x=100、y=20)に出ていたのはメニューでした。メニューのほうには z-index を書いていません。

開くボタンには aria-expanded 属性を書いていませんが、Chrome DevTools Protocol の Accessibility.getFullAXTree で開くボタンのアクセシビリティの情報を読むと、閉じているときは expanded が false、開いているときは true でした。

画面の左に固定し、transform で外へずらしておく

メニューは position: fixed と inset の 0 auto 0 0 で、画面の上・下・左に付けます。幅は min(80vw, 300px) にしました。画面の幅が390pxと800pxのときはメニューの幅が300px、320pxのときは256px(画面の80%)で、狭い画面でも右側にページが少し見えます。

inset から margin までを書かずに開くと、メニューは画面の真ん中に、中身の大きさで出ました。そのときの値を読むと、inset が 0px、margin が上下242.6px・左右103.2pxで、ブラウザが上と下、左と右の余白をそれぞれ同じにして、真ん中に置いていました。端に付けるには、margin: 0 と inset を書き直す必要があります。

閉じているときの位置は、transform の translateX(-100%) で決めます。% は、transform-box を指定していないときはメニュー自身の幅に対する割合で、幅300pxのメニューでは左端が-300pxになりました。幅が変わっても、ちょうど幅の分だけ左の画面の外へずれます。開いたとき(:popover-open)は translateX(0) にして、画面の中に戻します。

「メニュー」ボタンや外側を押してから、メニューの左端の位置を約50ミリ秒ごとに測りました。

押してから 開くとき(左端) 外側を押して閉じるとき(左端)
0ミリ秒前後 -300px 0px
約110ミリ秒 -100.1px -172.7px
約210ミリ秒 -15px -277.4px
約310ミリ秒 0px -300px
約360ミリ秒 0px 隠れた(display: none)

開くときは画面の外の-300pxから0pxへ、閉じるときは0pxから-300pxへ、0.3秒かけて動きました。

メニューが長いときは、メニューの中をスクロールできます。リンクを40個に増やして開くと、メニューの高さは画面と同じ700pxで、中身の高さは1939pxでした。本文も高さ3000pxにして、メニューの上でホイールを600pxずつ回すと、3回目でメニューの中がいちばん下(1239px)まで届きました。4回目は、メニューの中は動かず、代わりに後ろのページが600pxスクロールしました。

メニューの端まで来たら後ろのページを動かさないようにするには、メニューに overscroll-behavior を足します。

css
.drawer {
  overscroll-behavior: contain;
}

これを足して同じように4回回すと、4回目も後ろのページは0pxのままでした。

開く動きは @starting-style、閉じる動きは display と overlay

MDN の @starting-style の説明にあるとおり、display: none から表示に変わった要素には、ふつうは transition がかかりません。@starting-style の中に「表示した直後の値」として translateX(-100%) を書くと、そこから translateX(0) へ動きます。@starting-style を消して試すと、押した直後からメニューは0pxにあり、すべる動きはありませんでした。

閉じるときは、:popover-open が外れた瞬間に display: none になると、すべる動きが見えません。transition に display と overlay を入れて allow-discrete を付けると、display: none への切り替えが、transition が終わるまで遅れます。overlay は、最上位レイヤーから外すのを同じように遅らせる指定です。上の表で、閉じるときに約310ミリ秒まで display が block のままだったのは、この指定のためです。

transition を transform 0.3s ease だけにして試すと、外側を押した直後から display は none になり、メニューはすべらずに消えました。

@media (prefers-reduced-motion: reduce) の中では、transition: none にしています。OS などで動きを減らす設定をしている人には、メニューがすぐに出ます。Playwright でこの設定にして開くと、押した直後からメニューは0pxにありました。

transform だけで隠すと、Tab キーで見えないリンクに入る

popover を使わず、クラスを付け外しして transform だけで隠すメニューも作れます。ただし、隠しているのは見た目だけです。閉じた状態で Tab キーを押していくと、「メニュー」ボタンの次に、画面の外(左端が-284px)にある「閉じる」ボタンや「ホーム」のリンクにフォーカスが移りました。キーボードで操作する人には、見えない場所にフォーカスが行ってしまいます。

popover を使ったメニューは、閉じているあいだ display: none なので、Tab キーで入りません。閉じた状態で Tab キーを押すと、「メニュー」ボタンの次は本文のリンクでした。開いた状態では、「メニュー」ボタンの次に「閉じる」ボタン、「ホーム」などのリンクの順にフォーカスが移り、そのあと本文のリンクに進みました。

右の端から出すときは、向きを逆にする

右の端から出すときは、上の CSS の後ろに次の CSS を足します。メニューを画面の右に付け、translateX(100%) で右の画面の外へずらします。

css
.drawer {
  inset: 0 0 0 auto;
  transform: translateX(100%);
}

@starting-style {
  .drawer:popover-open {
    transform: translateX(100%);
  }
}

幅390pxの画面で開くと、メニューの左端は390pxから90pxへ動き、右の端に幅300pxで付きました。開いた後も、ページの横幅(scrollWidth)は390pxのままで、横のスクロールは出ませんでした。

対応しているブラウザ

MDN の互換性データ(@mdn/browser-compat-data 8.1.8)では、次のバージョンから使えます。

機能 Chrome Firefox Safari
popover 属性 114 125 17
@starting-style 117 129 17.5
transition-behavior(allow-discrete) 117 129(一部) 17.4
overlay 117 なし なし

Firefox の transition-behavior には、「display を allow-discrete で transition させることには対応していない」という注記が付いています。overlay は、Firefox と Safari では「なし」になっています。

​Posted by 86