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 / 5
探すときはここから
タイトルや本文の言葉で、過去のお知らせを絞り込めます。日付でも探せます。
- 1探すときはここからいま
- 2新しいお知らせを書く
- 3状態は札で見分ける
- 4予定はまとめてここへ
- 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