MENU CLOSE

レスポンシブなドロップダウンメニューをつくる

Dropdown Menu
Dropdown Menu

ドロップダウンメニューって?

メニューをクリックしたりマウスを上に載せたりすることでそのメニューの下に出てくるようなメニューのことです。

ドロップダウンメニューの例

ドロップダウンメニューの実装方法はググると結構出てきます。
ただjQueryプラグインとかをこのためだけに読み込ませたりするのはなんか違う気がするし、「CSSだけでできる!」とかいうのもありますが、実際は動いてもデザインが思い通りにならなかったり、タブレットで全然使い物にならなかったり、なんてことがありますね。

僕なんかは特にそうですが、ほぼ「コーディング専業」の人は依頼されたデザインに合わせて自由自在に変えられるように、このくらいのUIはササっと書ける知識を持っておく必要があります。

今回はそういう時に使えるメモです。
言うて自分もけっこう忘れるので。w

まずはデザインをhtml・cssで実装

基本的にはデザイナーから依頼されたデザインを完全に再現するという体で進めます。 そのためCSSでデザインを忠実に再現することが前提となります。

今回はデザイナーなしで自分で作りますがご容赦ください。

html

<nav class="nav" id="js-nav">
  <ul class="nav-inner">
    <li class="nav-item">
      <a href="<?php the_permalink()?>">TOP</a>
    </li>
    <!-- /.nav-item -->
    <li class="nav-item">
      <a href="<?php the_permalink()?>">ABOUT</a>
    </li>
    <!-- /.nav-item -->
    <li class="nav-item nav-dropdown js-dropdown">
      <a href="<?php the_permalink()?>">CATEGORY</a>
      <button type="button" class="nav-toggle js-toggle"></button>
      <ul class="dropdown">
        <li class="dropdown-item has-child js-has-child">
          <a href="<?php the_permalink()?>">CATEGORY A</a>
          <button type="button" class="nav-toggle js-toggle"></button>
          <ul class="dropdown-child">
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">A-ITEM 1</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">A-ITEM 2</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">A-ITEM 2</a>
            </li>
            <!-- /.dropdown-child-item -->
          </ul>
          <!-- /.dropdown-child -->
        </li>
        <!-- /.dropdown-item -->
        <li class="dropdown-item has-child js-has-child">
          <a href="<?php the_permalink()?>">CATEGORY B</a>
          <button type="button" class="nav-toggle js-toggle"></button>
          <ul class="dropdown-child">
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 1</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 2</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 3</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 4</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 5</a>
            </li>
            <!-- /.dropdown-child-item -->
            <li class="dropdown-child-item">
              <a href="<?php the_permalink()?>">B-ITEM 6</a>
            </li>
            <!-- /.dropdown-child-item -->
          </ul>
          <!-- /.dropdown-child -->
        </li>
        <!-- /.dropdown-item -->
        <li class="dropdown-item">
          <a href="<?php the_permalink()?>">CATEGORY C</a>
        </li>
        <!-- /.dropdown-item -->
      </ul>
      <!-- /.dropdown -->
    </li>
    <!-- /.nav-item -->
    <li class="nav-item">
      <a href="<?php the_permalink()?>">CONTACT</a>
    </li>
    <!-- /.nav-item -->
  </ul>
  <!-- /.nav-inner -->
</nav>
<!-- /.nav -->

このコードはWordPressでデモ用に適当に作ったのでリンクが全てthe_permalink()になっちゃってますが、今回は動きだけの紹介ですので気にしないでください。笑

css

CSSはリセットされている前提で進めます。

//ブレイクポイントを変数で設定
$bp_tb_v: 768px; //タブレット縦
$bp_tb_h: 960px; //タブレット横・PC

.nav{
  @include min-screen($bp_tb_v){
    display: flex;
    justify-content: center;
  }
}

.nav-inner{
  background: #eee;

  @include min-screen($bp_tb_v){
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    width: 100%;
    max-width: 1080px;
    margin-right: auto;
    margin-left: auto;
    padding:9px;
    background: #eee;
    border-radius: 9px;
    box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
  }

}

.nav-item{
  position: relative;
  @include min-screen($bp_tb_v){
    width: 25%;
  }

  &.is-show > a{
    @include min-screen($bp_tb_v){
      background: #ddd;
    }
  }

  + .nav-item{
    border-top:1px solid #fff;
    @include min-screen($bp_tb_v){
      border-top: none;
    }
  }

  &.is-show,
  &:hover {
    .dropdown{
      @include min-screen($bp_tb_v){
        opacity: 1;
        visibility: visible;
      }

      &::before{
        @include min-screen($bp_tb_v){
          transform:none;
        }
      }
    }
  }

  > a{
    padding: 15px;
    font-size: 18px;
    @include min-screen($bp_tb_v){
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 21px 30px;
      font-size: 15px;
      border-radius: 9px;
    }
    @include min-screen($bp_tb_h) {
      min-width:180px;
      font-size: 18px;
    }

    &:hover{
      @include min-screen($bp_tb_v){
        background: #ddd;
      }
    }
  }

}

.nav-dropdown{
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  @include min-screen($bp_tb_v){
    display: block;
  }

  a{
    flex-grow: 1;
  }
}

.nav-toggle{
  position: relative;
  width: 49px;
  min-height:49px;
  background: $blue;

  @include min-screen($bp_tb_v){
    display: none;
  }

  &::before,
  &::after{
    content:"";
    position: absolute;
    top:0;
    right:0;
    bottom:0;
    left:0;
    display: block;
    margin:auto;
    background: #fff;
    transition:.2s ease;
  }

  &::before{
    width:15px;
    height:1px;
  }

  &::after{
    width:1px;
    height:15px;

  }

  &.is-open{
    &::before{
      opacity: 0;
      transform:rotate(90deg);
    }
    &::after{
      transform:rotate(90deg);
    }
  }
}


.dropdown{
  display: none;
  width: 100%;
  background: rgba(#fff,.5);
  border-top:1px solid #fff;
  @include min-screen($bp_tb_v){
    opacity: 0;
    visibility: hidden;
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 10;
    display: block;
    width: 100%;
    padding: 30px 0 0;
    background: none;
    border-top: none;
    border-radius:9px;
    transition:.2s ease;
  }

  &::before{
    @include min-screen($bp_tb_v){
      content:"";
      position: absolute;
      top:-9px;
      right:0;
      left:0;
      z-index: 10;
      display: block;
      width: 1px;
      height: 30px;
      margin:auto;
      background: #000;
      transform-origin: top center;
      transform:scaleY(0);
      transition:.2s ease-out;
    }
  }
}

.dropdown-item{
  @include min-screen($bp_tb_v){
    position: relative;
  }

  + .dropdown-item {
    border-top: 1px solid #fff;
    @include min-screen($bp_tb_v){
      border-top: none;
    }
  }

  &.has-child{
    display: flex;
    flex-wrap: wrap;

    @include min-screen($bp_tb_v){
      position: relative;
      display: block;
    }

    &::after{
      @include min-screen($bp_tb_v){
        opacity: 0;
        visibility: hidden;
        content:"";
        position: absolute;
        top:0;
        right:-22px;
        z-index: 10;
        bottom:0;
        display: block;
        width: 30px;
        height: 1px;
        margin:auto;
        background: #000;
        transform-origin: left center;
        transform: scaleX(.5);
        transition:.2s ease-out;
      }
    }
  }

  &.is-show,
  &:hover{
    &::after{
      @include min-screen($bp_tb_v){
        opacity: 1;
        visibility: visible;
        transform: none;
      }
    }
    .dropdown-child{
      @include min-screen($bp_tb_v){
        opacity: 1;
        visibility: visible;
      }
    }
  }

  &.is-show {
    > a{
      @include min-screen($bp_tb_v){
        color:#fff;
        background: $blue;
      }
    }
  }

  a{
    padding:15px;
    font-size: 15px;
    @include min-screen($bp_tb_v){
      padding:18px 30px;
      text-align: center;
      border-radius:9px;
    }

    &:hover{
      @include min-screen($bp_tb_v){
        color:#fff;
        background: $blue;
      }
    }
  }
}

.dropdown-child{
  display: none;
  width: 100%;
  border-top:1px solid #fff;
  @include min-screen($bp_tb_v){
    opacity: 0;
    visibility: hidden;
    position: absolute;
    top:0;
    left:100%;
    display: block;
    padding-left:15px;
    background: none;
    border-top: none;
    transition:.2s ease;
  }
}

.dropdown-child-item{
  background: rgba(#fff,.3);
  @include min-screen($bp_tb_v){
    background: none;
  }

  + .dropdown-child-item{
    border-top: 1px solid #fff;
    @include min-screen($bp_tb_v){
      border-top: none;
    }
  }

  a{
    display: inline-block;
    font-size: 12px;
    @include min-screen($bp_tb_v){
      display: block;
      font-size: 15px;
    }
  }
}

全部書いてしまいましたが、
第一段階としてPCではCSSの:hoverで動くようにだけしておきます。
JSで追加するクラスのCSSに関してはその都度書いていきます。

基本的にこういった様々なサイトでよく使うようなUIを作る時はCSSのみで解決できる方が好ましいと思っています。
JavaScript全盛の時代ではありますが、Vueなどのフレームワークを使っても結局JSで操作してCSSやCSSの付いたクラスを付け替えることが大半なので:hover:focusといった擬似クラスが使える場合はそちらを優先して使った方が効率的に思います。
そのためこういった小規模UIを自作で作るときは先にCSSだけで動かすことを最初に考えます。

しかしレスポンシブがスタンダードになった最近では、スマホやタブレットといったマウスのないタッチ操作デバイスの対応は違ってきます。 スマホやタブレットでは逆にJSを使わないと制御できない部分が多くなります。
このドロップダウンメニューもCSSのみではスマホやタブレットでうまく機能しないのでJavaScriptでのカスタマイズが必要です。

ともあれひとまず最初の段階でCSSで見た目を作り込んでおくと次のJS実装がやりやすいと思います。

JavaScriptでタップ操作に対応する

スマホ・タブレット・PCで違う動作をするのでちょっとややこしくなりますが、分けて考えていきます。

サンプルはjQuery(ES6+をBabel7でトランスパイル)で書きます。

スマホ

スマホではタップして開くアコーディオンメニューにします。
横の「+」ボタンを押すと下のカテゴリを開いたり閉じたりします。

const $toggleBtns = $('.js-toggle');
$.each($toggleBtns, (i, el) => {
  el.addEventListener('touchstart', (e) => {
    e.preventDefault();
    e.stopPropagation();
    const $self = $(e.currentTarget);
    $self.toggleClass('is-open');
    $self.next().not(':animated').slideToggle(200);
  }, { passive: false });
});

.js-toggleが「+」ボタンです。
タップにはtouchstartイベントを使ってタッチした瞬間に発火させます。
addEventListenerを使っていますが、これで引数に{ passive: false }を付けていないとpreventDefaultを使った時に怒られます。jQueryのonでは{ passive: false }は付けられないので、リンクの動作をJSで制御する時などはaddEventListenerを使いましょう。

タップするとその直後の要素をnext()で取得し、slideToggle()を使ってメニューを開閉します。連打バグ防止のために.not(':animated')でアニメーションしていない時のみ動作するようにします。

「+」ボタンはCSSでタブレットの大きさになるとdisplay:none;で消えるように設定しているので、ここでは「スマホだけ動作するような判定処理」はしません。

タブレット

タブレットではPCと同じデザインになりますが、動作をマウスからタップに置き換えます。
1度目のタップでメニューを開いてメニューを開いた状態で同じリンクをタップするとリンクに飛ばす仕様です。また、メニューが開いた状態でメニュー以外をタップするとメニューを閉じます。

ここはスマホでもPCでも動作して欲しくない処理なので、「タブレットのみ」で動作するようにデバイス判定します。

デバイス判定

//デバイス判定
device(){
  const device = {
    isSp     : false,
    isTablet : false,
    isPC     : false
  };
  const ua = navigator.userAgent;
  //スマホ
  if(ua.indexOf('iPhone') > 0 || ua.indexOf('iPod') > 0 || (ua.indexOf('Android') > 0) && (ua.indexOf('Mobile') > 0) || ua.indexOf('Windows Phone') > 0){
    device.isSp = true;
  }
  //タブレット
  else if(ua.indexOf('iPad') > 0 || ua.indexOf('Android') > 0){
    device.isTablet = true;
  }
  //PC
  else{
    device.isPC = true;
  }
  return device;
}

親カテゴリのドロップダウン

デバイス判定の関数を使って、次に親カテゴリの動作。

const dropdown = document.querySelector('.js-dropdown');
dropdown.addEventListener('touchstart', (e) => {
  const device = device();
  if (!device.isTablet) {
    return;
  }
  const $dropdown = $(e.currentTarget);
  if($dropdown.hasClass('is-show')){
    return;
  }
  else {
    e.preventDefault();
    e.stopPropagation();
    $dropdown.addClass('is-show');
  }
}, false);

タブレットもタップするのでtouchstartでイベントを登録します。
最初にデバイス判定してタブレット以外の場合はreturnで終了させます。
タブレットの時のみ、.is-showクラスがない時につけるようにします。
この時、.is-showクラスがある場合はそのままreturnで終了し、ない場合はリンクの機能を止めてドロップダウンを開きたいのでpreventDefault()を使います。stopPropagation()はイベントの伝播を止めたい場合に使います。今回はなくても動きますが一応書いています。

子カテゴリのドロップダウン

//子カテゴリ
const $childDropdown = $('.js-has-child');
$.each($childDropdown, (i,el) => {
  el.addEventListener('touchstart', (e) => {
    const device = device();
    if (!device.isTablet) {
      return;
    }
    const $dropdown = $(e.currentTarget);
    if($dropdown.hasClass('is-show')){
      return;
    }
    else {
      e.preventDefault();
      e.stopPropagation();
      $childDropdown.removeClass('is-show');
      $dropdown.addClass('is-show');
    }
  }, false);
});

子カテゴリも親カテゴリと挙動は同じです。
ドロップダウンさせたいカテゴリに.js-has-childというクラスを付けておいて、それに対して動作させます。 
親カテゴリと違う点は、

  • 複数要素に対してイベントを発火させる
  • 1つのカテゴリが表示されたらそれ以外のカテゴリはすべて非表示にする

という2点です。

エリア外をタップすると閉じる

document.addEventListener('touchstart', (e) => {
  const device = device();
  if (!device.isTablet) {
    return;
  }
  if(!$('.js-dropdown').hasClass('is-show')){
    return;
  }
  if(!$(e.target).closest('.js-dropdown').length) {
    e.preventDefault();
    e.stopPropagation();
    $('.js-dropdown').removeClass('is-show');
    $('.js-has-child').removeClass('is-show');
  }
}, { passive: false });

documenttouchstartイベントを追加します。
これでどこをタップしてもイベントが発火する形になります。
タブレット以外の時やドロップダウンが開いていない時はすぐに終了。
タップした要素(e.target)の先祖に.js-dropdownが含まれていない場合にすべてのドロップダウンを閉じます。
その際にやはりpreventDefault()でリンクを機能させないようにしておきます。

これでタブレットで「1度目のタップでメニューを開いてメニューを開いた状態で同じリンクをタップするとリンクに飛ばす、またメニューが開いた状態でメニュー以外をタップするとメニューを閉じる」というちょっと複雑な条件をクリアできました。

JS全体

Classにまとめたもの

class Main {
  constructor(){
    this.bind()
  }

  //デバイス判定
  device(){
    const device = {
      isSp     : false,
      isTablet : false,
      isPC     : false
    };
    const ua = navigator.userAgent;
    //スマホ
    if(ua.indexOf('iPhone') > 0 || ua.indexOf('iPod') > 0 || (ua.indexOf('Android') > 0) && (ua.indexOf('Mobile') > 0) || ua.indexOf('Windows Phone') > 0){
      device.isSp = true;
    }
    //タブレット
    else if(ua.indexOf('iPad') > 0 || ua.indexOf('Android') > 0){
      device.isTablet = true;
    }
    //PC
    else{
      device.isPC = true;
    }
    return device;
  }

  bind(){
    //カテゴリ
    const dropdown = document.querySelector('.js-dropdown');
    dropdown.addEventListener('touchstart', (e) => {
      const device = this.device();
      if (!device.isTablet) {
        return;
      }
      const $dropdown = $(e.currentTarget);
      if($dropdown.hasClass('is-show')){
        return;
      }
      else {
        e.preventDefault();
        e.stopPropagation();
        $dropdown.addClass('is-show');
      }
    }, false);

    //子カテゴリ
    const $childDropdown = $('.js-has-child');
    $.each($childDropdown, (i,el) => {
      el.addEventListener('touchstart', (e) => {
        const device = this.device();
        if (!device.isTablet) {
          return;
        }
        const $dropdown = $(e.currentTarget);
        if($dropdown.hasClass('is-show')){
          return;
        }
        else {
          e.preventDefault();
          e.stopPropagation();
          $childDropdown.removeClass('is-show');
          $dropdown.addClass('is-show');
        }
      }, false);
    });

    document.addEventListener('touchstart', (e) => {
      const device = this.device();
      if (!device.isTablet) {
        return;
      }
      if(!$('.js-dropdown').hasClass('is-show')){
        return;
      }
      if(!$(e.target).closest('.js-dropdown').length) {
        e.preventDefault();
        e.stopPropagation();
        $('.js-dropdown').removeClass('is-show');
        $('.js-has-child').removeClass('is-show');
      }
    }, { passive: false });

    const $toggleBtns = $('.js-toggle');
    $.each($toggleBtns, (i, el) => {
      el.addEventListener('touchstart', (e) => {
        e.preventDefault();
        e.stopPropagation();
        const $self = $(e.currentTarget);
        $self.toggleClass('is-open');
        $self.next().not(':animated').slideToggle(200);
      }, { passive: false });
    });

  }


}

new Main();

レスポンシブなドロップダウンメニューのDEMO

レスポンシブなドロップダウンメニューのDEMO

どのデバイスでも動作するドロップダウンメニューができました。
デザインに関してはイケてないので参考にしないでくださいw

おわりに

最近よく作っているBASEデザインマーケットで販売しているテーマで、「タブレットのタップ操作がうまくいかない」といったご指摘を頂いて何度か修正しました。 その時に行ったこととほとんど同じ処理なのですが、しばらくすると忘れそうなのでここに書き残しておくことにしました。

ただこんなUI、Vueとかで作った方が早そうな気がしますね。
最近はjQueryは嫌いって人たちがかなり多いと思うので、また時間がある時にVueやネイティブバージョンも作ってみます。

記事一覧

RELATED

テキストの無限ループアニメーション【CSS + JS】
inoue
inoueのサムネイル
TIPS

【CSS + JS】テキストの無限ループアニメーション

shopify
inoue
inoueのサムネイル
TIPS

【Shopify】カートへの遷移を飛ばしてチェックアウトに進むボタンを設置する

PhotoSwipe v5
inoue
inoueのサムネイル
TIPS

【JS】PhotoSwipe v5を使って画像をポップアップ表示する

AVIF [ gulpでjpg・pngからAVIF画像を生成 ]
inoue
inoueのサムネイル
TIPS

gulpでjpg・pngからAVIF画像を生成する

MutationObserverでDOMを監視[PhotoSwipe(v5)+Swiper(v8)連携]
inoue
inoueのサムネイル
TIPS

【JS】MutationObserverでDOMを監視[PhotoSwipe(v5)+Swiper(v8)連携]

Google Sheets API スプレッドシートのデータをJSONで取得する
inoue
inoueのサムネイル
TIPS

【Google Sheets API】 スプレッドシートのデータをJSONで取得する

NEW POSTS

GA4連携人気記事ランキング機能を自作プラグイン化してみ【WordPress】
inoue
inoueのサムネイル
TRY

【WordPress】GA4連携の人気記事ランキング機能を自作プラグイン化してみた

netlify UPDATE BUILD IMAGE [ビルドイメージの更新]
inoue
inoueのサムネイル
TRY

【netlify】ビルドイメージを更新 [ Ubuntu Xenial 16.04 → Ubuntu Focal 20.04 ]

Google Business Profile API [ PHPでクチコミ一覧取得 ]
inoue
inoueのサムネイル
TIPS

【PHP】Google Business Profile APIを使ってクチコミを取得する

screendot screenshot API
inoue
inoueのサムネイル
TRY

スクリーンショットのAPI「screendot」を使ってみた

window.matchMedia hoverを判定
inoue
inoueのサムネイル
TIPS

【window.matchMedia】メディアクエリでhoverが使えるデバイスを判定

lax.js
inoue
inoueのサムネイル
TIPS

lax.jsの使い方【スクロール連動アニメーションの実装】

ブログ記事一覧
  • HOME
  • TIPS
  • レスポンシブなドロップダウンメニューをつくる