BLK-100Blocks
写真ギャラリー(石積みレイアウト+拡大表示ライトボックス)
縦横比の異なる写真を隙間なく石積みに並べ、押した1枚をその位置から全画面へ拡大して見せる写真ギャラリーです。並べ方は列ごとに縦へ詰めるのではなく、写真ごとに縦横比に比例した flex-grow と flex-basis を当てる行単位の石積みなので、見た目の並び順・DOMの順・Tabキーでのフォーカス順が常に一致し、同じ行の写真は同じ高さで行幅ぴったりに収まります(幅の計算にJSを使いません)。拡大表示はネイティブの dialog を showModal で開き、サムネイルとの位置の差を transform で打ち消してから戻すFLIPで拡大し、Escキーや閉じるボタンでは同じ道を縮んで一覧へ戻ります。ダイアログには名前と aria-modal を持たせ、初期フォーカスは閉じるボタン、Tabキーはダイアログ内で循環し、閉じた後は表示していた写真のサムネイルへフォーカスを返します。閉じる・前へ・次へのボタンは常に表示し、左右キー・Home/End・横スワイプでも送れて、送った結果は読み上げられます。全写真に内容を説明する代替テキストを付け、prefers-reduced-motion では拡大・縮小・送りの動きを省いて即時に切り替えます。新規の依存はありません。
- 追加日:
- 2026-10-17
- 依存:
- なし
- tags
- #gallery #masonry #lightbox #photo #dialog #flip-animation #keyboard #swipe #a11y #no-dependency
プレビュー
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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/masonry-lightbox-gallery.json