Photo Gallery — Masonry Layout with Zoom Lightbox
A photo gallery that packs images of mixed aspect ratios into a gap-free masonry wall and opens the one you press into a full-screen view that grows out of its thumbnail. Instead of stacking columns, each photo gets flex-grow and flex-basis proportional to its aspect ratio inside a wrapping row, so visual order, DOM order and Tab order always match and every photo in a row shares one height while filling the row exactly — no JS measuring. The viewer is a native dialog opened with showModal; a FLIP animation cancels out the offset from the thumbnail with transform and plays it back, and Escape or the close button shrinks the photo back along the same path. The dialog is named, marked aria-modal, puts initial focus on the close button, keeps Tab cycling inside, and returns focus to the thumbnail of the photo on screen. Close, previous and next buttons are always visible, arrow keys, Home/End and horizontal swipes also navigate, and each change is announced. Every photo carries descriptive alt text, and prefers-reduced-motion removes the grow, shrink and slide motion so changes happen instantly. No new dependencies.
- Added:
- 2026-10-17
- Dependencies:
- None
- tags
- #gallery #masonry #lightbox #photo #dialog #flip-animation #keyboard #swipe #a11y #no-dependency
Preview
BLK-100 写真ギャラリー
9枚
土を練るところから
箱に詰めるまで
小さな陶芸工房の一日を、縦長と横長の写真9枚で並べました。写真を押すとその位置から拡大し、左右のキーやスワイプで前後の写真へ送れます。Esc キーで一覧の元の位置へ戻ります。
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { flushSync } from "react-dom";
/**
* 写真ギャラリー(石積みレイアウト+拡大表示ライトボックス)(BLK-100)
*
* 差別化: 既存の実績・写真系(work-grid / clip-wipe-gallery / webgl-plane-gallery ほか)は一覧の並べ方や
* 出現演出が主題で、1枚を開いて全画面で見る・送る・元へ戻るという閲覧の操作体系を持たない。本ブロックは
* その操作体系を主題にした。英字キッカー・末尾の矢印リンク・角丸・スクロール連動の出現演出は外した。
*
* 汎用技法メモ:
* 1) 順序の崩れない石積み: CSS columns や「短い列へ足す」配置は、目で追う順(左→右)と DOM 順・Tab 順が
* 一致しない。ここでは flex-wrap の行に、写真ごと flex: <縦横比> 1 calc(var(--row-h) * <縦横比>) を当てる。
* 伸びる量も基準幅も縦横比に比例するので、同じ行の写真は必ず同じ高さで行幅ぴったりに収まる
* (JS で幅を計算しない)。最終行は末尾に flex-grow: 999 の奥付を置いて引き伸ばさず、
* 残り幅は奥付の欄にする(狭いときはコンテナクエリで中身を隠す)。
* --row-h を画面幅で変えれば、1行に入る枚数が自然に変わる。
* 2) 元の位置から開いて戻る(FLIP): サムネイルの矩形を先に測り、ダイアログ内の写真の最終位置との差を
* translate + scale で打ち消した状態から none へ Web Animations API で戻す。閉じるときは逆向き。
* 動かすのは transform と opacity だけ。スクロールを止めるとスクロールバーが消えてサムネイルの位置が
* ずれるので、測ってから止め、消えた幅を padding-right で埋める。開いている間は元のサムネイルを
* 透明にしておくと「写真が抜け出した」ように見える。
* 3) ダイアログの作法: ネイティブ <dialog> の showModal() で背面を不活性にし、aria-modal も明示する。
* Tab は閉じる→前へ→次へを循環させ、初期フォーカスは閉じるボタン。Esc は cancel を止めて縮む
* アニメーションを先に再生してから閉じ、フォーカスは表示中の写真のサムネイルへ返す(送らずに閉じれば
* 押したサムネイル)。左右キー・Home/End・横スワイプで送り、送った結果は aria-live で読み上げる。
* prefers-reduced-motion では拡大・縮小・送りの動きをすべて省き、開閉と切り替えだけを即時に行う。
*/
type Photo = {
src: string;
w: number;
h: number;
title: string;
alt: string;
note: string;
};
const PHOTOS: Photo[] = [
{
src: "/designs/masonry-lightbox-gallery-1.webp",
w: 896,
h: 1200,
title: "朝の作業台",
alt: "木の作業台を真上から撮った写真。素焼き前の白い器5つと、木べら、かき出しべら、布巾が置かれている",
note: "前の日に挽いた器を並べ、削る順番を決めてから一日の作業を始めます。",
},
{
src: "/designs/masonry-lightbox-gallery-2.webp",
w: 1200,
h: 800,
title: "ろくろを挽く",
alt: "ろくろの上で、濡れた粘土の器を両手で挽き上げている手元",
note: "器の厚みは指先の感覚で決めます。1日に挽くのは30個ほどです。",
},
{
src: "/designs/masonry-lightbox-gallery-3.webp",
w: 800,
h: 1200,
title: "窯詰めの前",
alt: "扉を開けた電気窯の前で、重ねた棚板に素焼き前の器と皿が並んでいる",
note: "熱が均等に回るように、背の高い器と皿を交互に棚板へ載せます。",
},
{
src: "/designs/masonry-lightbox-gallery-4.webp",
w: 1024,
h: 1024,
title: "釉薬を合わせる",
alt: "作業台の上の釉薬の缶3つを真上から撮った写真。左から乳白、飴色、藍色の釉薬と柄杓",
note: "乳白・飴・藍の3色を、その日の湿度に合わせて水で濃さを整えます。",
},
{
src: "/designs/masonry-lightbox-gallery-5.webp",
w: 1192,
h: 894,
title: "乾燥棚",
alt: "木の乾燥棚に何段も並んだ、焼く前の湯呑み",
note: "割れを防ぐため、窯に入れる前に1週間ほど棚でゆっくり乾かします。",
},
{
src: "/designs/masonry-lightbox-gallery-6.webp",
w: 896,
h: 1120,
title: "藍釉の茶碗",
alt: "白い布の上に置いた藍色の茶碗。縁から釉薬が垂れて流れた跡が見える",
note: "縁から流れた釉薬の跡は、窯の中の温度の差でひとつずつ異なります。",
},
{
src: "/designs/masonry-lightbox-gallery-7.webp",
w: 1200,
h: 675,
title: "道具の壁",
alt: "工房の窓辺。漆喰の壁に木のコテや針などの道具が掛かり、窓から光が差している",
note: "よく使うコテと針は、手を伸ばせば届く窓際の壁に掛けています。",
},
{
src: "/designs/masonry-lightbox-gallery-8.webp",
w: 1024,
h: 1024,
title: "練り上げた土",
alt: "作業台の上に置いた、渦巻き状に練り上げた粘土の塊と切り糸",
note: "菊練りで空気を抜いた土です。この工程を省くと焼成中に割れます。",
},
{
src: "/designs/masonry-lightbox-gallery-9.webp",
w: 894,
h: 1192,
title: "箱に詰める",
alt: "焼き上がった器を薄紙で包み、木箱に詰めている手元",
note: "1点ずつ薄紙で包み、木箱に詰めて発送します。",
},
];
const N = PHOTOS.length;
const pad = (n: number) => String(n).padStart(2, "0");
const EASE = "cubic-bezier(0.22, 0.7, 0.18, 1)";
const reducedMotion = () =>
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** from の矩形を to の位置・大きさに重ねるための transform(to 側の要素に当てる) */
function flip(from: DOMRect, to: DOMRect) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
return `translate(${dx}px, ${dy}px) scale(${from.width / to.width}, ${from.height / to.height})`;
}
/** 背面のスクロールを止める。消えるスクロールバーの幅は padding-right で埋めて一覧を動かさない */
function lockScroll() {
const root = document.documentElement;
const bar = window.innerWidth - root.clientWidth;
root.style.setProperty("overflow", "hidden");
if (bar > 0) root.style.setProperty("padding-right", `${bar}px`);
}
function unlockScroll() {
const root = document.documentElement;
root.style.removeProperty("overflow");
root.style.removeProperty("padding-right");
}
export default function MasonryLightboxGallery() {
const uid = useId();
const headingId = `${uid}-heading`;
const titleId = `${uid}-title`;
const [index, setIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [announce, setAnnounce] = useState("");
const dialogRef = useRef<HTMLDialogElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const chromeRef = useRef<HTMLDivElement>(null);
const footRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
const indexRef = useRef(0);
const busy = useRef(false);
const drag = useRef<{ id: number; x: number; y: number; dx: number; moved: boolean } | null>(null);
// ダイアログを開いたままアンマウントされたときにスクロールのロックを残さない
useEffect(() => () => unlockScroll(), []);
const openAt = (i: number) => {
const dialog = dialogRef.current;
const thumb = thumbRefs.current[i];
if (!dialog || !thumb || dialog.open || busy.current) return;
const from = thumb.getBoundingClientRect(); // スクロールを止める前に測る
indexRef.current = i;
flushSync(() => {
setIndex(i);
setAnnounce("");
setIsOpen(true);
});
lockScroll();
dialog.showModal();
closeRef.current?.focus({ preventScroll: true });
const img = imgRef.current;
if (reducedMotion() || !img) return;
busy.current = true;
const grow = img.animate([{ transform: flip(from, img.getBoundingClientRect()) }, { transform: "none" }], {
duration: 460,
easing: EASE,
});
backdropRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 320, easing: "ease-out" });
[chromeRef.current, footRef.current].forEach((el) =>
el?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 260, delay: 200, easing: "ease-out", fill: "backwards" }),
);
grow.finished.catch(() => {}).finally(() => (busy.current = false));
};
/** 閉じ終わりの後始末。ネイティブに閉じられた(close イベント)場合もここを通る */
const finishClose = () => {
const dialog = dialogRef.current;
const i = indexRef.current;
flushSync(() => setIsOpen(false)); // サムネイルを戻してから閉じ、1フレームも空かないようにする
if (dialog?.open) dialog.close();
[imgRef.current, backdropRef.current, chromeRef.current, footRef.current].forEach((el) =>
el?.getAnimations().forEach((a) => a.cancel()),
);
unlockScroll();
busy.current = false;
thumbRefs.current[i]?.focus({ preventScroll: true });
};
const requestClose = () => {
if (!dialogRef.current?.open || busy.current) return;
const thumb = thumbRefs.current[indexRef.current];
const img = imgRef.current;
if (reducedMotion() || !thumb || !img) return finishClose();
// 送った先のサムネイルが画面外なら、背面だけ静かにスクロールして見える位置へ寄せてから縮む
thumb.scrollIntoView({ block: "nearest", inline: "nearest" });
const to = thumb.getBoundingClientRect();
const vh = window.innerHeight;
if (to.width === 0 || to.bottom < 0 || to.top > vh) return finishClose();
busy.current = true;
const shrink = img.animate([{ transform: "none" }, { transform: flip(to, img.getBoundingClientRect()) }], {
duration: 380,
easing: EASE,
fill: "forwards",
});
backdropRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 340,
easing: "ease-in",
fill: "forwards",
});
[chromeRef.current, footRef.current].forEach((el) =>
el?.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, fill: "forwards" }),
);
shrink.finished.catch(() => {}).finally(finishClose);
};
const go = (target: number, dir: 1 | -1) => {
if (busy.current) return;
const next = (target + N) % N;
indexRef.current = next;
flushSync(() => {
setIndex(next);
setAnnounce(`${N}枚中${next + 1}枚目、${PHOTOS[next].title}`);
});
const img = imgRef.current; // key が変わったので新しい要素
if (reducedMotion() || !img) return;
img.animate(
[
{ opacity: 0, transform: `translateX(${dir * 36}px)` },
{ opacity: 1, transform: "none" },
],
{ duration: 280, easing: EASE },
);
};
const onDialogKeyDown = (e: KeyboardEvent<HTMLDialogElement>) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const i = indexRef.current;
if (e.key === "ArrowRight") go(i + 1, 1);
else if (e.key === "ArrowLeft") go(i - 1, -1);
else if (e.key === "Home") go(0, -1);
else if (e.key === "End") go(N - 1, 1);
else if (e.key === "Tab") {
// showModal は背面を不活性にするが、ブラウザのUIへ抜けないよう中のボタンだけで循環させる
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>("button:not([disabled])"));
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) last.focus();
else if (!e.shiftKey && document.activeElement === last) first.focus();
else return;
} else return;
e.preventDefault();
};
// --- 横スワイプで送る(指に追従させ、56px を超えたら送る。足りなければ戻す)---
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (busy.current || (e.pointerType === "mouse" && e.button !== 0)) return;
drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dx: 0, moved: false };
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (!d.moved && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
if (!d.moved) return;
d.dx = dx;
if (imgRef.current && !reducedMotion()) imgRef.current.style.transform = `translateX(${dx * 0.85}px)`;
};
const onPointerEnd = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const img = imgRef.current;
if (img) img.style.transform = "";
if (d.moved && Math.abs(d.dx) > 56) {
const dir = d.dx < 0 ? 1 : -1;
go(indexRef.current + dir, dir);
} else if (d.moved && img && !reducedMotion()) {
img.animate([{ transform: `translateX(${d.dx * 0.85}px)` }, { transform: "none" }], {
duration: 220,
easing: EASE,
});
}
// click より後に消す(スワイプの指離れを背景クリック=閉じると取り違えない)
window.setTimeout(() => (drag.current = null), 0);
};
const photo = PHOTOS[index];
return (
<section aria-labelledby={headingId} className="w-full max-w-6xl bg-white px-1 text-stone-900">
<div className="flex items-baseline justify-between gap-4 border-b border-stone-200 pb-3">
<p className="font-display text-[11px] font-semibold tracking-wide text-stone-500">
BLK-100 <span aria-hidden="true" className="text-stone-300">/</span>{" "}
<span className="font-sans">写真ギャラリー</span>
</p>
<p className="text-[11px] font-medium text-stone-400">
<span className="font-display tabular-nums">{N}</span>枚
</p>
</div>
<div className="mt-8 grid gap-5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:items-end sm:gap-12">
<h2
id={headingId}
className="text-[clamp(1.6rem,3.8vw,2.4rem)] leading-[1.35] font-bold tracking-tight text-stone-900"
>
土を練るところから
<br />
箱に詰めるまで
</h2>
<p className="border-l-2 border-amber-600 pl-4 text-[14px] leading-[1.9] font-medium text-stone-600 sm:text-[15px]">
小さな陶芸工房の一日を、縦長と横長の写真9枚で並べました。写真を押すとその位置から拡大し、左右のキーやスワイプで前後の写真へ送れます。Esc キーで一覧の元の位置へ戻ります。
</p>
</div>
<ul
className="mlg-wall mt-10 flex list-none flex-wrap gap-1.5 p-0 [--row-h:118px] sm:mt-12 sm:gap-2 sm:[--row-h:180px] lg:[--row-h:220px]"
aria-label="工房の写真一覧"
>
{PHOTOS.map((p, i) => {
const ar = p.w / p.h;
return (
<li key={p.src} style={{ flex: `${ar} 1 calc(var(--row-h) * ${ar})` }}>
<button
type="button"
ref={(el) => {
thumbRefs.current[i] = el;
}}
aria-haspopup="dialog"
onClick={() => openAt(i)}
style={{ aspectRatio: `${p.w} / ${p.h}` }}
className={`group relative block w-full cursor-zoom-in overflow-hidden bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
isOpen && i === index ? "opacity-0" : ""
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- 静的書き出しのため最適化なしの img を使う */}
<img
src={p.src}
alt={p.alt}
width={p.w}
height={p.h}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
draggable={false}
className="block h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
<span
aria-hidden="true"
className="font-display absolute top-0 left-0 bg-white px-1.5 py-0.5 text-[10px] font-bold tabular-nums text-stone-700"
>
{pad(i + 1)}
</span>
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white/92 px-3 py-2 text-left text-[12px] font-bold text-stone-800 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
>
{p.title}
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 shrink-0 text-amber-600" fill="none">
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" stroke="currentColor" strokeWidth="1.5" />
</svg>
</span>
</button>
</li>
);
})}
{/* 最終行の残り幅を受け止める奥付。伸び率 999 で最終行の写真を引き伸ばさず、狭いときは何も見せない */}
<li aria-hidden="true" className="@container min-w-0 flex-[999_1_0%]">
<div className="hidden h-full flex-col justify-end border-t border-stone-200 pt-3 pl-1 @[160px]:flex">
<span className="block h-px w-6 bg-amber-600" />
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px] leading-[1.6] text-stone-500">
<dt className="text-stone-400">撮影</dt>
<dd>4月〜9月</dd>
<dt className="text-stone-400">場所</dt>
<dd>工房の作業場</dd>
<dt className="text-stone-400">点数</dt>
<dd>
<span className="font-display tabular-nums">{N}</span>点
</dd>
</dl>
</div>
</li>
</ul>
<dialog
ref={dialogRef}
aria-modal="true"
aria-labelledby={titleId}
onCancel={(e) => {
e.preventDefault();
requestClose();
}}
onClose={() => {
if (isOpen) finishClose();
}}
onKeyDown={onDialogKeyDown}
className="fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 text-stone-900 backdrop:bg-transparent open:grid open:grid-rows-[auto_minmax(0,1fr)_auto]"
>
<div ref={backdropRef} aria-hidden="true" className="absolute inset-0 bg-stone-50" />
<div ref={chromeRef} className="relative flex items-center justify-between border-b border-stone-200 px-4 py-2.5 sm:px-6">
<p className="font-display text-[12px] font-semibold tabular-nums text-stone-500">
<span className="text-amber-700">{pad(index + 1)}</span>
<span aria-hidden="true" className="px-1.5 text-stone-300">
/
</span>
{pad(N)}
</p>
<button
ref={closeRef}
type="button"
onClick={requestClose}
className="inline-flex h-11 items-center gap-2 border border-stone-300 bg-white px-4 text-[13px] font-bold text-stone-800 transition-colors hover:border-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" />
</svg>
閉じる
</button>
</div>
<div
className="relative flex min-h-0 touch-pan-y items-center justify-center overflow-hidden px-3 py-4 select-none sm:px-10"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onClick={(e) => {
if (e.target === e.currentTarget && !drag.current?.moved) requestClose();
}}
>
{/* eslint-disable-next-line @next/next/no-img-element -- 静的書き出しのため最適化なしの img を使う */}
<img
key={photo.src}
ref={imgRef}
src={photo.src}
alt={photo.alt}
width={photo.w}
height={photo.h}
draggable={false}
className="block h-auto max-h-[calc(100dvh-12.5rem)] w-auto max-w-full shadow-[0_24px_60px_-36px_rgba(28,25,23,0.55)] will-change-transform"
/>
</div>
<div
className="relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-t border-stone-200 bg-white px-3 py-3 sm:gap-6 sm:px-6"
ref={footRef}
>
<button
type="button"
onClick={() => go(index - 1, -1)}
className="inline-flex h-11 items-center gap-2 border border-stone-300 bg-white px-3 text-[13px] font-bold text-stone-800 transition-colors hover:border-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none sm:px-4"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="1.6" />
</svg>
前へ
</button>
<div className="min-w-0 text-center">
<h2 id={titleId} className="truncate text-[15px] leading-[1.5] font-bold text-stone-900">
{photo.title}
</h2>
<p className="mt-0.5 line-clamp-2 text-[12px] leading-[1.7] text-stone-600 sm:text-[13px]">{photo.note}</p>
<p className="mt-1 hidden text-[11px] text-stone-400 sm:block">
左右のキーで前後へ送り、Esc キーで一覧へ戻ります
</p>
</div>
<button
type="button"
onClick={() => go(index + 1, 1)}
className="inline-flex h-11 items-center gap-2 border border-stone-300 bg-white px-3 text-[13px] font-bold text-stone-800 transition-colors hover:border-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none sm:px-4"
>
次へ
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.6" />
</svg>
</button>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</dialog>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/masonry-lightbox-gallery.json