サムネイル切り替えギャラリーのようなもの

電脳備忘録

本記事のソースコードの利用によって生じた損害について、当方は一切の責任を負いません。ご自身の判断と責任のもとで参照・ご利用ください。

商品ページで見かける、サムネイルを押すとメイン画像が入れ替わるあれを、自分用に一本書き直しました。名前は ThumbGallery、バージョンは v1.0.0。中身は HTML・CSS・JavaScript の3ファイルだけです。

jQuery は入れていません。同じ挙動はプラグインを一枚読み込めば済みますが、ライブラリを一つ抱えると、バージョンが上がるたびに追随するかどうかの判断が一件増えます。素の JavaScript で書けば、その判断がそもそも発生しません。(無くて困ったことは、今のところない)

使う側が書くのは、サムネイルを並べた ul だけにしました。メイン画像のエリアと左右の送りボタンは、JS が後から組み立てます。data-thumb-gallery を付けた要素を見つけたら自動で初期化する方式です。JS が動かない環境でも、サムネイルの一覧はそのまま残ります。

HTML

必要なのはサムネイルのリストだけです。高解像度画像を別に持っている場合は data-large を付けておくと、メイン表示にはそちらが使われます。

<div class="thumb-gallery" data-thumb-gallery>
  <ul class="thumb-gallery__thumbs">
    <li><img src="thumb1.jpg" data-large="large1.jpg" alt="正面"></li>
    <li><img src="thumb2.jpg" alt="側面"></li>
    <li><img src="thumb3.jpg" alt="パッケージ"></li>
  </ul>
</div>

CSS

サイズと色は、CSS 変数として先頭にまとめました。--tg- で始まる変数を上書きすれば、サイトごとの調整は CSS だけで閉じます。

レスポンシブは、メイン画像を width: 100%aspect-ratio でコンテナに追従させ、サムネイルと送りボタンのサイズを clamp() で伸縮させています。これでメディアクエリはほぼ書いていません。

送りボタンの矢印は、画像もアイコンフォントも使わず、正方形の上辺と右辺に線を引いて回転させただけです。rotate(45deg) で右、rotate(-135deg) で左。アイコン用の画像を足せば、管理するファイルがまた一枚増えます。ここではそれをしていません。

.thumb-gallery {
  /* ===== ここを上書きすれば見た目を変更できる ===== */
  --tg-max-width: 600px;
  --tg-aspect-ratio: 1 / 1;
  --tg-bg: #ffffff;
  --tg-radius: 4px;

  --tg-gap: 16px;
  --tg-thumb-size: clamp(56px, 18vw, 88px);   /* サムネイルはレスポンシブ */
  --tg-thumb-gap: clamp(8px, 2vw, 16px);
  --tg-thumb-border: #dddddd;
  --tg-active-color: #2563eb;

  --tg-nav-size: clamp(36px, 10vw, 48px);     /* 送りボタンもレスポンシブ */
  --tg-nav-bg: rgba(255, 255, 255, 0.85);
  --tg-nav-bg-hover: #ffffff;
  --tg-nav-arrow: #333333;
  --tg-nav-offset: 12px;

  box-sizing: border-box;
  width: 100%;
  max-width: var(--tg-max-width);
  margin-inline: auto;
}

.thumb-gallery *,
.thumb-gallery *::before,
.thumb-gallery *::after { box-sizing: border-box; }

/* メイン画像エリア */
.thumb-gallery__stage {
  position: relative;
  width: 100%;
  aspect-ratio: var(--tg-aspect-ratio);
  overflow: hidden;
  border-radius: var(--tg-radius);
  background-color: var(--tg-bg);
}
.thumb-gallery__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* 送りボタン */
.thumb-gallery__nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--tg-nav-size);
  height: var(--tg-nav-size);
  padding: 0;
  border: none;
  border-radius: 50%;
  background-color: var(--tg-nav-bg);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  cursor: pointer;
  transition: background-color 0.3s, opacity 0.3s;
}
.thumb-gallery__nav:hover { background-color: var(--tg-nav-bg-hover); }
.thumb-gallery__nav:focus-visible { outline: 2px solid var(--tg-active-color); outline-offset: 2px; }
.thumb-gallery__nav:disabled { opacity: 0.3; cursor: default; }

/* 矢印は枠線を回転させて描く(画像不要) */
.thumb-gallery__nav::before {
  content: '';
  width: 26%;
  height: 26%;
  border-top: 2px solid var(--tg-nav-arrow);
  border-right: 2px solid var(--tg-nav-arrow);
}
.thumb-gallery__nav--prev { left: var(--tg-nav-offset); }
.thumb-gallery__nav--prev::before { margin-left: 0.2em; transform: rotate(-135deg); }
.thumb-gallery__nav--next { right: var(--tg-nav-offset); }
.thumb-gallery__nav--next::before { margin-right: 0.2em; transform: rotate(45deg); }

/* サムネイル一覧 */
.thumb-gallery__thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tg-thumb-gap);
  margin: var(--tg-gap) 0 0;
  padding: 0;
  list-style: none;
}
.thumb-gallery__thumbs > li {
  width: var(--tg-thumb-size);
  height: var(--tg-thumb-size);
  overflow: hidden;
  border: 1px solid var(--tg-thumb-border);
  border-radius: var(--tg-radius);
  background-color: var(--tg-bg);
  cursor: pointer;
  transition: border-color 0.3s, box-shadow 0.3s;
}
.thumb-gallery__thumbs > li:focus-visible { outline: 2px solid var(--tg-active-color); outline-offset: 2px; }
.thumb-gallery__thumbs > li.is-active {
  border-color: var(--tg-active-color);
  box-shadow: 0 0 0 1px var(--tg-active-color);
}
.thumb-gallery__thumbs > li img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.3s;
}
.thumb-gallery__thumbs > li:hover img { opacity: 0.75; }

@media (prefers-reduced-motion: reduce) {
  .thumb-gallery__nav,
  .thumb-gallery__thumbs > li,
  .thumb-gallery__thumbs > li img { transition: none; }
}

JavaScript

本体です。全体を即時関数で包んで、ThumbGallery というコンストラクタだけを外に出しています。やっているのは、サムネイルを集めて、ステージを組み立てて、クリックで画像を差し替える、それだけです。

/*!
 * ThumbGallery v1.0.0 -- 依存なしのサムネイル切り替えギャラリー
 * License: MIT
 */
(function (global) {
  'use strict';

  var DEFAULTS = {
    thumbsSelector: '.thumb-gallery__thumbs',
    nav: true,          // 左右の送りボタンを表示するか
    loop: true,         // 端で反対側へループするか
    keyboard: true,     // ←→キーで送るか
    startIndex: 0,      // 初期表示のインデックス
    activeClass: 'is-active',
    aspectRatio: null,  // CSS変数 --tg-aspect-ratio を上書き
    maxWidth: null,     // CSS変数 --tg-max-width を上書き
    thumbSize: null,    // CSS変数 --tg-thumb-size を上書き
    prevLabel: '前の画像',
    nextLabel: '次の画像',
    onChange: null
  };

  function assign(target) {
    for (var i = 1; i < arguments.length; i++) {
      var src = arguments[i];
      if (!src) continue;
      for (var key in src) {
        if (Object.prototype.hasOwnProperty.call(src, key)) target[key] = src[key];
      }
    }
    return target;
  }
  function clamp(v, min, max) { return Math.min(Math.max(v, min), max); }
  function withUnit(v) {
    if (v == null) return v;
    return /^\d+(\.\d+)?$/.test(String(v)) ? v + 'px' : String(v);
  }

  function ThumbGallery(root, options) {
    if (!(this instanceof ThumbGallery)) return new ThumbGallery(root, options);
    this.root = typeof root === 'string' ? document.querySelector(root) : root;
    if (!this.root) throw new Error('ThumbGallery: 対象要素が見つかりません');
    if (this.root.thumbGalleryInstance) return this.root.thumbGalleryInstance; // 二重初期化防止

    this.options = assign({}, DEFAULTS, this._readDataOptions(), options);
    this.index = 0;

    this._onThumbClick = this._onThumbClick.bind(this);
    this._onThumbKeydown = this._onThumbKeydown.bind(this);
    this._onRootKeydown = this._onRootKeydown.bind(this);
    this._onPrev = this.prev.bind(this);
    this._onNext = this.next.bind(this);

    this._build();
    this.root.thumbGalleryInstance = this;
  }

  ThumbGallery.prototype = {
    constructor: ThumbGallery,

    _readDataOptions: function () {
      var d = this.root.dataset;
      var out = {};
      if (d.nav !== undefined) out.nav = d.nav !== 'false';
      if (d.loop !== undefined) out.loop = d.loop !== 'false';
      if (d.keyboard !== undefined) out.keyboard = d.keyboard !== 'false';
      if (d.start !== undefined) out.startIndex = parseInt(d.start, 10) || 0;
      if (d.aspectRatio !== undefined) out.aspectRatio = d.aspectRatio;
      if (d.maxWidth !== undefined) out.maxWidth = d.maxWidth;
      if (d.thumbSize !== undefined) out.thumbSize = d.thumbSize;
      return out;
    },

    _build: function () {
      this.thumbsList = this.root.querySelector(this.options.thumbsSelector);
      if (!this.thumbsList) throw new Error('ThumbGallery: サムネイル一覧が見つかりません');

      this.thumbImgs = Array.prototype.slice.call(this.thumbsList.querySelectorAll('img'));
      this.thumbLis = this.thumbImgs.map(function (img) { return img.closest('li') || img.parentElement; });
      if (this.thumbImgs.length === 0) return;

      this.items = this.thumbImgs.map(function (img) {
        return { full: img.getAttribute('data-large') || img.currentSrc || img.src, alt: img.getAttribute('alt') || '' };
      });

      this._applySizing();
      this._buildStage();

      this.thumbLis.forEach(function (li, i) {
        li.setAttribute('role', 'button');
        li.setAttribute('tabindex', '0');
        li.setAttribute('aria-label', (i + 1) + '枚目の画像を表示');
      });
      this.thumbsList.addEventListener('click', this._onThumbClick);
      this.thumbsList.addEventListener('keydown', this._onThumbKeydown);
      if (this.options.keyboard) this.root.addEventListener('keydown', this._onRootKeydown);

      this.root.classList.add('thumb-gallery--ready');
      this.go(clamp(this.options.startIndex, 0, this.items.length - 1), true);
    },

    _applySizing: function () {
      var s = this.root.style;
      if (this.options.aspectRatio) s.setProperty('--tg-aspect-ratio', this.options.aspectRatio);
      if (this.options.maxWidth) s.setProperty('--tg-max-width', withUnit(this.options.maxWidth));
      if (this.options.thumbSize) s.setProperty('--tg-thumb-size', withUnit(this.options.thumbSize));
    },

    _buildStage: function () {
      this.stage = document.createElement('div');
      this.stage.className = 'thumb-gallery__stage';
      this.image = document.createElement('img');
      this.image.className = 'thumb-gallery__image';
      this.stage.appendChild(this.image);

      if (this.options.nav && this.items.length > 1) {
        this.prevBtn = this._createNavButton('prev', this.options.prevLabel);
        this.nextBtn = this._createNavButton('next', this.options.nextLabel);
        this.stage.appendChild(this.prevBtn);
        this.stage.appendChild(this.nextBtn);
        this.prevBtn.addEventListener('click', this._onPrev);
        this.nextBtn.addEventListener('click', this._onNext);
      }
      this.thumbsList.parentNode.insertBefore(this.stage, this.thumbsList);
    },

    _createNavButton: function (dir, label) {
      var btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'thumb-gallery__nav thumb-gallery__nav--' + dir;
      btn.setAttribute('aria-label', label);
      return btn;
    },

    _onThumbClick: function (e) {
      var i = this.thumbLis.indexOf(e.target.closest('li'));
      if (i >= 0) this.go(i);
    },
    _onThumbKeydown: function (e) {
      if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
      var i = this.thumbLis.indexOf(e.target.closest('li'));
      if (i >= 0) { e.preventDefault(); this.go(i); }
    },
    _onRootKeydown: function (e) {
      if (e.key === 'ArrowLeft') { e.preventDefault(); this.prev(); }
      else if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
    },

    go: function (index, silent) {
      if (!this.items || !this.items.length) return this;
      var n = this.items.length;
      this.index = this.options.loop ? ((index % n) + n) % n : clamp(index, 0, n - 1);

      var item = this.items[this.index];
      this.image.src = item.full;
      this.image.alt = item.alt;

      var active = this.options.activeClass, current = this.index;
      this.thumbLis.forEach(function (li, i) {
        li.classList.toggle(active, i === current);
        if (i === current) li.setAttribute('aria-current', 'true');
        else li.removeAttribute('aria-current');
      });

      if (!this.options.loop) {
        if (this.prevBtn) this.prevBtn.disabled = this.index === 0;
        if (this.nextBtn) this.nextBtn.disabled = this.index === n - 1;
      }
      if (!silent && typeof this.options.onChange === 'function') {
        this.options.onChange(this.index, item, this);
      }
      return this;
    },
    next: function () { return this.go(this.index + 1); },
    prev: function () { return this.go(this.index - 1); },

    destroy: function () {
      if (this.stage) {
        if (this.prevBtn) this.prevBtn.removeEventListener('click', this._onPrev);
        if (this.nextBtn) this.nextBtn.removeEventListener('click', this._onNext);
        this.stage.parentNode && this.stage.parentNode.removeChild(this.stage);
      }
      this.thumbsList.removeEventListener('click', this._onThumbClick);
      this.thumbsList.removeEventListener('keydown', this._onThumbKeydown);
      this.root.removeEventListener('keydown', this._onRootKeydown);
      var active = this.options.activeClass;
      this.thumbLis.forEach(function (li) {
        li.classList.remove(active);
        li.removeAttribute('role'); li.removeAttribute('tabindex');
        li.removeAttribute('aria-label'); li.removeAttribute('aria-current');
      });
      this.root.classList.remove('thumb-gallery--ready');
      this.root.style.removeProperty('--tg-aspect-ratio');
      this.root.style.removeProperty('--tg-max-width');
      this.root.style.removeProperty('--tg-thumb-size');
      delete this.root.thumbGalleryInstance;
    }
  };

  ThumbGallery.init = function (selector, options) {
    selector = selector || '[data-thumb-gallery]';
    return Array.prototype.slice.call(document.querySelectorAll(selector))
      .map(function (el) { return new ThumbGallery(el, options); });
  };
  ThumbGallery.defaults = DEFAULTS;
  ThumbGallery.version = '1.0.0';

  if (typeof document !== 'undefined') {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', function () { ThumbGallery.init(); });
    } else {
      ThumbGallery.init();
    }
  }
  if (typeof module !== 'undefined' && module.exports) module.exports = ThumbGallery;
  global.ThumbGallery = ThumbGallery;
})(typeof window !== 'undefined' ? window : this);

あとから読み返すために、判断したところだけ残しておきます。

メイン画像のリストは、サムネイルの img から作ります。data-large があればそちらを、無ければサムネイル自身の src を使う。高解像度を別に持っている記事のときだけ差し替わります。

クリックは img 一枚ずつに登録せず、親の ul に一つだけ載せて、押された li が何番目かを調べる形にしました。リスナーが一つで済むので、destroy で外すときも一箇所で終わります。

端のループは剰余ひとつです。((index % n) + n) % n で、最後の次が最初、最初の前が最後になります。loop: false のときは clamp() で両端に止めて、端に来たボタンを disabled にします。

maxWidthaspectRatio のオプションは、JS が直接スタイルを書かず、CSS 変数を上書きするだけにしました。見た目のルールは CSS に置いたまま、値だけを JS から渡します。420 のような数値には px を補い、50%4 / 3 のような文字列はそのまま通します。

アクセシビリティは、サムネイルの lirole="button"tabindex="0" を付けて Enter と Space で操作できるようにし、選択中に aria-current、送りボタンに aria-label、フォーカスは focus-visible で出し、prefers-reduced-motion のときはアニメーションを止めています。

呼び出し方

読み込むのは linkscript を一つずつだけです。data-thumb-gallery を付けた要素は自動で初期化されます。

<link rel="stylesheet" href="/plugin/thumb-gallery.css">
<script src="/plugin/thumb-gallery.js" defer></script>

送りボタンを消したり比率を変えたりは、data 属性でできるようにしました。記事ごとに HTML を触るだけで済みます。

<div class="thumb-gallery" data-thumb-gallery
     data-nav="false"
     data-aspect-ratio="4 / 3"
     data-max-width="420px"
     data-thumb-size="64px">
  <ul class="thumb-gallery__thumbs"> ... </ul>
</div>

細かく制御したいときは new ThumbGallery() に渡して、onChange で切り替わりを拾います。

const gallery = new ThumbGallery('#gallery', {
  aspectRatio: '1 / 1',
  onChange(index, item) {
    console.log('表示中:', index, item.alt);
  }
});
gallery.next();

ライブラリを入れるほどではないけれど、素で書くと毎回同じところで手が止まる。その手間を一度だけ畳んでおくために書いた、というものです。ライセンスは MIT にしてあります。ご利用は計画的に。

0%