BLK-95Blocks

Coachmark Product Tour (step-by-step onboarding)

A first-run guide (product tour) that lights up one part of the screen at a time and explains the next action. Everything outside the highlighted spot is dimmed, and a callout says just one thing the part can do. The callout carries progress (2 / 5 plus five ticks) and Back, Next and Skip buttons, with the last step showing Done. Each step has a preferred side; when the callout does not fit near an edge it flips to the opposite side, or else to the roomier remaining side, while only the arrow shifts to point at the target's center. The spotlight is a single box fitted to the target with translate and scale, its outside darkened with box-shadow, so moving it is purely a transform transition; the frame is four corner brackets carried by translate. While the tour runs the mock screen is inert, so Tab skips the dimmed parts and lands on the callout buttons without trapping the page. Inside the callout, Left/Right step back and forth, Escape skips, closing moves focus to the Help button and reopening moves it to the callout heading. A side list shows the step order and which steps were seen, the part being highlighted and the callout direction (including whether it flipped). After closing, Watch again restarts from step one. Under prefers-reduced-motion transitions are cut and each step is simply placed, so all text stays readable. No dependencies. No rounded corners, English kickers, trailing arrow links or entrance animations.

Added:
2026-10-11
Dependencies:
None
tags
#block #ui #onboarding #product-tour #coachmark #spotlight #tooltip #popover #keyboard #accessibility #no-image #no-dependency

Preview

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

画面の部品を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>
  );
}

Add to your project via shadcn CLI

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