さぁ!検索しよう!

HTML+CSS+JavaScriptでLightbox風のモーダルウィンドウを作る方法です。

Demo

全体の構成を定義する

始めに以下のHTMLを書いて全体の構成を定義します。

<div id="overlay" class="overlay"></div>
<div id="modal-content" class="modal-content">
    <ul id="pager" class="pager">
        <li class="prev"><button class="prev-btn"><</button></li>
        <li class="next"><button class="next-btn">></button></li>
    </ul>
    <button id="close-btn" class="close-btn">☓</button>
    <div id="enlarged-images" class="enlarged-images"></div>
</div>
<div id="thumbnails" class="thumbnails">

</div>

HTMLではオーバーレイ#overlayとモーダルウィンドウのコンテンツ#modal-content、サムネイル一覧#thumbnailsを定義しています。

#modal-content内は前や次の拡大画像に切り替えるためのページャー#pagerやモーダルウィンドウを閉じるボタン#close-btn、拡大画像を表示する要素enlarged-imagesを含んでいます。

#enlarged-image#thumbnails内は後にJavaScriptで画像要素を挿入するため現時点では空にしておきます。

全体の見た目を作る

次に以下のCSSを書いて全体の見た目を作ります。

@mixin translate($hVal, $vVal) {
    transform: translate($hVal, $vVal);
}

@mixin top-left($pVal, $tVal, $lVal) {
    position: $pVal;
    top: $tVal;
    left: $lVal;
}

@mixin top-right($pVal, $tVal, $rVal) {
    position: $pVal;
    top: $tVal;
    right: $rVal;
}

%inactive {
    opacity: 0;
    visibility: hidden;
}

* {
    box-sizing: border-box;
}

body {
    background: #333;
}

.thumbnails {
    > div {
        background: #ccc;
        border: 5px solid #fff;
        cursor: pointer;
        display: block;
        float: left;
        overflow: hidden;
        position: relative;
        padding-bottom: 40%;
        width: 50%;
        @media (min-width: 800px) {
            padding-bottom: 15%;
            width: 25%;
        }
    }
    img {
        user-select: none;
            -webkit-user-select: none;
            -webkit-user-drag: none;
        display: block;
        max-height: 200%;
        @include top-left(absolute, 0, 0);
        width: 100%;
    }
}

.overlay {
    @extend %inactive;
    background: #000;
    height: 100%;
    @include top-left(fixed, 0, 0);
    transition: 0.5s;
    width: 100%;
    z-index: 1000;
    &.active {
        opacity: 0.6;
        visibility: visible;
    }
}

.modal-content {
    @extend %inactive;
    background: #ccc;
    height: 50vw;
    overflow: hidden;
    @include top-left(fixed, 50%, 50%);
    @include translate(-50%, -50%);
    transition: 0.5s;
    width: 50%;
    z-index: 2000;
    &.active {
        opacity: 1;
        visibility: visible;
    }
    img {
        //@extend %inactive;
        @include top-left(absolute, 50%, 50%);
        @include translate(-50%, -50%);
        transition: 0.5s;
        width: 100%;
        /*
        &.active {
            opacity: 1;
            visibility: visible;
        }
        */
    }
    @media (min-width: 800px) {
        height: 400px;
        width: 500px;
    }
}

.close-btn {
    background: #333;
    border: none;
    color: #fff;
    cursor: pointer;
    height: 8vw;
    line-height: 8vw;
    @include top-right(absolute, 0, 0);
    text-align: center;
    width: 8vw;
    z-index: 4000;
    @media (min-width: 800px) {
        line-height: 44px;
        height: 44px;
        width: 44px;
    }
}

.pager {
    height: 8vw;
    list-style: none;
    margin: 0;
    padding: 0;
    @include top-left(absolute, 50%, 0);
    @include translate(0, -50%);
    width: 100%;
    z-index: 4000;
    @media (min-width: 800px) {
        height: 44px;
    }
    > .prev,
    > .next {
        position: absolute;
        top: 0;
        > button {
            background: #333;
            border: none;
            color: #fff;
            cursor: pointer;
            height: 8vw;
            line-height: 8vw;
            text-align: center;
            width: 8vw;
            z-index: 5000;
            @media (min-width: 800px) {
                height: 44px;
                line-height: 44px;
                width: 44px;
            }
        }
    }
    > .prev {
        left: 0;
    }
    > .next {
        right: 0;
    }
}

CSSのポイントは以下の通りです。

Mixinを定義

transformpositionプロパティは頻繁に使うため以下のようなMixinを定義します。

transformプロパティのMixin

@mixin translate($hVal, $vVal) {
    transform: translate($hVal, $vVal);
}

positionプロパティのMixin

@mixin top-left($pVal, $tVal, $lVal) {
    position: $pVal;
    top: $tVal;
    left: $lVal;
}

@mixin top-right($pVal, $tVal, $rVal) {
    position: $pVal;
    top: $tVal;
    right: $rVal;
}

プレースホルダーを定義

要素を非表示にするスタイルは頻繁に記述するため以下のようなプレースホルダーとしておきます。

%inactive {
    opacity: 0;
    visibility: hidden;
}

初期状態を設定

初期状態としてオーバーレイとモーダルウィンドウのコンテンツは予め非表示にしておきます。また、JavaScriptでこれらの要素をフェードイン・アウトさせるために必要な.activeクラスも併せて作成します。

オーバーレイの初期状態

.overlay {
    @extend %inactive;
    &.active {
        opacity: 0.6;
        visibility: visible;
    }
}

モーダルコンテンツの初期状態

.modal-content {
    @extend %inactive;
    &.active {
        opacity: 1;
        visibility: visible;
    }
}

JavaScriptでこれらの要素に.activeクラスが追加・削除されるとフェードイン・アウトが行われます。

機能を実装する

最後に以下のJavaScriptを書いてHTMLとCSSによって出来上がったものに機能を実装します。

(() => {
    class Asset {
        constructor() {
            this.urls = [
                'https://tsukulog.net/wp-content/uploads/2015/06/HIRA86_MBAkey-thumb-1000xauto-17206.jpg',
                'https://tsukulog.net/wp-content/uploads/2015/06/GAK88_nekokafeneko-thumb-1000xauto-16206.jpg',
                'https://tsukulog.net/wp-content/uploads/2015/06/N825_benchinouenoshironeko-thumb-autox1000-14809.jpg',
                'https://tsukulog.net/wp-content/uploads/2015/06/https-www.pakutaso.com-assets_c-2015-05-26NJ_retuwotukuruahirucyan-thumb-1000xauto-14005.jpg'
            ];

            this.images = [];
        }

        createImage() {
            for(let i = 0; i < this.urls.length; i++) {
                const image = new Image();
                image.src = this.urls[i];
                this.images[i] = image;
            }
        }
    }

    class Thumbnail {
        constructor() {
            //サムネイル一覧
            this.elem = document.getElementById('thumbnails');
            this._image = this.elem.getElementsByTagName('img');
        }

        setupListener(modalContent, overlay) {
            const thumbnail = this._image;

            for(let i = 0; i < thumbnail.length; i++) {
                thumbnail[i].addEventListener('click', (e) => {
                    e.preventDefault();

                    //クリックしたサムネイルを取得
                    const target = e.target;

                    const clone = target.cloneNode(true);

                    //クリックしたサムネイルの番号
                    const currentIndex = [].slice.call(thumbnail).indexOf(target);
                    //console.log(currentIndex);

                    //console.log(thumbnail[currentIndex-2]);

                    //オーバーレイをフェードイン
                    overlay.show();

                    //モーダルウィンドウをフェードイン
                    modalContent.show();
                    modalContent.removeImage();
                    modalContent.addImage(clone);
                    modalContent.currentIndex = currentIndex;
                });
            }
        }

        get image() {
            return this._image;
        }

        insertImages(images) {
            images.forEach((image) => this.elem.appendChild(image));
        }

        wrapImages(images) {
            images.forEach((image) => image.outerHTML = `<div class="thumbnail">${image.outerHTML}</div>`);
        }
    }

    //モーダルウィンドウに関するクラス
    class ModalContent {
        constructor() {

            //モーダルウィンドウ
            this.elem = document.getElementById('modal-content');

            //拡大画像を子に持つ親要素
            this.enlargedImages = document.getElementById('enlarged-images');

            this._currentIndex = 0;
        }

        get currentIndex() {
            return this._currentIndex;
        }

        set currentIndex(value) {
            if(typeof value === 'number') {
                this._currentIndex = value;
            }
        }

        //モーダルウィンドウを表示するメソッド
        show() {
            this.elem.classList.add('active');
        }

        //モーダルウィンドウを非表示にするメソッド
        hide() {
            this.elem.classList.remove('active');
        }

        addImage(image) {
            this.enlargedImages.appendChild(image);
        }

        removeImage() {
            if(this.enlargedImages.firstChild) {
                this.enlargedImages.removeChild(this.enlargedImages.firstChild);
            }
        }

        slide(index, image) {

            //番号の最大値
            const max = image.length - 1;

            //渡された番号が0未満であればmaxに変更
            if(index < 0) {
                index = max;
            }

            //渡された番号がmax超過であれば0に変更
            if(index > max) {
                index = 0;
            }

            //渡された番号の拡大画像を表示
            const clone = image[index].cloneNode(true);

            this.removeImage();
            this.addImage(clone);

            //現在の番号を更新
            this.currentIndex = index;
        }
    }

    //オーバーレイに関するクラス
    class Overlay {
        constructor() {
            this.elem = document.getElementById('overlay');
        }

        setupListener(modalContent) {
            this.elem.addEventListener('click', (e) => {
                this.hide();
                modalContent.hide();
            });
        }

        show() {
            this.elem.classList.add('active');
        }

        hide() {
            this.elem.classList.remove('active');
        }
    }

    //ページャーに関するクラス
    class Pager {
        constructor() {
            this.elem = document.getElementById('pager');
            this.btn = this.elem.getElementsByTagName('button');
        }

        setupListener(modalContent, thumbnail) {
            const pagerBtn = this.btn;

            //.prev-btnをクリック→一つ前の画像に切り替え
            //.next-btnをクリック→一つ後の画像に切り替え
            for(let i = 0; i < pagerBtn.length; i++) {
                pagerBtn[i].addEventListener('click', (e) => {
                    if(pagerBtn[i].classList.contains('prev-btn')) {
                        modalContent.currentIndex = modalContent.currentIndex - 1;
                        console.log(modalContent.currentIndex);
                        //console.log(modalWindow.image[modalWindow.currentIndex]);
                        //modalWindow.slide(modalWindow.currentIndex, modalWindow.image);
                    }else{
                        modalContent.currentIndex = modalContent.currentIndex + 1;
                        console.log(modalContent.currentIndex);
                        //modalWindow.slide(currentIndex + 1, modalWindow.image);
                    }

                    modalContent.slide(modalContent.currentIndex, thumbnail.image);
                });
            }
        }
    }

    //閉じるボタンに関するクラス
    class CloseBtn {
        constructor() {
            this.elem = document.getElementById('close-btn');
        }

        //.close-btnをクリックできる状態にするメソッド
        setupListener(overlay, modalContent) {
            this.elem.addEventListener('click', (e) => {
                overlay.hide();
                modalContent.hide();
            });
        }
    }

    //インスタンス化
    const asset = new Asset();
    const thumbnail = new Thumbnail();
    const modalContent = new ModalContent();
    const overlay = new Overlay();
    const pager = new Pager();
    const closeBtn = new CloseBtn();

    //全体を初期化する関数
    function init() {
        asset.createImage();
        thumbnail.insertImages(asset.images);
        thumbnail.wrapImages(asset.images);
        setupListeners();
    }

    //モーダルウィンドウを操作できる状態にする関数
    function setupListeners() {
        thumbnail.setupListener(modalContent, overlay);
        overlay.setupListener(modalContent);
        pager.setupListener(modalContent, thumbnail);
        closeBtn.setupListener(overlay, modalContent);
    }

    init();
})();

事前準備

機能を実装する前に次の事前準備に関するコードを書きます。

画像を生成する

Assetクラスのコンストラクタで画像4枚分のURLを定義します。

this.urls = [
  'https://tsukulog.net/wp-content/uploads/2015/06/HIRA86_MBAkey-thumb-1000xauto-17206.jpg',
  'https://tsukulog.net/wp-content/uploads/2015/06/GAK88_nekokafeneko-thumb-1000xauto-16206.jpg',
  'https://tsukulog.net/wp-content/uploads/2015/06/N825_benchinouenoshironeko-thumb-autox1000-14809.jpg',
  'https://tsukulog.net/wp-content/uploads/2015/06/https-www.pakutaso.com-assets_c-2015-05-26NJ_retuwotukuruahirucyan-thumb-1000xauto-14005.jpg'
];

その後createImageメソッドで画像を生成します。生成した画像はimages配列に格納します。

createImage() {
  for(let i = 0; i < this.urls.length; i++) {
    const image = new Image();
    image.src = this.urls[i];
    this.images[i] = image;
  }
}

サムネイルを生成する

ThumbnailクラスのwrapImagesメソッドで先程生成した画像を.thumbnail要素で包んでサムネイルを生成します。

wrapImages(images) {
  images.forEach((image) => image.outerHTML = `<div class="thumbnail">${image.outerHTML}</div>`);
}

サムネイルを挿入する

ThumbnailクラスのinsertImagesメソッドで生成したサムネイルを.thumbnails内に挿入します。

insertImages(images) {
  images.forEach((image) => this.elem.appendChild(image));
}

実装する機能

実装する機能は次の通りです。

サムネイルをクリックするとモーダルウィンドウを表示する

ThumbnailクラスのsetupListenerメソッドでサムネイルをクリックするとモーダルウィンドウがフェードイン表示されるようにします。

setupListener(modalContent, overlay) {
  const thumbnail = this._image;

  for(let i = 0; i < thumbnail.length; i++) {
    thumbnail[i].addEventListener('click', (e) => {
      e.preventDefault();

      //クリックしたサムネイルを取得
      const target = e.target;

      const clone = target.cloneNode(true);

      //クリックしたサムネイルの番号
      const currentIndex = [].slice.call(thumbnail).indexOf(target);

      //オーバーレイをフェードイン
      overlay.show();

      //モーダルウィンドウをフェードイン
      modalContent.show();
      modalContent.removeImage();
      modalContent.addImage(clone);
      modalContent.currentIndex = currentIndex;
    });
  }
}

仕組みは次の通りです。

  1. クリックしたサムネイルの画像targetを複製する。
  2. overlayshowメソッドでオーバーレイをフェードイン表示する
  3. modalContentshowメソッドでモーダルコンテンツをフェードイン表示する。
  4. modalContentremoveImageメソッドで#enlarged-image内の直前まで表示されていた画像を削除する。
  5. modalContentaddImageメソッドで#enlarged-image内に複製した画像cloneを挿入する。

又、取得したクリックしたサムネイル画像の番号currentIndexmodalContentcurrentIndexに代入して更新します。

ページャーをクリックすると前後の画像に切り替える

PagerクラスのsetupListenerメソッドでページャーボタンをクリックするとモーダルウィンドウ内の画像が切り替わるようにします。

setupListener(modalContent, thumbnail) {
  const pagerBtn = this.btn;

  //.prev-btnをクリック→一つ前の画像に切り替え
  //.next-btnをクリック→一つ後の画像に切り替え
  for(let i = 0; i < pagerBtn.length; i++) {
    pagerBtn[i].addEventListener('click', (e) => {
      if(pagerBtn[i].classList.contains('prev-btn')) {
        modalContent.currentIndex = modalContent.currentIndex - 1;
      }else{
        modalContent.currentIndex = modalContent.currentIndex + 1;
      }

      modalContent.slide(modalContent.currentIndex, thumbnail.image);
    });
  }
}

仕組みは以下の通りです。

  1. クリックしたボタンが戻るボタン.prev-btnであればmodalContentをデクリメントする。
  2. クリックしたボタンが進むボタン.next-btnであればmodalContentをインクリメントする。
  3. modalContentslideメソッドで1.であれば一つ前の画像に戻し、2.であれば一つ後の画像に進める。

modalContentslideメソッドは以下のようになっています。

slide(index, image) {

  //番号の最大値
  const max = image.length - 1;

  //渡された番号が0未満であればmaxに変更
  if(index < 0) {
    index = max;
  }

  //渡された番号がmax超過であれば0に変更
  if(index > max) {
    index = 0;
  }

  //渡された番号の拡大画像を表示
  const clone = image[index].cloneNode(true);

  this.removeImage();
  this.addImage(clone);

  //現在の番号を更新
  this.currentIndex = index;
}

閉じるボタンをクリックするとモーダルウィンドウを非表示にする

CloseBtnクラスのsetupListenerメソッドで閉じるボタンがクリックされるとモーダルウィンドウがフェードアウトで非表示されるようにします。

//.close-btnをクリックできる状態にするメソッド
setupListener(overlay, modalContent) {
  this.elem.addEventListener('click', (e) => {
    overlay.hide();
    modalContent.hide();
  });
}

仕組みは.close-btnがクリックされるとoverlaymodalContenthideメソッドで両者をフェードアウトで非表示にしています。

overlaymodalContenthideメソッドは同じ処理内容です。

hide() {
  this.elem.classList.remove('active');
}

オーバーレイをクリックするとモーダルウィンドウを非表示にする

OverlayクラスのsetupListenerメソッドでオーバーレイがクリックされるとモーダルウィンドウがフェードアウトで非表示されるようにします。

setupListener(modalContent) {
  this.elem.addEventListener('click', (e) => {
    this.hide();
    modalContent.hide();
  });
}

仕組みはCloseBtnクラスのsetupListenerメソッドと同様で自身とmodalContenthideメソッドでオーバーレイとモーダルコンテンツの両者を非表示にしています。

モーダルウィンドウを完成させる

最後に以下のことを行って実装した機能を働かせてモーダルウィンドウを完成させます。

インスタンス化

作成した全てのクラスをインスタンス化して実装した機能を使えるようにします。

//インスタンス化
const asset = new Asset();
const thumbnail = new Thumbnail();
const modalContent = new ModalContent();
const overlay = new Overlay();
const pager = new Pager();
const closeBtn = new CloseBtn();

全体を初期化する

init関数を実行して全体を初期化すればモーダルウィンドウの完成です。

init();

init関数は以下のようになっています。

//全体を初期化する関数
function init() {
  asset.createImage();
  thumbnail.insertImages(asset.images);
  thumbnail.wrapImages(asset.images);
  setupListeners();
}

init関数では以下のようにしてそれぞれのオブジェクトの初期化を行っています。

  1. assetcreateImageメソッドで画像を生成する。
  2. thumbnailinsertImagesメソッドで1.を挿入する。
  3. thumbnailwrapImagesメソッドでサムネイルを生成する(ここまでが事前準備)。
  4. setupListeners関数で各要素をクリックできる状態にする。

setupListeners関数は以下のようになっています。

//モーダルウィンドウを操作できる状態にする関数
function setupListeners() {
  thumbnail.setupListener(modalContent, overlay);
  overlay.setupListener(modalContent);
  pager.setupListener(modalContent, thumbnail);
  closeBtn.setupListener(overlay, modalContent);
}

setupListeners関数ではthumbnailoverlaypagercloseBtnオブジェクトのsetupListenerメソッドを実行しているだけです。

おわりに

以上で、【JavaScript】ページャー付きのLightbox風モーダルウィンドウを作る方法を終わります。

参考文献