Sabigara

CSSのみ(JavaScriptなし)でハンバーガーメニューを実装する

最近開発しているプロジェクトで、JavaScriptを使わずにハンバーガーメニューを実装したい場面があった。いろいろ試してみたところ、最低限は実現できたのでここにメモしておく。

ただし、hackyというか無理矢理感があったり、アクセシビリティを担保するのが難しかったりするので、JavaScriptを使えるなら使ったほうがいい。このあたりの問題点は後述する。

INFO

5 UI components you can build without JavaScript(英語)も参照してほしい。

CodeSandbox

仕様

スクリーンの幅に余裕がある場合

ハンバーガーメニューではなく、下図のように普通のナビゲーションとして表示する。

ナビゲーションが表示されている

スクリーンの幅が一定以上狭い場合

ナビゲーションを非表示にして、ハンバーガーメニューを開くためのボタンを表示する。

ハンバーガーメニューを開くボタンが表示されている

クリックしたら右から滑ってくる。

ハンバーガーメニューが開いている

閉じるボタンで閉じる

当然閉じてほしい。

オーバーレイのクリックで閉じる

できればメニューの範囲外、すなわち黒の半透明のオーバーレイをクリックしたときも閉じてほしい。

htmlの構造

「JavaScriptを使わない」とか言っておきながらいきなりReactのコードを出してしまうが、Nextのエクスポート機能でhtml + cssに書き出すことを前提としている。stateを使用していないのでただのhtmlと同等と考えてもらいたい。

import styles from "./Header.module.scss"

export default function Header() {
  return (
    <header className={styles.header}>
      <input type="checkbox" id="drawer" className={styles.input} />
      <label htmlFor="drawer" className={styles.drawerButton} role="button" />
      <label htmlFor="drawer" className={styles.overlay} role="button" />
      <nav className={styles.nav}>
        <ul className={styles.navLinkList}>
          <li>
            <a href="#" className={styles.navLink}>
              Product
            </a>
          </li>
          // more links
        </ul>
      </nav>
    </header>
  )
}

headernav を組み合わせたごくありきたりなヘッダーだが、不自然な inputlabel が挟まっている。JavaScriptが使えないため、今回は input[type="checkbox"] をメニューの開閉のスイッチとして利用する。 labelhtmlFor をチェックボックスに向けることで、これらの要素の操作でON/OFFを切り替えることができる。

メディアクエリ

スクリーン幅によって挙動を変えるためのメディアクエリを @mixin として定義しておく。

$breakpoints: (
  "sm": 576px,
  "md": 768px,
  "lg": 992px,
  "xl": 1200px,
);

@mixin mq($size) {
  @media screen and (max-width: #{map-get($breakpoints, $size)}) {
    @content;
  }
}

ナビゲーションを非表示にする

translate: 100% でスクリーン右側の非表示領域にピッタリつけておく。

.nav {
  display: flex;
  column-gap: var(--space-2);
  place-self: center;
  transition-duration: var(--transition-duration-slow);
  transition-property: translate;

  @include mq("md") {
    position: absolute;
    flex-direction: column;
    align-items: flex-end;
    row-gap: var(--space-8);
    top: 0;
    right: 0;
    height: 100vh;
    min-width: 16rem;
    max-width: 100vw;
    padding-top: var(--space-20);
    padding-right: var(--space-2);
    translate: 100%;
    background-color: white;
    z-index: 999;
  }
}

inputlabel を重ねて表示

label をクリックすることでチェックボックスをON/OFFできる」と書いたが、実はこれにはアクセシビリティ上の問題がある。

label はタブでフォーカスできるのだが、スペースを押しても input の値は変更できない(Mac, Chromeで検証)。詳しくは以下の記事を参照してほしい。

アクセシビリティで気をつけるcheckbox,radioのCSS - Qiita※ 2020/12/15 フォーカスについて一部追記、参考リンクを追加※ 2020/04/16 加筆、CSSの表現を一部修正しましたこの記事で言いたいことはラジオボタン、チェックボックスをdi…ファビコンqiita.com

しょうがないので、以下のように inputlabel の位置を重ね合わせることで対応した。これはこれで何か別の問題がありそうにも思える。

@mixin drawerButtonShape {
  display: none;

  @include mq("md") {
    display: grid;
    place-items: center;
    position: absolute;
    top: 50%;
    right: var(--space-4);
    transform: translateY(-50%);
    width: var(--sizes-10);
    height: var(--sizes-10);
    border-radius: var(--radii-md);
    z-index: 9999;
  }
}

.input {
  @include drawerButtonShape();
  appearance: none; // ブラウザのスタイリングを除去
}

// label
.drawerButton {
  @include drawerButtonShape();
}

input:checked にスタイルを当てる

後は以下のように、メニューが開いたときのスタイルを :checked 擬似クラスに記述すれば完成する。

.input {
  @include mq("md") {
    &:checked {
      // メニューをスライド
      & ~ .nav {
        translate: 0;
      }
      // 閉じるアイコンに変更
      & ~ .drawerButton {
        background-image: url("/url/to/close/icon.svg");
      }
      // オーバーレイを表示
      & ~ .overlay {
        width: 100%;
        height: 100%;
        opacity: 0.5;
      }
    }
  }
}

課題

メニューが非表示のときもタブでフォーカスされてしまう

この実装ではメニューをスクリーンの範囲外に隠しているだけなので、タブを押せばメニュー内のリンクなどにフォーカスが当たってしまう。

これは display: none にすることで避けられるが、そうすると(この実装では)スライドのアニメーションが上手く動かない。

そのフォーカス・スクロールなどの諸問題

ハンバーガーメニューは一種のモーダルなので、他のモーダル系UIと同様に諸々の問題を抱えている。

HTMLでモーダルUIを作るときに気をつけたいこと - ICS MEDIAダイアログやハンバーガーメニューといったユーザーインタフェース(UI)は、多くのウェブサイトで利用されており頻繁に見かけます。どこでも見かけることから「簡単に作成できる」と思われがちですが、意外と実装が難しいUIです。ファビコンics.media

このあたりはJavaScriptを使わなければ解決できなさそうなので、今回は諦めるしかない。

まとめ

アクセシビリティも含めて完璧とはいかなかったが、cssだけでもそれっぽいUIを作ることができて楽しかった。

使えるならまともなモーダル系ライブラリを使いましょう。