About tour-guide
tour-guide is a dependency-free in-app tour pattern for React and Next.js interfaces. It highlights multiple elements on a route in one overlay, using stable `data-tour` attributes rather than a multi-step library.
Key features
- 01No tour library; React only
- 02Select elements with `data-tour`
- 03Highlight multiple route elements at once
- 04Support mobile and desktop layouts
- 05Click to dismiss and restore scrolling
Use cases
- 01Point out search and key actions to new users
- 02Configure tour spots for distinct screen layouts
- 03Wait for asynchronously loaded lists before starting a tour
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ rheinmir/ setup --skill tour-guideAfter installing, type/tour-guidein Claude Code to confirm it loaded.
Details
From the project READMEtour-guide — In-app spotlight tour (no library)
Pattern đã chạy thật trong bonbon DMS. Một component duy nhất + quy ước data-tour + mảng spots mỗi route.
WHAT
Purpose và context
- Purpose: thêm một in-app product tour (spotlight onboarding overlay) tự viết, 0 thư viện: lớp tối + khoét spotlight quanh từng phần tử theo CSS selector, tooltip nhãn, pill "tap để đóng" ở khoảng trống lớn nhất, responsive mobile/desktop.
- Trigger (when to use): user muốn "thêm tour guide", "onboarding tour", "hướng dẫn trong app", "spotlight tour", "product tour", "walkthrough", "tour kiểu bonbon", hoặc invoke
/tour-guide. Hợp với React/Next.js + Tailwind. - Non-goals: không dùng thư viện tour (joyride/intro.js/driver.js); không phải tour nhiều bước next/prev — một overlay hiện mọi điểm dừng của route, click đâu cũng đóng.
Mental model
- Đánh dấu phần tử cần highlight bằng
data-tour="..."(ổn định hơn class). - Một state
tourActive+ nút trigger (vd tap logo) bật/tắt. - Mỗi route khai báo
spots: TourSpot[](selector + label) truyền vào<TourGuide>. - Component đo DOM, vẽ SVG dim + spotlight cutout + tooltip, click đâu cũng đóng.
Input và output contract
| Field | Required? | Ý nghĩa | |
|---|---|---|---|
| In | app React/Next.js (+ Tailwind) | có | nơi đặt component |
| In | danh sách điểm dừng mỗi route (selector + label, tuỳ labelSide/spotlightOnly/tooltipOnly) | có | mảng spots: TourSpot[] |
| In | 6 token màu brand + dismissText | không | mặc định trong TOUR_THEME / prop |
| Out | components/ui/tour-guide.tsx (hoặc nơi tương đương) | có | copy nguyên file, 0 dependency ngoài React |
| Out | thuộc tính data-tour trên phần tử + state tourActive + nút trigger + <TourGuide> mỗi route | có | tour bật/tắt được |
Rules và capabilities
Bản gốc không có mục Rules riêng — các luật dưới gom từ "Cách hoạt động", Bước 1–2 và Gotchas (giữ ý nguyên văn):
- RULE-01 (MUST): Không thư viện tour — copy nguyên file component, 0 dependency ngoài React; chỉ chỉnh 6 token màu trong
TOUR_THEMEvàdismissTextqua prop. - RULE-02 (MUST): Dùng
data-tourthay vì class — class hay đổi do refactor/Tailwind;data-tourbền. - RULE-03 (MUST): Lock scroll: set
document.body.style.overflow='hidden'khi active, nhớ khôi phục khi đóng (đã handle trong cleanup). - RULE-04 (MUST): Click-to-close: toàn overlay
onClick={onClose}; tooltip/hint làpointer-events-nonenên không chặn click đóng. - RULE-05 (SHOULD): Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Capabilities: sửa code frontend của app (thêm component, thuộc tính, state); đo DOM runtime.
Failure boundaries
- Selector không khớp / phần tử khuất hoặc off-screen → spot tự bị bỏ qua (không lỗi) — nếu mọi spot bị bỏ thì tour trống: failed, kiểm lại
data-tourhoặc thời điểm mở tour. - App có lớp z-index cao hơn
z-[500]→ overlay bị che: nâng z-index. - App không phải React → clarify: component là TSX/React, cần port.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | effect | app | Bước 1: tạo components/ui/tour-guide.tsx (copy nguyên file), chỉnh TOUR_THEME | component | — |
| W02 | effect | phần tử cần highlight | Bước 2: thêm data-tour="..." | selector ổn định | — |
| W03 | effect | route | Bước 3: state tourActive + nút trigger + mảng SPOTS + <TourGuide> | tour chạy trên route | layout 2 dạng → B01 |
| W04 | judgment | trang chạy | Mở tour, kiểm spotlight/tooltip/pill đúng chỗ trên mobile + desktop, click đâu cũng đóng, scroll khôi phục | tour đạt | spot mất → kiểm Gotchas |
Chi tiết từng bước (nguồn chân lý cho W01–W04):
Bước 1 — Component (copy nguyên file)
Tạo components/ui/tour-guide.tsx (hoặc nơi tương đương). 0 dependency ngoài React.
Chỉnh 6 token màu trong TOUR_THEME cho khớp brand; dismissText đổi qua 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>
);
}
Bước 2 — Đánh dấu phần tử bằng data-tour
Trên các phần tử muốn highlight, thêm attribute ổn định:
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div data-tour="list-item" … />
Dùng
data-tourthay vì class — class hay đổi do refactor/Tailwind;data-tourbền.
Bước 3 — Wire vào route (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" />
Branches
| ID | Kind | Guard | Hành vi | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | layout mobile và desktop khác nhau | khai 2 mảng spots (selector data-tour='desktop-*' riêng), chọn theo window.innerWidth >= 768 (hoặc useMediaQuery) | trộn 1 mảng vẫn chạy vì measure() bỏ phần tử ẩn | W04 |
| B02 | conditional_required | list load async | mở tour sau khi data có (hoặc gọi remeasure) | — | W04 |
| B03 | user_optional | phần tử quá to / chỉ cần một kiểu | tooltipOnly (không khoét) hoặc spotlightOnly + label: "" (không tooltip) | — | W04 |
Validation và stopping
Kiểm bằng mắt trên trang chạy thật ở cả hai bề rộng (< 768 và ≥ 768): mỗi spot có cutout/tooltip đúng phần tử, pill nằm ở khoảng trống, click bất kỳ đâu đóng, body.style.overflow khôi phục sau khi đóng. Dừng khi mọi route đã khai spots hiện đúng.
Examples
- Positive: màn danh sách đơn có logo, ô tìm kiếm, dòng đơn, nút duyệt → gắn
data-tour4 chỗ,SPOTSnhư Bước 3 → tap logo mở tour: 4 spotlight kèm nhãn, pill "Nhấn bất kỳ đâu để đóng" ở vùng trống, tap đóng và trang cuộn lại được. - Boundary/failure: mở tour ngay khi list còn đang fetch → spot
[data-tour='list-item']chưa có trong DOM nên bịmeasure()bỏ, tour thiếu điểm → mở tour sau khi data về (B02).
Reference — TourSpot
| field | ý nghĩa |
|---|---|
selector | CSS selector (ưu tiên [data-tour='x']); element không tồn tại/khuất → tự bỏ qua |
label | chữ tooltip; "" + spotlightOnly = chỉ khoét sáng |
labelSide | top / bottom / right (mặc định: tự chọn theo vị trí) |
spotlightOnly | khoét spotlight, KHÔNG tooltip |
tooltipOnly | tooltip nổi, KHÔNG khoét (cho phần tử quá to / desktop) |
Reference — Mobile vs Desktop
Vì layout 2 dạng khác nhau, khai báo 2 mảng spots (selector data-tour='desktop-*' riêng) rồi chọn theo window.innerWidth >= 768 (hoặc useMediaQuery). Phần tử desktop ẩn trên mobile sẽ tự bị measure() bỏ qua, nên trộn chung 1 mảng cũng chạy — nhưng tách rõ ràng dễ chỉnh nhãn hơn.
Reference — Gotchas
- z-index: overlay
z-[500], tooltip/hintz-[501]. Nâng nếu app có thứ cao hơn. - Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Lock scroll: set
document.body.style.overflow='hidden'khi active, nhớ khôi phục khi đóng (đã handle trong cleanup). - Click-to-close: toàn overlay
onClick={onClose}; tooltip/hint làpointer-events-nonenên không chặn click đóng. - Element khuất: spot off-screen tự loại — nên cuộn lên đầu (
scrollTo top) trước khi đo (đã làm).
Origin
Trích & generalize từ bonbon DMS components/dms/tour-guide.tsx (đã chạy production, mobile+desktop).