画像 拡大 表示 UI 9選 — 写真ファイルなしで作るビューア
画像 拡大 表示とは、一枚の絵を大きく、回して、あるいは一か所だけ指して見せる画面の仕組みです。9つとも実際に動き、写真ファイルは一枚も使わず、グラデーションだけで描いています。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 マウスを追う拡大レンズ
- 02 ホイールで寄せてドラッグで動かすビューア
- 03 今見ている場所を教えるミニマップ
- 04 段階で止まる倍率コントロールバー
- 05 回して反転するツールバー
- 06 サムネイルの位置から広がるライトボックス
- 07 ドラッグで一周する360度ビュー
- 08 ポインタを追って点くスポットライト
- 09 写真に刺さる説明ピン
並び順は人気順ではなく、人が一枚の絵をのぞき込んでいく順です。まず手を加えずに一か所だけ大きく見て(01)、次に画面ごと寄せてドラッグで動かします(02)。寄せると現在地を見失うので、そこにミニマップが加わり(03)、倍率を正確に合わせたいときは段階コントロールが加わります(04)。向きの狂った絵は回して立て直し(05)、小さく並べたうちの一枚は大きく開きます(06)。品物なら裏まで回して見て(07)、説明するときは一か所だけ照らすか(08)、その場に名前を留めます(09)。9つとも素の JavaScript で60行以内に収まり、中の絵は linear-gradient と radial-gradient の系統だけです。
01マウスを追う拡大レンズ
生地の上に浮かぶ丸いレンズの中には、同じ背景を2.4倍の大きさで敷き直した複製が入っています。ポインターの座標に倍率を掛け、レンズの半径を引いた値がそのまま background-position になるので、2枚の層がずれずにかみ合います。レンズが絵の外へ出ないよう、座標は半径と絵の幅の間に収めておきます。
var MG_ZOOM = 2.4;
function mgPaint() {
var box = mgPhoto.getBoundingClientRect();
var half = mgLens.offsetWidth / 2;
mgX = Math.min(Math.max(mgX, half), box.width - half);
mgY = Math.min(Math.max(mgY, half), box.height - half);
mgLens.style.transform = 'translate(' + (mgX - half) + 'px,' + (mgY - half) + 'px)';
mgLens.style.backgroundPosition = -(mgX * MG_ZOOM - half) + 'px ' + -(mgY * MG_ZOOM - half) + 'px';
}
mgPhoto.addEventListener('pointermove', function (e) {
var box = mgPhoto.getBoundingClientRect();
mgX = e.clientX - box.left;
mgY = e.clientY - box.top;
mgPaint();
});
02ホイールで寄せてドラッグで動かすビューア
ホイールを回すと倍率が1倍から3倍の間で上下し、ドラッグすると地図が指先についてきます。動かせる距離は (幅 × (倍率 − 1)) ÷ 2 ÷ 倍率 で先に求め、その外へは出られないようにします。この一行がないと、少し強く引いただけで絵が枠から抜け、下地の余白が見えてしまいます。
function zpPaint() {
var box = zpView.getBoundingClientRect();
var maxX = box.width * (zpScale - 1) / 2 / zpScale;
var maxY = box.height * (zpScale - 1) / 2 / zpScale;
zpX = Math.min(Math.max(zpX, -maxX), maxX);
zpY = Math.min(Math.max(zpY, -maxY), maxY);
zpLayer.style.transform = 'scale(' + zpScale + ') translate(' + zpX + 'px,' + zpY + 'px)';
}
zpView.addEventListener('wheel', function (e) {
e.preventDefault();
zpScale = Math.min(Math.max(zpScale - e.deltaY / 400, 1), 3);
zpPaint();
}, { passive: false });
03今見ている場所を教えるミニマップ
右下のミニマップは同じ図面をまるごと縮めたもので、中の白い枠が今画面に映っている範囲です。倍率が2.4倍なら枠はミニマップの1÷2.4、つまり41.67パーセントの大きさで、残りの58.33パーセントが枠の動ける幅になります。大きい画面と枠は同じ一つの値から計算されるため、両者がずれることはありません。
var MM_SCALE = 2.4;
function mmPaint() {
var view = mmView.getBoundingClientRect();
var map = mmMap.getBoundingClientRect();
var rest = (1 - 1 / MM_SCALE) / 2;
var tx = mmPx * view.width * (MM_SCALE - 1) / 2 / MM_SCALE;
var ty = mmPy * view.height * (MM_SCALE - 1) / 2 / MM_SCALE;
mmLayer.style.transform = 'scale(' + MM_SCALE + ') translate(' + tx + 'px,' + ty + 'px)';
mmBox.style.transform = 'translate(' + rest * map.width * (1 - mmPx) + 'px,'
+ rest * map.height * (1 - mmPy) + 'px)';
}
04段階で止まる倍率コントロールバー
倍率は配列に書いた4つの値しか取りません。端まで来るとそのボタンが disabled になるので、押しても何も起きないボタンを押し続けることがなくなります。数字は role="status" の中にあり、新しい倍率が画面を見ない人にも一度だけ読み上げられます。
var ZL_STOPS = [1, 1.5, 2, 3];
function zlPaint() {
zlLayer.style.transform = 'scale(' + ZL_STOPS[zlStep] + ')';
zlPct.textContent = Math.round(ZL_STOPS[zlStep] * 100) + '%';
document.getElementById('zl-out').disabled = zlStep === 0;
document.getElementById('zl-in').disabled = zlStep === ZL_STOPS.length - 1;
}
document.getElementById('zl-in').addEventListener('click', function () {
zlStep = Math.min(zlStep + 1, ZL_STOPS.length - 1);
zlPaint();
});
05回して反転するツールバー
角度は90度ずつ足すだけで、360度で0に戻すことはしません。数を増やし続ければ回転はいつも同じ向きに続き、巻き戻る場面が画面に出ません。反転は scaleX の符号を変えるだけですが、その状態を aria-pressed に書いておくので、色の変化が見えない人にも今は反転していることが伝わります。
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () {
rfAngle += 90;
rfPaint();
});
rfFlip.addEventListener('click', function () {
rfMirror = -rfMirror;
rfPaint();
});
06サムネイルの位置から広がるライトボックス
大きな板は最終的に置かれる場所に組んでおき、押されたサムネイルとの差だけ後ろへ戻してから、その戻した分を解きます。戻している間は transition を一度切る必要があり、入れ直せるのは requestAnimationFrame を1回はさんだあとです。同じフレームの中で切って入れ直すと、ブラウザーが2つの値をまとめて計算し、アニメーションがまるごと消えます。
function lbOpen(i) {
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
07ドラッグで一周する360度ビュー
12コマが一列に並び、窓は1コマだけを見せます。引いた距離を18で割って四捨五入した値がコマ番号になり、剰余を2回重ねて負の数も0から11の間に折り返します。取っ手の横位置とつぶれ具合はコマごとに書いたサインの値から、カップに入ったロゴのつぶれ具合はコサインの値から決まるので、本物の写真12枚がなくても一周します。
var SP_N = 12, SP_STEP = 18;
function spPaint() {
spI = ((spI % SP_N) + SP_N) % SP_N;
spTrack.style.transform = 'translateX(' + (-spI * 100 / SP_N) + '%)';
}
spWin.addEventListener('pointerdown', function (e) {
spDrag = { x: e.clientX, i: spI };
spWin.setPointerCapture(e.pointerId);
});
spWin.addEventListener('pointermove', function (e) {
if (!spDrag) return;
spI = spDrag.i - Math.round((e.clientX - spDrag.x) / SP_STEP);
spPaint();
});
08ポインターを追って点くスポットライト
同じ絵を2枚重ね、下の1枚だけを灰色に落とします。上の層は mask-image に入れた円の中にしか残らないため、円の位置である mask-position を動かすだけで明るい場所がついてきます。円の大きさは mask-size で固定してあるので、スクリプトが計算するのは座標2つだけです。
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
09写真に刺さる説明ピン
ピンは <div> ではなく <button> なので、タブキーだけで3か所を回れます。開閉の状態はクラスではなく aria-expanded に書き、付箋を見せる規則もその属性をセレクターに使います。そうすれば、読み上げに伝わる値と目に見える姿が食い違うことはなくなります。
function hsOpen(pin) {
var was = pin.getAttribute('aria-expanded') === 'true';
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
pin.setAttribute('aria-expanded', was ? 'false' : 'true');
}
hsPins.forEach(function (pin) {
pin.addEventListener('click', function () { hsOpen(pin); });
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
});
どこで壊れるか — 落とし穴
ビューアで一番よく壊れるのは、自動再生の演出と、人が実際に触ったときの道筋が別々になっているところです。格子に並ぶ9つのタイルはすべて自動ループで動きますが、そのループを CSS のキーフレームだけに任せると、ポインターが来てループのクラスが外れた瞬間に画面が空になります。01のレンズがまさにそれでした。キーフレームが transform と background-position の両方を指定していたので、同じ2つの値を通常の規則として先に書き、その上にループを重ねました。9つとも、ループを外した状態を一度ずつ撮って確かめています。
2つ目は、どの角を0とみなすかです。ポインターの座標は窓の左上から始まり、絵の中の座標は絵の左上から始まるため、getBoundingClientRect で測った箱の left と top を引かないと、レンズもスポットライトも指先から数十ピクセル離れてついてきます。06のライトボックスは同じ計測を2回行います。サムネイルの箱と開いた板の箱をそれぞれ測り、その差を戻す量に使い、中心をそろえるために大きさの差を2で割って足します。
最後の落とし穴は、画面が手のひらほどに狭くなったときに出ます。このデモは記事の中に480×300の iframe として入り、スマートフォンでは320×200に縮みます。ステージの余白を引くと横294px、縦174pxしか残りませんが、320pxで測ったところ9つのうち8つは横288pxで同じ(例外は05)、縦は144pxから168pxの間でした。一番背の高い01と02が168pxなので、余裕は6pxです。そのため狭い画面の規則では間隔を上げず、下げるだけにしています。4pxの格子に合わせようとして2pxを4pxへ丸めると、それだけで無理なく収まっていた部品が下へはみ出すので、狭い側では0へ落として帳尻を合わせます。05だけ横204pxと細いのは、ツールバーを絵の下ではなく右隣に立てたからで、縦を144pxまで削れたのもその並べ方のおかげです。9つは一つのフォルダーにまとめてあり、その圧縮ファイルのパスワードは ce7bbmt2 で、いま見ている素の版と同じ動きの React 版が一緒に入っています。
アクセシビリティ
マウスがないと使えないものはビューアとして未完成です。拡大と移動は矢印キー、倍率はボタン、開閉は Enter と Escape に割り当ててあり、prefers-reduced-motion: reduce では自動再生だけを止めて、見せていた状態はそのまま残します。動きを消したからといって「今2倍で見ている」という事実まで消えてはいけないからです。
| 項目 | マウス | キーボード | 読み上げ |
|---|---|---|---|
| 01 拡大レンズ | ポインター移動 | 矢印キーで16pxずつ | role="img" と操作の案内 |
| 02 ホイールズームとパン | ホイールとドラッグ | 矢印キーと + - |
role="img" と操作の案内 |
| 03 ミニマップ | ドラッグ | 矢印キー | role="img" と操作の案内 |
| 04 倍率コントロール | ボタン3つ | タブと Enter | aria-live が新しい倍率を読む |
| 05 回転と反転 | ボタン2つ | タブと Enter | aria-pressed が反転を示す |
| 06 ライトボックス | サムネイルのクリック | Escape と左右矢印 | role="dialog" と閉じるボタン |
| 07 360度ビュー | ドラッグ | 左右矢印 | role="img" と操作の案内 |
| 08 スポットライト | ポインター移動 | 矢印キーで20pxずつ | role="img" と操作の案内 |
| 09 説明ピン | ピンのクリック | タブ・Enter・Escape | aria-expanded が開閉を示す |
フォーカスが動くのは06だけです。ライトボックスが開けば閉じるボタンへ移り、閉じれば押したサムネイルへ戻り、どちらも focus({ preventScroll: true }) で渡します。デモは iframe の中にあるため、この指定を落とすと親ページがその要素の位置までスクロールしてしまいます。マスクの対応状況と接頭辞が必要な範囲は MDN の mask-image ページにまとまっています。同じ系統の画面はメディアのカテゴリーに、ポインターを乗せると応えるものはホバーのカテゴリーにまとめてあります。
FAQ
画像の拡大表示にライブラリーを使うほうがよいのでしょうか?
9つとも外部の依存なしで60行以内に収まりました。ビューア用のライブラリーはピンチズーム、慣性スクロール、複数枚の送りをまとめて持ってくることが多く、商品写真1枚にレンズ1つだけという画面ではその全部が余分な荷物になります。逆に、回転した原本を保存する、形式を変換するといった、絵そのものを書き換える仕事が入るなら、そこは専用の道具の出番です。
拡大したときに絵がぼやけるのはどう防ぎますか?
ここの9つはグラデーションで描いた絵なので、何倍にしても計算で描き直されて鮮明なままです。ただし本物の写真を使うときは、表示する大きさより大きい原本を別に用意し、拡大したときだけそれに差し替えるしかありません。01のようにレンズだけの画面なら、大きい原本はレンズの背景にだけ使い、下地は小さいファイルのままで足ります。
タッチ画面でも同じコードが動きますか?
pointermove と pointerdown はマウス・ペン・指を一つの名前で受け取るので、02・03・07はそのままドラッグできます。ただし指で引くときにページまでスクロールしないよう箱に touch-action: none を指定する必要があり、2本指のピンチは pointermove を2本分自分で数えることになるため、この9つには入れていません。代わりに02は + と - のキーで、04はボタンで同じ倍率に届くようにしてあります。