BLK-101Blocks

写真の注釈ピン(番号の点を押すと説明が出る)

店内や製品の写真1枚の上に番号つきの点を置き、押すとその場所の説明が吹き出しで出る注釈ブロックです。点の位置は元の写真に対する割合で持ち、写真の枠を container-type: size にしたうえで object-fit: cover と同じ計算を CSS の calc と cqw・cqh 単位で行うため、写真の切り抜き方(2:1・3:2・4:3)を変えても点は同じ物の上に残ります。点は名前つきの button で aria-expanded と aria-controls によって説明と結び、前へ・次へで番号順に回れて、閉じるボタンと Esc キーで閉じると点へフォーカスが戻ります。点の上では左右キーと Home/End で前後の点へ移れるので、キーボードだけで操作が完結します。枠が狭いときや点どうしが近いときは、点を番号だけの小さな印にして説明を写真の下に出し、写真を吹き出しで隠しません。同じ説明は写真の下に番号付きリストでも載せており、スクリーンリーダーや印刷でも内容を読めます。吹き出しの出現は opacity と transform だけを動かし、prefers-reduced-motion では動きを止めます。新規の依存はありません。

追加日:
2026-10-18
依存:
なし
tags
#image-hotspot #annotation #photo #disclosure #container-query #object-fit #keyboard #a11y #print #no-dependency

プレビュー

BLK-101 写真の注釈ピン

6か所

カウンターまわりの6か所を写真の上でご案内します

番号を押すと、その場所の説明が写真の上に出ます。前へ・次へのボタンで1番から順に回れて、Esc キーで閉じます。同じ説明は写真の下の一覧にも載せています。

切り抜き

切り抜きを変えても、点は同じ物の上に残ります

自家焙煎のコーヒースタンドの店内。左の大きな窓際に木の2人席、奥の壁際に黒い焙煎機、中央の木のカウンターにグラインダーとエスプレッソマシン、右の壁の棚に豆の入ったガラス瓶、カウンターの右端に焼き菓子のケースがある
番号の上で左右のキーを押すと前後の番号へ移ります。
  1. 1. 窓際の2人席

    朝は窓から光が入る、向かい合わせの2人席です。足元にコンセントが1口あります。

  2. 2. 焙煎機

    毎週火曜と金曜の朝に、その週に出す豆を店内で焼いています。焼いている間は香りが店の外まで届きます。

  3. 3. グラインダー

    注文を受けてから1杯分だけ挽きます。挽き目は豆の種類と淹れ方に合わせて変えています。

  4. 4. エスプレッソマシン

    2杯を同時に抽出できます。ラテなどミルクを使う飲み物も、ここで泡立てて仕上げます。

  5. 5. 豆の棚

    常に7種類の豆を置いています。瓶の中の豆の色で焙煎の深さを見比べられ、100gから量り売りします。

  6. 6. 焼き菓子のケース

    クロワッサンとマフィンは近くのパン屋から毎朝届きます。売り切れた時点で終わりです。

"use client";

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type KeyboardEvent,
} from "react";

/**
 * 写真の注釈ピン(番号の点を押すと説明が出る)(BLK-101)
 *
 * 差別化: isometric-floor-map は自作の立体模型を自由に見て回る型、coachmark-product-tour は画面の部品を決まった順に
 * 照らす操作案内で、実写1枚に注釈を重ねる型は無かった。本ブロックは写真に対する割合で持った点を、切り抜き方を
 * 変えても同じ物の上に置き続け、同じ説明を写真の下の番号付きリストにも出す。英字キッカー・末尾の矢印リンク・角丸は外した。
 *
 * 汎用技法メモ:
 * 1) 切り抜いても点がずれない座標: 点は元写真に対する割合 (x, y) で持つ。写真の枠を container-type: size にし、
 *    object-fit: cover で描かれる写真の大きさを CSS だけで再現する。
 *      --dw: max(100cqw, 100cqh * 縦横比)  --dh: max(100cqh, 100cqw / 縦横比)
 *      left: calc((100cqw - var(--dw)) * 焦点x + var(--dw) * x)   top も同様
 *    焦点は object-position と同じ値にする。JS で測らないので、サーバー描画の時点から位置が合っている。
 * 2) 狭いときの切り替え: ResizeObserver で枠の大きさを受け、同じ式で点どうしの最短距離を出す。
 *    枠が狭い・点が近すぎる・点が枠の外に出たときは、点を小さな番号だけにして説明を写真の下に出す(吹き出しで写真を隠さない)。
 * 3) 開閉の作法: 点は名前つきの button で aria-expanded と aria-controls を持つ開閉ボタン。開いてもフォーカスは点に残し、
 *    Tab で次に説明の中のボタンへ進めるよう、吹き出しは点の直後に置く。前へ・次へは押したボタンへフォーカスを戻し、
 *    閉じるボタンと Esc は点へフォーカスを返す。点の上の左右キーで前後の点へ移る。吹き出しの出現は @starting-style
 *    (Tailwind の starting:)で opacity と transform だけを動かし、prefers-reduced-motion では動かさない。
 */

type Spot = { x: number; y: number; title: string; body: string };

const PHOTO = {
  src: "/designs/image-hotspot-tour.webp",
  w: 1536,
  h: 1024,
  alt: "自家焙煎のコーヒースタンドの店内。左の大きな窓際に木の2人席、奥の壁際に黒い焙煎機、中央の木のカウンターにグラインダーとエスプレッソマシン、右の壁の棚に豆の入ったガラス瓶、カウンターの右端に焼き菓子のケースがある",
};

/** object-position と同じ焦点(0〜1) */
const FOCUS = { x: 0.5, y: 0.5 };

const SPOTS: Spot[] = [
  {
    x: 0.15,
    y: 0.56,
    title: "窓際の2人席",
    body: "朝は窓から光が入る、向かい合わせの2人席です。足元にコンセントが1口あります。",
  },
  {
    x: 0.31,
    y: 0.39,
    title: "焙煎機",
    body: "毎週火曜と金曜の朝に、その週に出す豆を店内で焼いています。焼いている間は香りが店の外まで届きます。",
  },
  {
    x: 0.485,
    y: 0.4,
    title: "グラインダー",
    body: "注文を受けてから1杯分だけ挽きます。挽き目は豆の種類と淹れ方に合わせて変えています。",
  },
  {
    x: 0.62,
    y: 0.46,
    title: "エスプレッソマシン",
    body: "2杯を同時に抽出できます。ラテなどミルクを使う飲み物も、ここで泡立てて仕上げます。",
  },
  {
    x: 0.83,
    y: 0.25,
    title: "豆の棚",
    body: "常に7種類の豆を置いています。瓶の中の豆の色で焙煎の深さを見比べられ、100gから量り売りします。",
  },
  {
    x: 0.88,
    y: 0.55,
    title: "焼き菓子のケース",
    body: "クロワッサンとマフィンは近くのパン屋から毎朝届きます。売り切れた時点で終わりです。",
  },
];

const N = SPOTS.length;
const pad = (n: number) => String(n).padStart(2, "0");

const FRAMES = [
  { id: "wide", label: "横長", ratio: "2:1", ar: 2 },
  { id: "photo", label: "元の比率", ratio: "3:2", ar: 3 / 2 },
  { id: "std", label: "標準", ratio: "4:3", ar: 4 / 3 },
] as const;
type FrameId = (typeof FRAMES)[number]["id"];

/** 枠より狭い・点がこれより近いときは番号だけの点にして、説明を写真の下に出す */
const COMPACT_WIDTH = 600;
const MIN_GAP = 44;

const NARROW_QUERY = "(max-width: 639px)";
const isNarrow = () => window.matchMedia(NARROW_QUERY).matches;
function subscribeNarrow(cb: () => void) {
  const mq = window.matchMedia(NARROW_QUERY);
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
}

type Layout = { w: number; h: number; pts: { x: number; y: number; inside: boolean }[] };

/** object-fit: cover と同じ計算で、枠の中での点の位置を出す(CSS の calc と同じ式) */
function layoutFor(w: number, h: number): Layout {
  const ar = PHOTO.w / PHOTO.h;
  const dw = Math.max(w, h * ar);
  const dh = Math.max(h, w / ar);
  const pts = SPOTS.map((s) => {
    const x = (w - dw) * FOCUS.x + dw * s.x;
    const y = (h - dh) * FOCUS.y + dh * s.y;
    return { x, y, inside: x >= 12 && x <= w - 12 && y >= 12 && y <= h - 12 };
  });
  return { w, h, pts };
}

const PIN_POS = (s: Spot): CSSProperties => ({
  left: `calc((100cqw - var(--dw)) * ${FOCUS.x} + var(--dw) * ${s.x})`,
  top: `calc((100cqh - var(--dh)) * ${FOCUS.y} + var(--dh) * ${s.y})`,
});

const FOCUS_RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";
const NAV_BTN = `inline-flex h-9 items-center gap-1.5 border border-stone-300 bg-white px-3 text-[12px] font-bold text-stone-800 transition-colors hover:border-stone-900 disabled:cursor-not-allowed disabled:border-stone-200 disabled:text-stone-300 motion-reduce:transition-none ${FOCUS_RING}`;

export default function ImageHotspotTour() {
  const uid = useId();
  const headingId = `${uid}-heading`;
  const panelId = `${uid}-note`;
  const noteTitleId = `${uid}-note-title`;

  const [active, setActive] = useState<number | null>(null);
  // 未選択の間は画面幅で決める(狭い画面は 4:3。2:1 では写真が低くなりすぎる)
  const [picked, setPicked] = useState<FrameId | null>(null);
  const narrow = useSyncExternalStore(subscribeNarrow, isNarrow, () => false);
  const frame: FrameId = picked ?? (narrow ? "std" : "wide");
  const [layout, setLayout] = useState<Layout | null>(null);
  const [touched, setTouched] = useState(false);
  const [announce, setAnnounce] = useState("");

  const frameRef = useRef<HTMLDivElement>(null);
  const pinRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const prevRef = useRef<HTMLButtonElement>(null);
  const nextRef = useRef<HTMLButtonElement>(null);
  const pendingFocus = useRef<"prev" | "next" | null>(null);

  useEffect(() => {
    const el = frameRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setLayout(layoutFor(width, height));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  let minGap = Infinity;
  if (layout) {
    const pts = layout.pts.filter((p) => p.inside);
    for (let i = 0; i < pts.length; i++)
      for (let j = i + 1; j < pts.length; j++)
        minGap = Math.min(minGap, Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y));
  }
  const compact = layout !== null && (layout.w < COMPACT_WIDTH || minGap < MIN_GAP);
  const isInside = (i: number) => !layout || layout.pts[i].inside;
  // 吹き出しを写真の上に出せないとき(狭い・近い・点が枠の外)は、説明を写真の下に出す
  const noteBelow = compact || (active !== null && !isInside(active));

  // 前へ・次へで説明が別の点の下へ移っても、押したボタンにフォーカスを戻す
  useLayoutEffect(() => {
    const target = pendingFocus.current;
    if (!target) return;
    pendingFocus.current = null;
    (target === "prev" ? prevRef : nextRef).current?.focus();
  }, [active]);

  const open = (i: number) => {
    setTouched(true);
    setActive(i);
    setAnnounce(`${i + 1} / ${N}、${SPOTS[i].title}`);
  };
  const close = (returnFocus: boolean) => {
    const i = active;
    setActive(null);
    setAnnounce("");
    if (returnFocus && i !== null) pinRefs.current[i]?.focus();
  };
  const step = (dir: -1 | 1) => {
    if (active === null) return;
    const n = active + dir;
    if (n < 0 || n >= N) return;
    pendingFocus.current = dir < 0 ? "prev" : "next";
    open(n);
  };

  const onRootKeyDown = (e: KeyboardEvent<HTMLElement>) => {
    if (e.key === "Escape" && active !== null) {
      e.preventDefault();
      close(true);
    }
  };

  const onPinKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    let n: number | null = null;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") n = Math.min(N - 1, i + 1);
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") n = Math.max(0, i - 1);
    else if (e.key === "Home") n = 0;
    else if (e.key === "End") n = N - 1;
    if (n === null) return;
    e.preventDefault();
    pinRefs.current[n]?.focus();
    if (active !== null) open(n);
  };

  const showFromList = (i: number) => {
    open(i);
    const pin = pinRefs.current[i];
    if (!pin) return;
    const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    frameRef.current?.scrollIntoView({ block: "nearest", behavior: smooth ? "smooth" : "auto" });
    pin.focus({ preventScroll: true });
  };

  const frameDef = FRAMES.find((f) => f.id === frame)!;

  const note = (placement: "bubble" | "below") => {
    if (active === null) return null;
    const s = SPOTS[active];
    let side = "";
    if (placement === "bubble" && layout) {
      const p = layout.pts[active];
      const horiz = p.x / layout.w > 0.55 ? "right-6" : "left-6";
      const vert = p.y / layout.h > 0.5 ? "bottom-[-0.75rem]" : "top-[-0.75rem]";
      side = `${horiz} ${vert}`;
    } else if (placement === "bubble") {
      side = `${s.x > 0.55 ? "right-6" : "left-6"} ${s.y > 0.5 ? "bottom-[-0.75rem]" : "top-[-0.75rem]"}`;
    }
    return (
      <div
        id={panelId}
        role="group"
        aria-labelledby={noteTitleId}
        className={
          placement === "bubble"
            ? `absolute z-20 w-[18rem] border border-stone-300 bg-white p-4 text-left shadow-[0_18px_40px_-22px_rgba(28,25,23,0.5)] transition-[opacity,transform] duration-200 ease-out starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none print:hidden ${side}`
            : "border-b border-stone-200 py-4 transition-[opacity,transform] duration-200 ease-out starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none print:hidden"
        }
      >
        <div className="flex items-start justify-between gap-3">
          <h3 id={noteTitleId} className="text-[15px] leading-[1.5] font-bold text-stone-900">
            <span className="font-display mr-2 text-[12px] font-bold tabular-nums text-amber-700">{pad(active + 1)}</span>
            {s.title}
          </h3>
          <button
            type="button"
            onClick={() => close(true)}
            className={`-mt-1 -mr-1 inline-flex h-8 w-8 shrink-0 items-center justify-center text-stone-500 transition-colors hover:text-stone-900 motion-reduce:transition-none ${FOCUS_RING}`}
          >
            <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>
            <span className="sr-only">説明を閉じる</span>
          </button>
        </div>
        <p className="mt-2 text-[13px] leading-[1.85] text-stone-600">{s.body}</p>
        <div className="mt-4 flex items-center justify-between gap-2 border-t border-stone-200 pt-3">
          <button ref={prevRef} type="button" onClick={() => step(-1)} disabled={active === 0} className={NAV_BTN}>
            <svg aria-hidden="true" viewBox="0 0 16 16" className="h-3 w-3" fill="none">
              <path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="1.6" />
            </svg>
            前へ
          </button>
          <p className="font-display text-[11px] font-semibold tabular-nums text-stone-400">
            {pad(active + 1)} / {pad(N)}
          </p>
          <button ref={nextRef} type="button" onClick={() => step(1)} disabled={active === N - 1} className={NAV_BTN}>
            次へ
            <svg aria-hidden="true" viewBox="0 0 16 16" className="h-3 w-3" fill="none">
              <path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.6" />
            </svg>
          </button>
        </div>
      </div>
    );
  };

  return (
    <section
      aria-labelledby={headingId}
      onKeyDown={onRootKeyDown}
      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-101 <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-4 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] sm:items-end sm:gap-12">
        <h2
          id={headingId}
          className="text-[clamp(1.55rem,3.6vw,2.3rem)] leading-[1.4] font-bold tracking-tight text-stone-900"
        >
          {/* 文節ごとに inline-block にして、語の途中で折り返さない */}
          <span className="inline-block">カウンターまわりの</span>
          <span className="inline-block">6か所を</span>
          <span className="inline-block">写真の上で</span>
          <span className="inline-block">ご案内します</span>
        </h2>
        <p className="text-[14px] leading-[1.9] font-medium text-stone-600 sm:text-[15px]">
          番号を押すと、その場所の説明が写真の上に出ます。前へ・次へのボタンで1番から順に回れて、Esc キーで閉じます。同じ説明は写真の下の一覧にも載せています。
        </p>
      </div>

      <div className="mt-8 flex flex-wrap items-center justify-between gap-3 border-y border-stone-200 py-2.5 print:hidden">
        <div role="group" aria-label="写真の切り抜き方" className="flex items-center gap-1">
          <span className="mr-2 text-[11px] font-medium text-stone-500">切り抜き</span>
          {FRAMES.map((f) => (
            <button
              key={f.id}
              type="button"
              aria-pressed={frame === f.id}
              onClick={() => setPicked(f.id)}
              className={`inline-flex h-8 items-center gap-1.5 border px-2.5 text-[12px] font-bold transition-colors motion-reduce:transition-none ${FOCUS_RING} ${
                frame === f.id
                  ? "border-stone-900 bg-stone-900 text-white"
                  : "border-stone-200 bg-white text-stone-600 hover:border-stone-400"
              }`}
            >
              {f.label}
              <span className={`font-display text-[10px] tabular-nums ${frame === f.id ? "text-amber-300" : "text-stone-400"}`}>
                {f.ratio}
              </span>
            </button>
          ))}
        </div>
        <p className="text-[11px] text-stone-500">切り抜きを変えても、点は同じ物の上に残ります</p>
      </div>

      <figure className="mt-4">
        <div
          ref={frameRef}
          onClick={(e) => {
            if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === "IMG") close(false);
          }}
          style={
            {
              aspectRatio: frameDef.ar,
              containerType: "size",
              "--dw": `max(100cqw, 100cqh * ${PHOTO.w / PHOTO.h})`,
              "--dh": `max(100cqh, 100cqw / ${PHOTO.w / PHOTO.h})`,
            } as CSSProperties
          }
          className="relative w-full bg-stone-100"
        >
          {/* eslint-disable-next-line @next/next/no-img-element -- 静的書き出しのため最適化なしの img を使う */}
          <img
            src={PHOTO.src}
            alt={PHOTO.alt}
            width={PHOTO.w}
            height={PHOTO.h}
            decoding="async"
            draggable={false}
            style={{ objectPosition: `${FOCUS.x * 100}% ${FOCUS.y * 100}%` }}
            className="absolute inset-0 block h-full w-full object-cover select-none"
          />

          {SPOTS.map((s, i) => {
            const isActive = active === i;
            const hidden = !isInside(i);
            return (
              <div
                key={s.title}
                style={PIN_POS(s)}
                className={`absolute h-0 w-0 ${isActive ? "z-20" : "z-10"} ${hidden ? "hidden" : ""}`}
              >
                <button
                  ref={(el) => {
                    pinRefs.current[i] = el;
                  }}
                  type="button"
                  aria-expanded={isActive}
                  aria-controls={panelId}
                  aria-label={`${i + 1}. ${s.title}`}
                  onClick={() => (isActive ? close(false) : open(i))}
                  onKeyDown={(e) => onPinKeyDown(e, i)}
                  className={`font-display absolute top-0 left-0 inline-flex -translate-x-1/2 -translate-y-1/2 items-center justify-center border font-bold tabular-nums shadow-[0_4px_14px_-6px_rgba(28,25,23,0.6)] transition-[transform,background-color,border-color,color] duration-200 motion-reduce:transition-none ${FOCUS_RING} ${
                    compact ? "h-6 w-6 text-[10px]" : "h-8 w-8 text-[11px] hover:scale-110 motion-reduce:hover:scale-100"
                  } ${isActive ? "border-amber-600 bg-amber-600 text-white" : "border-white bg-white/95 text-stone-900"}`}
                >
                  {pad(i + 1)}
                  {i === 0 && !touched && (
                    <span
                      aria-hidden="true"
                      className="pointer-events-none absolute -inset-1 border border-amber-500 motion-safe:animate-ping motion-reduce:hidden"
                    />
                  )}
                </button>
                {isActive && !noteBelow && note("bubble")}
              </div>
            );
          })}
        </div>

        {noteBelow && note("below")}

        <figcaption className="mt-3 text-[11px] leading-[1.7] text-stone-500 print:hidden">
          番号の上で左右のキーを押すと前後の番号へ移ります。
        </figcaption>
      </figure>

      <ol className="mt-10 grid list-none gap-x-10 p-0 sm:grid-cols-2 lg:grid-cols-3" aria-label="写真の注釈の一覧">
        {SPOTS.map((s, i) => (
          <li key={s.title} className="grid grid-cols-[2.25rem_minmax(0,1fr)] gap-x-2 border-t border-stone-200 py-4">
            <span
              aria-hidden="true"
              className={`font-display pt-0.5 text-[13px] font-bold tabular-nums ${active === i ? "text-amber-700" : "text-stone-400"}`}
            >
              {pad(i + 1)}
            </span>
            <div>
              <h3 className="text-[14px] leading-[1.6] font-bold text-stone-900">
                <span className="sr-only">{i + 1}. </span>
                {s.title}
              </h3>
              <p className="mt-1 text-[13px] leading-[1.85] text-stone-600">{s.body}</p>
              <button
                type="button"
                onClick={() => showFromList(i)}
                className={`mt-2 text-[12px] font-bold text-stone-700 underline decoration-stone-300 underline-offset-4 transition-colors hover:decoration-amber-600 motion-reduce:transition-none print:hidden ${FOCUS_RING}`}
              >
                写真の上で見る
                <span className="sr-only">({s.title})</span>
              </button>
            </div>
          </li>
        ))}
      </ol>

      <p aria-live="polite" className="sr-only">
        {announce}
      </p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/image-hotspot-tour.json