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 キーで一覧の元の位置へ戻ります。

0109

木の作業台を真上から撮った写真。素焼き前の白い器5つと、木べら、かき出しべら、布巾が置かれている

朝の作業台

前の日に挽いた器を並べ、削る順番を決めてから一日の作業を始めます。

"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