BLK-95Blocks

初回ガイド(コーチマークで順番に操作を案内)

はじめて画面を開いた人に、部品を1つずつ照らして次の操作を案内する初回ガイド(プロダクトツアー)。照らした場所の外側を暗くし、吹き出しに「何ができるか」を1つだけ書く。吹き出しには進捗(2 / 5 と5本の目盛り)と「戻る」「次へ」「スキップ」を置き、最後の段は「完了」になる。各段は希望の向きを持ち、入りきらない端では反対側へ、それでも入らなければ広い方へ自動で向きを変え、矢印だけが目標の中心を指す。照らす穴は1つの箱を translate と scale で目標へ合わせ、box-shadow で外側を暗くするので、移動は transform の遷移だけで済む。縁取りは四隅のカギを translate で運ぶ。案内中は見本の画面を inert にし、Tab は暗くなった部品に止まらず吹き出しのボタンへ進む(ページ全体は閉じ込めない)。吹き出しの中では ← → で前後、Esc でスキップ、閉じると「使い方」ボタンへ、開き直すと吹き出しの見出しへフォーカスが移る。横の一覧に案内の順番と見た段、いま照らしている部品と吹き出しの向き(反転したかどうか)を出す。閉じた後は「もう一度見る」で最初から。prefers-reduced-motion では遷移を切り、段ごとに置き直すだけで全文が読める。依存ライブラリなし。角丸・英字の小見出し・末尾の矢印リンク・出現アニメは使わない。

追加日:
2026-10-11
依存:
なし
tags
#block #ui #onboarding #product-tour #coachmark #spotlight #tooltip #popover #keyboard #accessibility #no-image #no-dependency

プレビュー

はじめて開いた人への案内

画面の部品を1つずつ照らして、次の操作へ導く初回ガイド

照らした場所の外を暗くし、吹き出しで「何ができるか」を1つだけ伝えます。吹き出しは入りきらない端では向きを変えます。

First CH
お知らせを探す
+ 新規作成

お知らせ一覧

12 件

  • 年末年始の営業について12/26 9:00予約
  • 相談会の日程を追加しました12/18公開中
  • 料金表の改定のお知らせ—下書き
  • 制作実績を3件掲載しました12/02公開中
  1. 1探すときはここからいま
  2. 2新しいお知らせを書く
  3. 3状態は札で見分ける
  4. 4予定はまとめてここへ
  5. 5迷ったら、いつでも見直せます
照らしている所
検索欄
吹き出しの向き
下
キー操作
← → で前後・Esc でスキップ

"use client";

import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });

/**
 * 初回ガイド(コーチマークで順番に操作を案内)
 *
 * 差別化: 既存のフロアマップや3D系は「見る人が選んだ箇所に寄って説明する」自由探索型で、同意バナーは画面下に固定した1枚のパネル。
 * 本ブロックは決まった順番で画面の部品を1つずつ照らし、吹き出しで次の操作へ導く直線の案内で、吹き出しの向きが場所ごとに変わる。
 * 角丸・英字の小見出し・末尾の矢印リンク・スクロールでの出現アニメは使わない。
 *
 * 汎用技法メモ:
 * 1. **照らす穴は 100×100 の箱1つを translate + scale で目標に合わせる**。箱に `box-shadow: 0 0 0 9999px` を付けると
 *    穴の外側だけが暗くなる。幅・高さを直接動かさないので、移動は transform の遷移だけで済む(レイアウトを起こさない)。
 * 2. **縁取りは穴と別に四隅のカギ4つ**。穴の箱に枠線を付けると scale で線の太さが縦横で変わるので、
 *    角ごとの小さな要素を translate だけで四隅へ運ぶ。
 * 3. **吹き出しの向きは「入るか」で決める**。各段に希望の向きを持たせ、入らなければ反対側、それでも入らなければ
 *    残り2方向のうち広い方へ倒す。交差方向は枠の内側へ寄せ、矢印だけを目標の中心へずらす(吹き出しが枠から出ない)。
 * 4. **座標は getBoundingClientRect の差を「見た目の幅 ÷ offsetWidth」で割って戻す**。一覧カードのように親が縮小されていても
 *    同じ位置に重なる。吹き出しの大きさは offsetWidth/offsetHeight(縮小の影響を受けない)。
 * 5. **案内中は見本の画面を inert にする**。Tab は暗くなった部品に止まらず吹き出しのボタンへ進む(ページ全体は閉じ込めない)。
 *    吹き出しの中では ← → で前後、Esc でスキップ。
 *    閉じたら入口の「使い方」ボタンへ、開き直したら吹き出しの見出しへフォーカスを移す(preventScroll でページを動かさない)。
 * 6. 動きは transform と opacity だけ。prefers-reduced-motion では遷移を切り、穴と吹き出しは段ごとに置き直すだけで全文が読める。
 *
 * 文言・画面・配色はすべて First CH のオリジナル。本番では案内を見終えたことを保存し、初回だけ自動で開く。
 */

type Side = "top" | "bottom" | "left" | "right";
type Target = "search" | "create" | "status" | "schedule" | "help";

type Step = { target: Target; part: string; title: string; body: string; prefer: Side };

const STEPS: Step[] = [
  {
    target: "search",
    part: "検索欄",
    title: "探すときはここから",
    body: "タイトルや本文の言葉で、過去のお知らせを絞り込めます。日付でも探せます。",
    prefer: "bottom",
  },
  {
    target: "create",
    part: "新規作成ボタン",
    title: "新しいお知らせを書く",
    body: "ここから下書きを作ります。公開するまでは、読者からは見えません。",
    prefer: "right",
  },
  {
    target: "status",
    part: "状態の札",
    title: "状態は札で見分ける",
    body: "「公開中」「予約」「下書き」の3つです。予約は指定した時刻に自動で公開されます。",
    prefer: "left",
  },
  {
    target: "schedule",
    part: "配信予約の項目",
    title: "予定はまとめてここへ",
    body: "予約したお知らせは、公開される日時の順にこの画面へ並びます。",
    prefer: "right",
  },
  {
    target: "help",
    part: "使い方ボタン",
    title: "迷ったら、いつでも見直せます",
    body: "この案内は右下の「使い方」から何度でも開けます。おつかれさまでした。",
    prefer: "bottom",
  },
];

const SIDE_LABEL: Record<Side, string> = { top: "上", bottom: "下", left: "左", right: "右" };
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };

const ROWS = [
  { title: "年末年始の営業について", date: "12/26 9:00", status: "予約" },
  { title: "相談会の日程を追加しました", date: "12/18", status: "公開中" },
  { title: "料金表の改定のお知らせ", date: "—", status: "下書き" },
  { title: "制作実績を3件掲載しました", date: "12/02", status: "公開中" },
] as const;

const NAV = ["一覧", "配信予約", "読者", "設定"] as const;

// 穴の余白・吹き出しと穴の間(矢印込み)・枠の内側の余白
const HOLE_PAD = 6;
const GAP = 14;
const EDGE = 10;

type Rect = { x: number; y: number; w: number; h: number };
type Geo = { hole: Rect; bx: number; by: number; side: Side; flipped: boolean; arrow: number };

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), Math.max(lo, hi));

function place(hole: Rect, bw: number, bh: number, W: number, H: number, prefer: Side): Geo {
  const space: Record<Side, number> = {
    top: hole.y - EDGE,
    bottom: H - (hole.y + hole.h) - EDGE,
    left: hole.x - EDGE,
    right: W - (hole.x + hole.w) - EDGE,
  };
  const need = (s: Side) => (s === "top" || s === "bottom" ? bh : bw) + GAP;
  const rest = (["top", "bottom", "left", "right"] as Side[])
    .filter((s) => s !== prefer && s !== OPPOSITE[prefer])
    .sort((a, b) => space[b] - space[a]);
  const order = [prefer, OPPOSITE[prefer], ...rest];
  const side = order.find((s) => space[s] >= need(s)) ?? order.slice().sort((a, b) => space[b] - space[a])[0];

  const cx = hole.x + hole.w / 2;
  const cy = hole.y + hole.h / 2;
  let bx: number;
  let by: number;
  if (side === "top" || side === "bottom") {
    bx = clamp(cx - bw / 2, EDGE, W - EDGE - bw);
    by = side === "bottom" ? hole.y + hole.h + GAP : hole.y - GAP - bh;
    by = clamp(by, EDGE, H - EDGE - bh);
  } else {
    by = clamp(cy - bh / 2, EDGE, H - EDGE - bh);
    bx = side === "right" ? hole.x + hole.w + GAP : hole.x - GAP - bw;
    bx = clamp(bx, EDGE, W - EDGE - bw);
  }
  const arrow = side === "top" || side === "bottom" ? clamp(cx - bx, 16, bw - 16) : clamp(cy - by, 16, bh - 16);
  return { hole, bx, by, side, flipped: side !== prefer, arrow };
}

// 矢印は白い正方形を45°回し、外側を向く2辺にだけ線を引く
const ARROW: Record<Side, string> = {
  bottom: "-top-[6.5px] border-t border-l",
  top: "-bottom-[6.5px] border-r border-b",
  right: "-left-[6.5px] border-b border-l",
  left: "-right-[6.5px] border-t border-r",
};

const RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";

export default function CoachmarkProductTour() {
  const headingId = useId();
  const bubbleTitleId = useId();
  const bubbleBodyId = useId();

  const [open, setOpen] = useState(true);
  const [step, setStep] = useState(0);
  const [seen, setSeen] = useState(1);
  const [geo, setGeo] = useState<Geo | null>(null);
  const [glide, setGlide] = useState(false);
  const [message, setMessage] = useState("");

  const stageRef = useRef<HTMLDivElement>(null);
  const bubbleRef = useRef<HTMLDivElement>(null);
  const bubbleTitleRef = useRef<HTMLHeadingElement>(null);
  const helpRef = useRef<HTMLButtonElement>(null);
  const searchRef = useRef<HTMLDivElement>(null);
  const createRef = useRef<HTMLDivElement>(null);
  const statusRef = useRef<HTMLSpanElement>(null);
  const scheduleRef = useRef<HTMLSpanElement>(null);
  // 操作の後だけフォーカスを動かす(読み込み直後に吹き出しへ奪わない)
  const moveFocus = useRef(false);

  const measure = useCallback(() => {
    const stage = stageRef.current;
    const bubble = bubbleRef.current;
    const targets: Record<Target, HTMLElement | null> = {
      search: searchRef.current,
      create: createRef.current,
      status: statusRef.current,
      schedule: scheduleRef.current,
      help: helpRef.current,
    };
    const el = targets[STEPS[step].target];
    if (!stage || !bubble || !el) return;
    const s = stage.getBoundingClientRect();
    const r = el.getBoundingClientRect();
    // 一覧カードでは親が縮小されているので、見た目の幅÷レイアウト幅で戻す
    const k = s.width / (stage.offsetWidth || 1) || 1;
    const W = stage.clientWidth;
    const H = stage.clientHeight;
    const x = Math.max(0, (r.left - s.left) / k - HOLE_PAD);
    const y = Math.max(0, (r.top - s.top) / k - HOLE_PAD);
    const hole = {
      x,
      y,
      w: Math.min(W - x, r.width / k + HOLE_PAD * 2),
      h: Math.min(H - y, r.height / k + HOLE_PAD * 2),
    };
    setGeo(place(hole, bubble.offsetWidth, bubble.offsetHeight, W, H, STEPS[step].prefer));
  }, [step]);

  // 段が変わったら(中身が入れ替わって吹き出しの大きさが決まってから)測り直す
  useEffect(() => {
    const raf = requestAnimationFrame(measure);
    return () => cancelAnimationFrame(raf);
  }, [measure, open]);

  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const ro = new ResizeObserver(() => measure());
    ro.observe(stage);
    if (bubbleRef.current) ro.observe(bubbleRef.current);
    return () => ro.disconnect();
  }, [measure]);

  // 最初の配置は遷移なしで置き、その次のフレームから滑らせる
  useEffect(() => {
    if (!geo || glide) return;
    const raf = requestAnimationFrame(() => setGlide(true));
    return () => cancelAnimationFrame(raf);
  }, [geo, glide]);

  useEffect(() => {
    if (!moveFocus.current) return;
    moveFocus.current = false;
    if (open) bubbleTitleRef.current?.focus({ preventScroll: true });
    else helpRef.current?.focus({ preventScroll: true });
  }, [open, step]);

  const go = (to: number) => {
    if (to < 0 || to >= STEPS.length) return;
    moveFocus.current = true;
    setStep(to);
    setSeen((n) => Math.max(n, to + 1));
    setMessage("");
  };
  const close = (finished: boolean) => {
    moveFocus.current = true;
    setOpen(false);
    setMessage(
      finished
        ? "案内を最後まで見ました。右下の「使い方」からもう一度見られます"
        : "案内を閉じました。右下の「使い方」からもう一度見られます",
    );
  };
  const restart = () => {
    moveFocus.current = true;
    setStep(0);
    setSeen(1);
    setOpen(true);
    setMessage("");
  };

  const onBubbleKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      close(false);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      go(step + 1);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      go(step - 1);
    }
  };

  const current = STEPS[step];
  const last = step === STEPS.length - 1;
  const ready = geo !== null;
  const motion = glide ? "transition-transform duration-500 ease-[cubic-bezier(0.22,0.8,0.24,1)] motion-reduce:transition-none" : "";
  const hole = geo?.hole ?? { x: 0, y: 0, w: 100, h: 100 };
  const corners = [
    { cls: "border-t-2 border-l-2", x: hole.x - 3, y: hole.y - 3 },
    { cls: "border-t-2 border-r-2", x: hole.x + hole.w - 9, y: hole.y - 3 },
    { cls: "border-b-2 border-l-2", x: hole.x - 3, y: hole.y + hole.h - 9 },
    { cls: "border-b-2 border-r-2", x: hole.x + hole.w - 9, y: hole.y + hole.h - 9 },
  ];

  const badge = (status: (typeof ROWS)[number]["status"]) =>
    status === "予約"
      ? "border-amber-600 bg-amber-50 text-amber-800"
      : status === "公開中"
        ? "border-gray-900 bg-white text-gray-900"
        : "border-dashed border-gray-400 bg-white text-gray-500";

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 広い枠では舞台を左に置き、見出しと案内の順番は右の列へ寄せる */}
      <div className="grid gap-x-8 gap-y-6 @2xl:grid-cols-[minmax(0,1fr)_15rem] @2xl:grid-rows-[auto_1fr]">
        <div className="@2xl:col-start-2 @2xl:row-start-1">
          <p className="text-[12px] font-bold tracking-[0.08em] text-amber-700">はじめて開いた人への案内</p>
          <h2 id={headingId} className="mt-2 text-[clamp(1.4rem,3.4vw,2rem)] leading-[1.4] font-bold tracking-tight @2xl:text-[1.4rem]">
            画面の部品を1つずつ照らして、次の操作へ導く初回ガイド
          </h2>
          <p className="mt-4 border-b border-gray-200 pb-6 text-[14px] leading-[1.9] font-medium text-gray-600 @2xl:mt-3 @2xl:text-[13px] @2xl:leading-[1.85]">
            照らした場所の外を暗くし、吹き出しで「何ができるか」を1つだけ伝えます。吹き出しは入りきらない端では向きを変えます。
          </p>
        </div>

        {/* 見本の画面(案内中は inert) */}
        <div
          ref={stageRef}
          className="@container/stage relative h-[31rem] overflow-hidden border border-gray-200 bg-[#faf9f7] @2xl:col-start-1 @2xl:row-span-2 @2xl:row-start-1 @2xl:h-[29rem]"
        >
          <div inert={open} className="flex h-full flex-col">
            <div className="flex h-12 flex-none items-center gap-3 border-b border-gray-200 bg-white px-3 @md/stage:px-4">
              <span className={`${montserrat.className} flex-none text-[13px] font-semibold tracking-wide`}>First CH</span>
              <span className="hidden flex-none text-[11px] text-gray-400 @lg/stage:inline">お知らせ配信</span>
              <div
                ref={searchRef}
                className="ml-auto flex h-8 min-w-0 flex-1 items-center gap-2 border border-gray-300 bg-white px-2.5 text-[12px] text-gray-400 @md/stage:max-w-60"
              >
                <span aria-hidden="true" className="size-2.5 flex-none rotate-45 border border-gray-400" />
                <span className="truncate">お知らせを探す</span>
              </div>
              <div
                ref={createRef}
                className="flex h-8 flex-none items-center bg-gray-900 px-3 text-[12px] font-bold whitespace-nowrap text-white"
              >
                + 新規作成
              </div>
            </div>

            <div className="flex min-h-0 flex-1 flex-col @md/stage:flex-row">
              <nav aria-label="見本の画面のメニュー" className="flex flex-none gap-1 border-b border-gray-200 bg-white px-2 py-1.5 @md/stage:w-32 @md/stage:flex-col @md/stage:border-r @md/stage:border-b-0 @md/stage:px-2 @md/stage:py-3">
                {NAV.map((n, i) => (
                  <span
                    key={n}
                    ref={n === "配信予約" ? scheduleRef : undefined}
                    className={`px-2.5 py-1.5 text-[12px] whitespace-nowrap ${i === 0 ? "bg-gray-100 font-bold text-gray-900" : "text-gray-600"}`}
                  >
                    {n}
                  </span>
                ))}
              </nav>

              <div className="min-w-0 flex-1 px-4 py-4 @md/stage:px-5">
                <div className="flex items-baseline justify-between gap-3">
                  <p className="text-[15px] font-bold">お知らせ一覧</p>
                  <p className="text-[11.5px] text-gray-500">
                    <span className={`${montserrat.className} tabular-nums`}>12</span> 件
                  </p>
                </div>
                <ul className="mt-3 border-t border-gray-200">
                  {ROWS.map((r, i) => (
                    <li key={r.title} className="flex items-center justify-between gap-3 border-b border-gray-200 py-2.5">
                      <span className="min-w-0">
                        <span className="block truncate text-[12.5px] font-medium text-gray-800">{r.title}</span>
                        <span className={`${montserrat.className} mt-0.5 block text-[10.5px] text-gray-400 tabular-nums`}>{r.date}</span>
                      </span>
                      <span
                        ref={i === 0 ? statusRef : undefined}
                        className={`flex-none border px-1.5 py-0.5 text-[11px] font-bold whitespace-nowrap ${badge(r.status)}`}
                      >
                        {r.status}
                      </span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>

            <button
              ref={helpRef}
              type="button"
              onClick={restart}
              className={`absolute right-3 bottom-3 flex h-8 items-center gap-1.5 border border-gray-900 bg-white px-3 text-[12px] font-bold transition-colors hover:border-amber-700 hover:text-amber-800 motion-reduce:transition-none ${RING}`}
            >
              <span aria-hidden="true" className={`${montserrat.className} grid size-4 place-items-center border border-current text-[10px]`}>
                ?
              </span>
              使い方
            </button>
          </div>

          {/* 照らす穴・四隅のカギ・吹き出し。最初の表示は遷移なしで出す(静止画の撮影でも案内が写る) */}
          <div
            aria-hidden={!open}
            className={`pointer-events-none absolute inset-0 overflow-hidden ${glide ? "transition-opacity duration-300 motion-reduce:transition-none" : ""} ${open && ready ? "opacity-100" : "opacity-0"}`}
          >
            <div
              style={{ transform: `translate(${hole.x}px, ${hole.y}px) scale(${hole.w / 100}, ${hole.h / 100})` }}
              className={`absolute top-0 left-0 size-[100px] origin-top-left shadow-[0_0_0_9999px_rgba(17,24,39,0.56)] ${motion}`}
            />
            {corners.map((c, i) => (
              <span
                key={i}
                aria-hidden="true"
                style={{ transform: `translate(${c.x}px, ${c.y}px)` }}
                className={`absolute top-0 left-0 size-3 border-amber-500 ${c.cls} ${motion}`}
              />
            ))}

            <div
              ref={bubbleRef}
              role="dialog"
              aria-modal="false"
              aria-labelledby={bubbleTitleId}
              aria-describedby={bubbleBodyId}
              inert={!open}
              onKeyDown={onBubbleKey}
              style={{ transform: `translate(${geo?.bx ?? EDGE}px, ${geo?.by ?? EDGE}px)` }}
              className={`pointer-events-auto absolute top-0 left-0 w-[17rem] max-w-[calc(100%-20px)] border border-gray-900 bg-white px-4 pt-3.5 pb-3 shadow-[0_18px_40px_-18px_rgba(17,24,39,0.55)] ${motion}`}
            >
              <span
                aria-hidden="true"
                style={
                  geo && (geo.side === "top" || geo.side === "bottom")
                    ? { left: geo.arrow - 6, transform: "rotate(45deg)" }
                    : { top: (geo?.arrow ?? 16) - 6, transform: "rotate(45deg)" }
                }
                className={`absolute size-3 border-gray-900 bg-white ${ARROW[geo?.side ?? "bottom"]}`}
              />
              <div className="flex items-center justify-between gap-3">
                <p className={`${montserrat.className} text-[11px] font-semibold text-gray-500 tabular-nums`}>
                  <span className="sr-only">案内 </span>
                  <span className="text-amber-700">{step + 1}</span> / {STEPS.length}
                </p>
                <span aria-hidden="true" className="flex gap-1">
                  {STEPS.map((s, i) => (
                    <span key={s.target} className={`h-[3px] w-4 ${i <= step ? "bg-amber-600" : "bg-gray-200"}`} />
                  ))}
                </span>
              </div>
              <h3 id={bubbleTitleId} ref={bubbleTitleRef} tabIndex={-1} className={`mt-2 text-[14.5px] leading-[1.5] font-bold ${RING}`}>
                {current.title}
              </h3>
              <p id={bubbleBodyId} className="mt-1.5 text-[12.5px] leading-[1.8] text-gray-600">
                {current.body}
              </p>
              <div className="mt-3 flex items-center justify-between gap-2 border-t border-gray-200 pt-3">
                <button
                  type="button"
                  onClick={() => close(false)}
                  className={`-ml-1 px-1 py-1 text-[12px] text-gray-500 underline decoration-gray-300 underline-offset-4 transition-colors hover:text-gray-900 hover:decoration-gray-900 motion-reduce:transition-none ${RING}`}
                >
                  スキップ
                </button>
                <span className="flex gap-2">
                  <button
                    type="button"
                    onClick={() => go(step - 1)}
                    disabled={step === 0}
                    className={`border border-gray-300 px-3 py-1.5 text-[12px] font-bold text-gray-700 transition-colors hover:border-gray-900 disabled:cursor-default disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none ${RING}`}
                  >
                    戻る
                  </button>
                  <button
                    type="button"
                    onClick={() => (last ? close(true) : go(step + 1))}
                    className={`bg-amber-600 px-3.5 py-1.5 text-[12px] font-bold text-white transition-colors hover:bg-amber-700 motion-reduce:transition-none ${RING}`}
                  >
                    {last ? "完了" : "次へ"}
                  </button>
                </span>
              </div>
            </div>
          </div>
        </div>

        {/* 案内の順番と、いまの吹き出しの状態 */}
        <div className="@2xl:col-start-2 @2xl:row-start-2">
          <ol className="border-t border-gray-200">
            {STEPS.map((s, i) => {
              const state = !open ? (i < seen ? "done" : "todo") : i === step ? "now" : i < seen ? "done" : "todo";
              return (
                <li
                  key={s.target}
                  aria-current={state === "now" ? "step" : undefined}
                  className={`flex items-baseline gap-3 border-b border-gray-200 py-2 pl-3 text-[12.5px] ${state === "now" ? "border-l-2 border-l-amber-600 pl-2.5 font-bold text-gray-900" : state === "done" ? "text-gray-600" : "text-gray-400"}`}
                >
                  <span className={`${montserrat.className} w-4 flex-none text-[11px] tabular-nums ${state === "now" ? "text-amber-700" : ""}`}>{i + 1}</span>
                  <span className="min-w-0 flex-1">{s.title}</span>
                  <span className="flex-none text-[10.5px] font-medium text-gray-400">{state === "done" ? "見た" : state === "now" ? "いま" : ""}</span>
                </li>
              );
            })}
          </ol>

          {open ? (
            <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[12px]">
              <dt className="text-gray-500">照らしている所</dt>
              <dd className="font-medium text-gray-900">{current.part}</dd>
              <dt className="text-gray-500">吹き出しの向き</dt>
              <dd className="font-medium text-gray-900">
                {geo ? SIDE_LABEL[geo.side] : SIDE_LABEL[current.prefer]}
                {geo?.flipped && <span className="ml-1.5 text-[11px] font-normal text-amber-800">({SIDE_LABEL[current.prefer]}に入らず反転)</span>}
              </dd>
              <dt className="text-gray-500">キー操作</dt>
              <dd className="text-gray-700">← → で前後・Esc でスキップ</dd>
            </dl>
          ) : (
            <div className="mt-4">
              <p className="text-[12.5px] leading-[1.8] text-gray-600">
                案内を閉じました({STEPS.length}つ中 <span className={`${montserrat.className} tabular-nums`}>{seen}</span> つ目まで)
              </p>
              <button
                type="button"
                onClick={restart}
                className={`mt-3 bg-amber-600 px-4 py-2 text-[12.5px] font-bold text-white transition-colors hover:bg-amber-700 motion-reduce:transition-none ${RING}`}
              >
                もう一度見る
              </button>
            </div>
          )}
          <p role="status" aria-live="polite" className="sr-only">
            {message}
          </p>
        </div>
      </div>
    </section>
  );
}

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

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