tour-guide について
tour-guide は、React/Next.js アプリ向けの依存ライブラリ不要な画面内ツアーの実装パターンです。安定した `data-tour` 属性で要素を指定し、1つのオーバーレイで複数箇所を強調します。
主な機能
- 01ツアーライブラリ不要、Reactのみ
- 02`data-tour` による要素指定
- 03複数の要素を同時に強調
- 04モバイルとデスクトップに対応
- 05クリックで終了しスクロールを復元
活用シーン
- 01新規ユーザーに検索欄や主要操作を案内
- 02画面幅ごとにツアー箇所を設定
- 03非同期リストの読み込み後にツアーを開始
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rheinmir/ setup --skill tour-guideインストール後、Claude Code で/tour-guideを入力して読み込まれたか確認します。
詳細
tour-guide — ヘルプなしのアプリ内スポットライトツアー
bonbon DMSで実際に稼働しているパターンです。単一のコンポーネントと data-tour の規約、ルートごとの spots 配列で構成します。
概要
目的とコンテキスト
- 目的: ライブラリを使わず、独自実装のアプリ内プロダクトツアー(スポットライト型のオンボーディングオーバーレイ)を追加します。暗いレイヤーを重ね、CSSセレクターで各要素の周囲にスポットライトをくり抜き、ラベル付きツールチップと、最も広い空きスペースに「タップして閉じる」ピルを表示します。モバイルとデスクトップに対応します。
- トリガー(使用する場面): ユーザーが「ツアーガイドを追加」「オンボーディングツアー」「アプリ内ガイド」「スポットライトツアー」「プロダクトツアー」「ウォークスルー」「bonbon風ツアー」と依頼した場合、または
/tour-guideを呼び出した場合です。React/Next.js + Tailwindに適しています。 - 対象外: ツアー用ライブラリ(joyride/intro.js/driver.js)は使いません。next/prevで進む複数ステップのツアーではありません。1つのオーバーレイに、そのルートのすべての停止ポイントを表示し、どこをクリックしても閉じます。
メンタルモデル
- ハイライトする要素に
data-tour="..."を設定します(classより安定しています)。 tourActiveのstateとトリガーボタン(例:ロゴのタップ)で有効/無効を切り替えます。- 各ルートで
spots: TourSpot[](selector + label)を宣言し、<TourGuide>に渡します。 - コンポーネントがDOMを計測し、SVGの暗転レイヤー、スポットライトの切り抜き、ツールチップを描画します。どこをクリックしても閉じます。
入力と出力の契約
| Field | Required? | 意味 | |
|---|---|---|---|
| 入力 | React/Next.jsアプリ(+ Tailwind) | はい | コンポーネントを配置する場所 |
| 入力 | ルートごとの停止ポイント一覧(selector + label、labelSide/spotlightOnly/tooltipOnly は任意) | はい | spots: TourSpot[] 配列 |
| 入力 | ブランドカラーの6つのトークン + dismissText | いいえ | TOUR_THEME/propのデフォルト値 |
| 出力 | components/ui/tour-guide.tsx(または同等の場所) | はい | ファイル全体をそのままコピー。React以外の依存関係は0 |
| 出力 | 要素の data-tour 属性、tourActive state、トリガーボタン、各ルートの <TourGuide> | はい | ツアーを有効/無効にできます |
ルールと機能
原文にはRulesの項目がありません。以下のルールは「動作方法」、ステップ1〜2、Gotchasからまとめたものです(意図は原文どおりです)。
- RULE-01(必須):ツアー用ライブラリは使いません。コンポーネントファイル全体をそのままコピーし、React以外の依存関係は0にします。
TOUR_THEMEの6つのカラー トークンと、prop経由のdismissTextだけを変更します。 - RULE-02(必須):classではなく
data-tourを使います。classはリファクタリングやTailwindによって変更されることがありますが、data-tourは安定しています。 - RULE-03(必須):スクロールをロック: 有効時に
document.body.style.overflow='hidden'を設定し、閉じる際に必ず元に戻します(クリーンアップ内ですでに処理されています)。 - RULE-04(必須):クリックで閉じる: オーバーレイ全体に
onClick={onClose}を設定します。ツールチップ/ヒントはpointer-events-noneのため、閉じるクリックを妨げません。 - RULE-05(推奨):300ms遅らせて計測: 計測前にレイアウトやアニメーションが安定するまで待ちます。リストが非同期で読み込まれる場合は、データが揃ってからツアーを開くか、再計測を呼び出します。
- 機能:アプリのフロントエンドコードを変更します(コンポーネント、属性、stateの追加)。実行時にDOMを計測します。
失敗時の境界
- セレクターが一致しない/要素が隠れている、または画面外にある場合 → 該当のspotはエラーにならずにスキップされます。すべてのspotがスキップされた場合、ツアーは空になります。失敗として、
data-tourまたはツアーを開くタイミングを確認してください。 - アプリに
z-[500]より高いz-indexのレイヤーがある場合 → オーバーレイが隠れます。z-indexを上げてください。 - アプリがReactではない場合 → 要確認: コンポーネントはTSX/Reactで実装されているため、移植が必要です。
手順
主なワークフロー
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | effect | アプリ | ステップ1:components/ui/tour-guide.tsx を作成(ファイル全体をそのままコピー)し、TOUR_THEME を調整します | コンポーネント | — |
| W02 | effect | ハイライトする要素 | ステップ2:data-tour="..." を追加します | 安定したセレクター | — |
| W03 | effect | ルート | ステップ3:tourActive state、トリガーボタン、SPOTS 配列、<TourGuide> を追加します | ルート上でツアーが動作 | レイアウトが2種類 → B01 |
| W04 | judgment | 実行中のページ | ツアーを開き、モバイルとデスクトップでスポットライト/ツールチップ/ピルの位置を確認します。どこをクリックしても閉じ、スクロールが元に戻ることを確認します | ツアーが完成 | spotが表示されない → Gotchasを確認 |
各ステップの詳細(W01〜W04の信頼できる情報源):
ステップ1 — コンポーネント(ファイル全体をそのままコピー)
components/ui/tour-guide.tsx(または同等の場所)を作成します。React以外の依存関係は0です。
ブランドに合わせて TOUR_THEME のカラー トークンを6つ調整します。dismissText はprop経由で変更します。
"use client";
import { useEffect, useState, useCallback } from "react";
/** Sửa 6 token này cho khớp brand của bạn. */
const TOUR_THEME = {
scrim: "rgba(0,0,0,0.72)", // lớp tối
ring: "rgba(255,255,255,0.2)", // viền spotlight
tipDesktop: "border border-sky-400 bg-white text-slate-900 shadow-[0_2px_8px_rgba(56,189,248,0.2)]",
tipMobile: "bg-sky-100 text-slate-900",
hintDesktop: "bg-slate-900 text-white",
hintMobile: "bg-white/25 text-white backdrop-blur-sm",
};
export interface TourSpot {
selector: string; // CSS selector phần tử (ưu tiên [data-tour='...'])
label: string; // chữ tooltip ("" nếu chỉ muốn spotlight)
labelSide?: "top" | "bottom" | "right"; // hướng đặt tooltip
spotlightOnly?: boolean; // chỉ khoét spotlight, không tooltip
tooltipOnly?: boolean; // chỉ tooltip, không khoét spotlight
}
interface MeasuredSpot {
top: number; left: number; width: number; height: number;
label: string; labelSide: "top" | "bottom" | "right";
spotlightOnly?: boolean; tooltipOnly?: boolean;
}
function measure(spots: TourSpot[]): MeasuredSpot[] {
return spots.flatMap(spot => {
const el = document.querySelector(spot.selector);
if (!el) return [];
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return [];
if (r.top < -20 || r.top > window.innerHeight) return []; // off-screen → bỏ
const vh = window.innerHeight;
return [{
top: r.top, left: r.left, width: r.width, height: r.height,
label: spot.label,
labelSide: spot.labelSide ?? (r.top > vh * 0.6 ? "top" : "bottom"),
spotlightOnly: spot.spotlightOnly,
tooltipOnly: spot.tooltipOnly,
}];
});
}
export function TourGuide({
active, onClose, spots, dismissText = "Tap anywhere to close",
}: {
active: boolean; onClose: () => void; spots: TourSpot[]; dismissText?: string;
}) {
const [maskId] = useState(() => `tm-${Math.random().toString(36).slice(2, 8)}`);
const [rects, setRects] = useState<MeasuredSpot[]>([]);
const remeasure = useCallback(() => { if (active) setRects(measure(spots)); }, [active, spots]);
useEffect(() => {
if (!active) { setRects([]); document.body.style.overflow = ""; return; }
window.scrollTo({ top: 0, behavior: "instant" });
document.body.style.overflow = "hidden";
const id = setTimeout(() => setRects(measure(spots)), 300) as unknown as number;
window.addEventListener("resize", remeasure);
return () => {
clearTimeout(id);
window.removeEventListener("resize", remeasure);
document.body.style.overflow = "";
};
}, [active, remeasure]);
if (!active) return null;
const PAD = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
const isDesktop = vw >= 768;
return (
<div className="fixed inset-0 z-[500]" onClick={onClose}>
<svg className="pointer-events-none absolute inset-0" width={vw} height={vh}>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12} fill="black" />
))}
</mask>
</defs>
<rect width="100%" height="100%" fill={TOUR_THEME.scrim} mask={`url(#${maskId})`} />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12}
fill="none" stroke={TOUR_THEME.ring} strokeWidth={1.5} />
))}
</svg>
{rects.filter(r => !r.spotlightOnly).map((r, i) => {
const isTop = r.labelSide === "top";
const isRight = r.labelSide === "right";
const isTall = r.height > 50 && r.height <= 200;
const tip = `whitespace-nowrap rounded-lg px-2.5 py-1 text-[10px] font-semibold shadow-md ${isDesktop ? TOUR_THEME.tipDesktop : TOUR_THEME.tipMobile}`;
let tooltipTop: number;
if (isRight) {
const estWR = Math.min(r.label.length * 7 + 24, 220);
tooltipTop = r.top + r.height / 2 - 14;
const rightLeft = r.left + r.width + PAD + 6;
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: Math.min(rightLeft, vw - estWR - 8) }}>
<div className={tip}>{r.label}</div>
</div>
);
} else if (isTop) {
tooltipTop = Math.max(8, r.top - 30);
} else if (isTall) {
tooltipTop = Math.min(r.top + r.height * 0.82, vh - 40);
} else if (r.height > 200 && (!isDesktop || r.tooltipOnly)) {
return null; // card mở rộng (mobile luôn ẩn; desktop chỉ ẩn khi tooltipOnly)
} else {
tooltipTop = Math.min(r.top + r.height + 6, vh - 40);
}
const estW = Math.min(r.label.length * 7 + 24, 220);
const centerX = r.left + r.width / 2 - estW / 2;
const tooltipLeft = Math.max(8, Math.min(centerX, vw - estW - 8));
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: tooltipLeft }}>
<div className={tip}>{r.label}</div>
</div>
);
})}
{(() => {
const PILL_H = 40, MARGIN = 16;
const zones: [number, number][] = rects.map(r => [r.top - PAD - 36, r.top + r.height + PAD + 36]);
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const z of zones) {
if (merged.length && z[0] <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], z[1]);
} else { merged.push([...z] as [number, number]); }
}
const gaps: { top: number; bot: number; size: number }[] = [];
let prev = 80;
for (const [zTop, zBot] of merged) {
if (zTop - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: zTop, size: zTop - prev });
prev = zBot;
}
if (vh - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: vh, size: vh - prev });
const best = gaps.sort((a, b) => b.size - a.size)[0];
if (!best || best.size < PILL_H + MARGIN * 2) return null;
const pillTop = best.top + (best.size - PILL_H) / 2;
return (
<div className={`pointer-events-none fixed left-1/2 z-[501] -translate-x-1/2 rounded-full px-5 py-2.5 text-xs font-semibold shadow-lg ${isDesktop ? TOUR_THEME.hintDesktop : TOUR_THEME.hintMobile}`}
style={{ top: pillTop }}>
{dismissText}
</div>
);
})()}
</div>
);
}
ステップ 2 — data-tour で要素をマークする
ハイライトしたい要素に、安定した属性を追加します。
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div data-tour="list-item" … />
class ではなく
data-tourを使ってください。class はリファクタリングや Tailwind によって頻繁に変わりますが、data-tourは安定しています。
ステップ 3 — ルートに組み込む(state + trigger + spots)
import { TourGuide, type TourSpot } from "@/components/ui/tour-guide";
import { useMemo, useState } from "react";
const [tourActive, setTourActive] = useState(false);
const SPOTS = useMemo<TourSpot[]>(() => [
{ selector: "[data-tour='logo']", label: "Mở hướng dẫn", labelSide: "bottom" },
{ selector: "[data-tour='search-input']", label: "Tìm kiếm ở đây", labelSide: "bottom" },
{ selector: "[data-tour='list-item']", label: "Nhấn để xem chi tiết", labelSide: "bottom" },
{ selector: "[data-tour='action-approve']", label: "Duyệt", labelSide: "top" },
], []);
// …trong JSX:
<button data-tour="logo" onClick={() => setTourActive(true)}>Hướng dẫn</button>
<TourGuide active={tourActive} onClose={() => setTourActive(false)} spots={SPOTS} dismissText="Nhấn bất kỳ đâu để đóng" />
分岐
| ID | 種類 | 条件 | 動作 | スキップ/失敗 | 再合流 |
|---|---|---|---|---|---|
| B01 | conditional_required | モバイルとデスクトップでレイアウトが異なる | spot の配列を 2 つ定義し(data-tour='desktop-*' のセレクターを別にする)、window.innerWidth >= 768(または useMediaQuery)で選択します | 1 つの配列にまとめても、measure() が非表示の要素を除外するため動作します | W04 |
| B02 | conditional_required | リストの読み込みが非同期 | データが揃ってからツアーを開きます(または remeasure を呼び出します) | — | W04 |
| B03 | user_optional | 要素が大きすぎる/どちらか一方の形式だけでよい | tooltipOnly(くり抜かない)または spotlightOnly + label: ""(ツールチップなし)を使います | — | W04 |
検証と終了条件
実際に動作しているページを 2 つの幅(< 768、≥ 768)で目視確認します。各 spot のくり抜き/ツールチップが正しい要素に表示され、pill が空白部分に配置され、どこをクリックしても閉じ、閉じた後に body.style.overflow が元に戻ることを確認します。すべてのルートで宣言した spot が正しく表示されれば終了です。
例
- 成功例: ロゴ、検索ボックス、注文行、承認ボタンがある注文一覧画面 → 4 か所に
data-tourを付け、ステップ 3 のようにSPOTSを定義します → ロゴをタップすると、ラベル付きの spotlight が 4 つ表示され、空白部分に pill「どこをタップしても閉じます」が表示されます。タップすると閉じ、ページを再びスクロールできます。 - 境界/失敗例: リストの取得中にツアーを開くと、
[data-tour='list-item']の spot は DOM にまだ存在しないためmeasure()によって除外され、ツアーに表示されません → データが揃ってからツアーを開きます(B02)。
リファレンス — TourSpot
| フィールド | 意味 |
|---|---|
selector | CSS セレクター([data-tour='x'] を推奨)。要素が存在しない/非表示の場合は自動的に除外されます |
label | ツールチップのテキスト。"" + spotlightOnly = スポットライトのみ |
labelSide | top / bottom / right(デフォルト:位置に応じて自動選択) |
spotlightOnly | スポットライトでくり抜き、ツールチップは表示しません |
tooltipOnly | 浮動ツールチップのみを表示し、くり抜きません(要素が大きすぎる場合/デスクトップ向け) |
リファレンス — モバイルとデスクトップ
レイアウトが 2 種類あるため、spot の配列を 2 つ定義し(data-tour='desktop-*' のセレクターを別にする)、window.innerWidth >= 768(または useMediaQuery)で選択します。モバイルで非表示になるデスクトップ用の要素は measure() によって自動的に除外されるため、1 つの配列にまとめても動作します。ただし、分けておくとラベルを調整しやすくなります。
リファレンス — 注意点
- z-index: overlay は
z-[500]、tooltip/hint はz-[501]です。アプリ内にこれより高い要素がある場合は、値を上げてください。 - 300ms 待ってから測定: 測定前にレイアウトやアニメーションが安定するのを待ちます。リストの読み込みが非同期の場合は、データが揃ってからツアーを開きます(または remeasure を呼び出します)。
- スクロールのロック: active の間は
document.body.style.overflow='hidden'を設定し、閉じるときに必ず元に戻します(cleanup で処理済みです)。 - クリックして閉じる: overlay 全体に
onClick={onClose}を設定します。tooltip/hint はpointer-events-noneのため、閉じるクリックを妨げません。 - 非表示の要素: 画面外の spot は自動的に除外されるため、測定前に先頭へスクロールします(実装済みです)。
出典
bonbon DMS の components/dms/tour-guide.tsx から抜粋・一般化しています(本番環境で動作済み。モバイル+デスクトップ)。