mcpField
T
tour-guide

tour-guide

github.com/rheinmirデザイン・UI10258 回インストール2 週間前に更新
リポジトリ

tour-guide について

tour-guide は、React/Next.js アプリ向けの依存ライブラリ不要な画面内ツアーの実装パターンです。安定した `data-tour` 属性で要素を指定し、1つのオーバーレイで複数箇所を強調します。

主な機能

  1. 01ツアーライブラリ不要、Reactのみ
  2. 02`data-tour` による要素指定
  3. 03複数の要素を同時に強調
  4. 04モバイルとデスクトップに対応
  5. 05クリックで終了しスクロールを復元

活用シーン

  1. 01新規ユーザーに検索欄や主要操作を案内
  2. 02画面幅ごとにツアー箇所を設定
  3. 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つのオーバーレイに、そのルートのすべての停止ポイントを表示し、どこをクリックしても閉じます。
メンタルモデル
  1. ハイライトする要素に data-tour="..." を設定します(classより安定しています)。
  2. tourActive のstateとトリガーボタン(例:ロゴのタップ)で有効/無効を切り替えます。
  3. 各ルートで spots: TourSpot[](selector + label)を宣言し、<TourGuide> に渡します。
  4. コンポーネントがDOMを計測し、SVGの暗転レイヤー、スポットライトの切り抜き、ツールチップを描画します。どこをクリックしても閉じます。
入力と出力の契約
FieldRequired?意味
入力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で実装されているため、移植が必要です。

手順

主なワークフロー
StepTypeInputsActionOutputs/exitFailure/next
W01effectアプリステップ1:components/ui/tour-guide.tsx を作成(ファイル全体をそのままコピー)し、TOUR_THEME を調整しますコンポーネント—
W02effectハイライトする要素ステップ2:data-tour="..." を追加します安定したセレクター—
W03effectルートステップ3:tourActive state、トリガーボタン、SPOTS 配列、<TourGuide> を追加しますルート上でツアーが動作レイアウトが2種類 → B01
W04judgment実行中のページツアーを開き、モバイルとデスクトップでスポットライト/ツールチップ/ピルの位置を確認します。どこをクリックしても閉じ、スクロールが元に戻ることを確認しますツアーが完成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種類条件動作スキップ/失敗再合流
B01conditional_requiredモバイルとデスクトップでレイアウトが異なるspot の配列を 2 つ定義し(data-tour='desktop-*' のセレクターを別にする)、window.innerWidth >= 768(または useMediaQuery)で選択します1 つの配列にまとめても、measure() が非表示の要素を除外するため動作しますW04
B02conditional_requiredリストの読み込みが非同期データが揃ってからツアーを開きます(または remeasure を呼び出します)—W04
B03user_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
フィールド意味
selectorCSS セレクター([data-tour='x'] を推奨)。要素が存在しない/非表示の場合は自動的に除外されます
labelツールチップのテキスト。"" + spotlightOnly = スポットライトのみ
labelSidetop / 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 から抜粋・一般化しています(本番環境で動作済み。モバイル+デスクトップ)。

コメント