本記事のソースコードの利用によって生じた損害について、当方は一切の責任を負いません。ご自身の判断と責任のもとで参照・ご利用ください。
商品ページで見かける、サムネイルを押すとメイン画像が入れ替わるあれを、自分用に一本書き直しました。名前は 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 にします。
maxWidth や aspectRatio のオプションは、JS が直接スタイルを書かず、CSS 変数を上書きするだけにしました。見た目のルールは CSS に置いたまま、値だけを JS から渡します。420 のような数値には px を補い、50% や 4 / 3 のような文字列はそのまま通します。
アクセシビリティは、サムネイルの li に role="button" と tabindex="0" を付けて Enter と Space で操作できるようにし、選択中に aria-current、送りボタンに aria-label、フォーカスは focus-visible で出し、prefers-reduced-motion のときはアニメーションを止めています。
呼び出し方
読み込むのは link と script を一つずつだけです。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 にしてあります。ご利用は計画的に。