Bootstrap4 のナビゲーションバーの折り畳み (collapse) をカスタマイズする

今更ではありますが、 Bootstrap4 のナビゲーションバーの折り畳み (collapse) の開閉アニメーションをカスタマイズしたくなったのでやってみました。

成果物

今回は横からのドロワーにしてみました。

動機

Bootstrap4 のデフォルトの挙動はNavbar ・ Bootstrap v4.6ナビゲーションバー~Bootstrap4移行ガイド等で確認できます。

お馴染みの上下方向に折り畳みするアニメーションですね。ちなみに、このアニメーションは折り畳み (collapse) で実現しているようです。

そこでカスタマイズする方法を調べてみると【Bootstrap】細かすぎて使い道がないかもしれないBootstrap4小技集10選 | Wood-Roots:blogがヒットしました。

ただしこの方法では横に開く場合でもheightを取得、設定する処理自体は発生するため、Collapseを開くタイミングがワンテンポ遅れます。この点を回避したい場合はソースを修正して再ビルドする必要があります。

【Bootstrap】細かすぎて使い道がないかもしれないBootstrap4小技集10選 | Wood-Roots:blog

記事内にこのような記載があり、確かに折り畳み (collapse) のアニメーションに干渉するにはJSのソースから手を入れなければならないか……?と思いつつ、それ以外の方法がないか模索してみることにしました。

調査

デモ等で何度も折り畳み (collapse) のアニメーションを動作させると、

  • 折り畳んだ状態 (デフォルト状態、または .navbar-toggler.collapsed が付与かつ aria-expanded="false" で、 navbar-collapse .collapse.show が付与されていない状態)
  • 遷移状態 (navbar-collapse .collapse.collapsing が付与されている状態)
  • 展開状態 (.navbar-toggler.collapsed が付与されていないかつ aria-expanded="true" で、 navbar-collapse .collapse.show が付与されている状態)

この3つの状態を行き来してることが分かりました。

最初は css transition で干渉しようかと思いましたが、この状態遷移ではJSも絡んでくること、また、アニメーションの内容によっては充分に制御することができない場合もあったので断念しました (opacity: 0;opacity: 1; を transition すると、展開時はスムーズにアニメーションが開始するが、折り畳み時はトグルボタンをクリックしてから若干 (デフォルトの折り畳みを実行する時間, 0.2~0.3秒程?) のタイムラグが発生し、もっさりした動きにかじられる、といった具合)。

そこで別の方法を模索。

折り畳み (collapse) にはイベントがAPIとして公開されているため、これを使ってデフォルトの挙動を強制的に無効化して、クラスの付与・削除のみを実行する処理に置き換えてしまうことにしました。

コード

ということで、コード。

<nav class="navbar navbar-expand-lg navbar-light bg-white fixed-top l-header p-0" id="navbar">
    <div class="container">
        <a class="navbar-brand my-0" href="./"><%= commons.sitename %></a>
        <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarList" aria-controls="navbarList" aria-expanded="false" aria-label="ナビゲーションの切替">
            <span class="navbar-toggler-icon"></span>
        </button>
    <div class="collapse navbar-collapse" id="navbarList">
        <ul class="nav navbar-nav ml-xl-auto">
            <li class="nav-item">
                <a class="nav-link" href="#">hoge</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="#">piyo</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="#">fuga</a>
            </li>
        </ul>
    </div>
</nav>

例えばこんなナビゲーションバーのコンポーネントを用意します。

$(() => {
    if($('#navbarList').length > 0 && $('#navbar .navbar-toggler').length > 0) {
        // navbar にメニューとハンバーガーアイコンが存在する場合
        const $navbarList = $('#navbarList');
        const $navbarToggler = $('#navbar .navbar-toggler');
        $navbarList.on('show.bs.collapse', function () {
            // 展開時のデフォルトの挙動を潰して Collapse の結果のクラスの付与・削除のみ実施
            $navbarToggler.removeClass('collapsed');
            $navbarList.addClass('show');
        })
        $navbarList.on('hide.bs.collapse', function () {
            // 格納時のデフォルトの挙動を潰して Collapse の結果のクラスの付与・削除のみ実施
            $navbarToggler.addClass('collapsed');
            $navbarList.removeClass('show');
        });
    }
});

JS (jQuery) はこのような感じで。 show.bs.collapse, hide.bs.collapse のイベントトリガーに関数を付けて、処理を上書きしています。

後は単純にクラスの付け外しです。

.navbar {
    .container-fluid {
        position: relative; //ドロワーのため
    }
    &-collapse {
        width: 100%;
        @media (max-width: 1199px) {
            min-height: calc(100vh - 60px);
            transition: opacity 0.3s ease
            background-color: #fff;
            color: #333;
            padding: 2rem 3rem;
            position: absolute;
            top: 60px;
            transition: left 0.4s ease;
            &:not(.show) {
                display: block;
                left: 100vw;
            }
            &.show {
                left: 0;
            }
            .navbar-nav {
                width: 100%;
            }
            .nav-item {
                padding: 1rem;
                &:hover,
                &:active,
                &:focus {
                    background-color: #000;
                    transition: color 0.3s ease, background-color 0.3s ease;
                }
                .nav-link {
                    font-size: 1.25rem;
                    &,
                    &:link,
                    &:hover,
                    &:active,
                    &:focus {
                        color: #fff;
                        text-decoration: none;
                    }
                }
            }
        }
    }
}

デザイン的な装飾も混ざっていますが、ドロワーのアニメーションとしてはクラスの付け外しに伴って css transition で left: 100vw; から left: 0;.navbar-nav を移動させているだけです。

後はドロワーにするために Bootstrap4 のコンテナの padding 等と干渉を避けるようにしたり。

ハンバーガーアイコンのアニメーション

基本はCSSで実装するハンバーガーメニュークリック時のエフェクト 10+ – NxWorldを参考に。

<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarList" aria-controls="navbarList" aria-expanded="false" aria-label="ナビゲーションの切替">
    <span></span>
    <span></span>
    <span></span>
</button>

HTML は span を3つ用意。

@use "sass:map";
@use "sass:math";

$hamburger: (
    button-wh: 2.75rem,
    icon-width: 2rem,
    icon-height: 2px,
    margin: 0.5rem,
    top: 0.75rem
);

@keyframes hamburger-bar1 {
    0% {
        transform: translateY(map.get($hamburger, margin)) rotate(45deg);
    }
    50% {
        transform: translateY(map.get($hamburger, margin)) rotate(0);
    }
    100% {
        transform: translateY(0) rotate(0);
    }
}
@keyframes active-hamburger-bar1 {
    0% {
        transform: translateY(0) rotate(0);
    }
    50% {
        transform: translateY(map.get($hamburger, margin)) rotate(0);
    }
    100% {
        transform: translateY(map.get($hamburger, margin)) rotate(45deg);
    }
}
@keyframes hamburger-bar3 {
    0% {
        transform: translateY(calc(-1 * map.get($hamburger, margin))) rotate(-45deg);
    }
    50% {
        transform: translateY(calc(-1 * map.get($hamburger, margin))) rotate(0);
    }
    100% {
        transform: translateY(0) rotate(0);
    }
}
@keyframes active-hamburger-bar3 {
    0% {
        transform: translateY(0) rotate(0);
    }
    50% {
        transform: translateY(calc(-1 * map.get($hamburger, margin))) rotate(0);
    }
    100% {
        transform: translateY(calc(-1 * map.get($hamburger, margin))) rotate(-45deg);
    }
}

.navbar {
    & .navbar-toggler {
        border-radius: 0;
        border-color: transparent;
        position: relative;
        width: map.get($hamburger, button-wh);
        height: map.get($hamburger, button-wh);
        &:focus {
            outline: 1px solid #333;  // outline カスタマイズ
        }
        span {
            width: map.get($hamburger, icon-width);
            height: map.get($hamburger, icon-height);
            background-color: #333;
            display: block;
            position: absolute;
            left: math.div((map.get($hamburger, button-wh) - map.get($hamburger, icon-width)), 2);
            &:first-of-type {
                top: map.get($hamburger, top);
            }
            &:nth-of-type(2) {
                top: calc(map.get($hamburger, top) + map.get($hamburger, margin));
            }
            &:last-of-type {
                top: calc(map.get($hamburger, top) + map.get($hamburger, margin) * 2);
            }
        }
        &.collapsed {
            span {
                &:first-of-type {
                    animation: hamburger-bar1 0.5s forwards;
                }
                &:nth-of-type(2) {
                    opacity: 1;
                }
                &:last-of-type {
                    animation: hamburger-bar3 0.5s forwards;
                }
            }
        }
        &.active {
            span {
                &:first-of-type {
                    animation: active-hamburger-bar1 0.5s forwards;
                }
                &:nth-of-type(2) {
                    opacity: 0;
                }
                &:last-of-type {
                    animation: active-hamburger-bar3 0.5s forwards;
                }
            }
        }
    }
}

本題とは関係ないですが、ハンバーガーアイコン周りの数値はわりと緻密に計算したかったので Scss の配列としてまとめました。

$(() => {
    if($('#navbarList').length > 0 && $('#navbar .navbar-toggler').length > 0) {
        // navbar にメニューとハンバーガーアイコンが存在する場合
        const $navbarList = $('#navbarList');
        const $navbarToggler = $('#navbar .navbar-toggler');
        $navbarList.on('show.bs.collapse', function () {
            // 展開時のデフォルトの挙動を潰して Collapse の結果のクラスの付与・削除のみ実施
            $navbarToggler.removeClass('collapsed');
            $navbarToggler.addClass('active');
            $navbarList.addClass('show');
        })
        $navbarList.on('hide.bs.collapse', function () {
            // 格納時のデフォルトの挙動を潰して Collapse の結果のクラスの付与・削除のみ実施
            $navbarToggler.addClass('collapsed');
            $navbarToggler.removeClass('active');
            $navbarList.removeClass('show');
        });
    }
});

JS は1つ .active の付け外しを追加。これはハンバーガーアイコンで :not(.collapsed)collapsed のトグルのみだと、ページの初回表示時にもハンバーガーを綴じるアニメーションが走ってしまうのでそれを抑制したくて別のクラスを付与することにしました。

これでもブレークポイントをまたがって行き来した際のアニメーションは抑制できていないので不完全ではありますが、一応対処ということで。

参考

Bootstrap4, Navbarコンポーネント

Bootstrap4, 折り畳み (collapse)

折り畳み (collapse) のカスタマイズ

Bootstrap4, 折り畳み (collapse) のイベント

Bootstrap4, 折り畳み (collapse) のJS

css transition

Bootstrap4 のコンテナ

ハンバーガーアイコンのカスタマイズ

Dart Sass の配列と map.get

css outline

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。