Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
Tour
A guided walk through a screen: each step dims the page, cuts a spotlight around one control and explains it in a card that stays attached to it. For the first visit to an app like Tasks, or the week a feature moves.
Import and origin. From the main entry — import { Tour, TourStep, … } from '@comitor/ui' — with no optional peer to install; card placement uses @floating-ui/react-dom, an ordinary dependency (the same one Radix positions popovers with). The file is a client component ("use client" inside): a Server Component page can render it, but the open state, the refs you point steps at and every callback belong in a client component — in practice a tour lives in a small client component of its own. Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens — the fixes are listed below and in the changelog.
Anatomy
A root that holds the state, a portal, the spotlight, and one TourStep per step. Only the current step renders its card; a footer shared by all of them goes in stepFooter, and a TourFooter placed inside a step replaces it for that step alone.
import {
Tour, TourPortal, TourSpotlight, TourSpotlightRing, TourStep, TourArrow,
TourHeader, TourTitle, TourDescription, TourClose,
TourFooter, TourStepCounter, TourPrev, TourNext, TourSkip,
} from '@comitor/ui'
<Tour open={open} onOpenChange={setOpen} stepFooter={footer}>
<TourPortal> {/* renders into document.body */}
<TourSpotlight /> {/* the dimmed page, with a hole around the target */}
<TourSpotlightRing /> {/* a ring around that hole */}
<TourStep target={searchRef}> {/* one per step, in order — the card, role="dialog" */}
<TourArrow />
<TourHeader>
<TourTitle>Search everything</TourTitle> {/* the card's accessible name */}
<TourDescription>…</TourDescription> {/* …and its description */}
</TourHeader>
<TourClose /> {/* × — named by labels.close */}
{/* a TourFooter here replaces stepFooter for this step only */}
</TourStep>
{/* …one TourStep per step */}
</TourPortal>
</Tour>
// stepFooter — rendered into every step that has no TourFooter of its own
const footer = (
<TourFooter>
<TourStepCounter /> {/* "Step 2 of 4" */}
<TourSkip />
<TourPrev />
<TourNext /> {/* "Next" — "Finish" on the last step */}
</TourFooter>
)A first-run tour
Four steps across an app toolbar. Both open and the step value are controlled so the read-out can show them, and the tour only opens from the button — never on page load. Walk it with the keyboard too: Enter on “Start tour”, then Enter on every card.
open = false · value = 0 · last callback = —
- Watch the read-out as it ends. Finish sets
valueto the number of steps (4) and callsonCompletebefore closing. Escape or × closes withonSkipon any step, the last one included — since3.0.1; in3.0.0a close on the last step called neither, and onlyonOpenChange(false)saw it. - When a card appears, focus moves into it — by default to its first enabled control, which here is the ×. The demo's
onOpenAutoFocusputs it on Next instead, so Enter, Enter, Enter walks the tour. It runs for every step, not just the first. - You can click the highlighted control itself — the target counts as inside the tour — but focus stays trapped in the card, so a search box under the spotlight cannot be typed into.
Steps and targets
A step points at its target and nothing else — the tour never reads your layout. While a step's target cannot be found, that step renders no card (see Known limitations for what that leaves on screen).
// Three kinds of target, looked up after every render of the current step:
<TourStep target="#new-task" /> // a selector — document.querySelector: first match in the WHOLE page
<TourStep target={newTaskRef} /> // a ref — no ids to keep unique; what every demo here uses
<TourStep target={element} /> // an element you already hold
// Steps are numbered in the order they mount. Render them all, in order, up front —
// a TourStep that mounts later joins at the end of the tour.Placement
side and align say where the card goes; the card flips to the opposite side and shifts along the edge when there is no room, and data-side / data-align report where it actually landed. Change both while the step is open: it is the same step throughout, which upstream could not do — there, changing any prop sent the step to the end of the tour.
The card is a fixed w-80 popover surface; widen it with className on the step. A single-step tour's Next reads “Finish”, because its only step is also its last.
Opening, finishing, skipping
- Opening restarts at the first step only after a finished tour. Closed half-way, an uncontrolled tour resumes where it stopped — control
valueand set it to 0 if “Start tour” should always mean the beginning, as the demos here do. onCompleteis the Finish button;onSkipis every other close — Skip, ×, Escape, a click outside — on any step, the last included. Record “has seen the tour” in both, or once inonOpenChange, which sees every close, as the code below does. Fixed in3.0.1— in3.0.0a close on the last step fired neither, soonOpenChangewas the only place that saw it.- Open a tour on its own only on a first visit, and decide after mount — in a Comitor app, keep the flag on the user's profile so it follows them to another device.
'use client'
import { useEffect, useState } from 'react'
import { Tour } from '@comitor/ui'
const SEEN = 'tasks-tour-v1'
export function TasksTour({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
// Decide after mount: there is no localStorage on the server, and deciding during
// render would flash the tour for a frame at people who have already seen it.
useEffect(() => {
if (localStorage.getItem(SEEN) === null) setOpen(true)
}, [])
return (
<Tour
open={open}
onOpenChange={(next) => {
setOpen(next)
// Any close counts: Finish, Skip, ×, Escape, a click outside — one place
// instead of both onComplete and onSkip.
if (!next) localStorage.setItem(SEEN, new Date().toISOString())
}}
>
{children}
</Tour>
)
}Dismissal, Escape and scrolling
dismissible(default on): a pointer-down or focus outside both the card and the target closes the tour.onPointerDownOutsideandonInteractOutsiderun first;preventDefault()in either keeps it open.- Escape always closes, whatever
dismissiblesays. To keep a tour open, callpreventDefault()inonEscapeKeyDown. modal(default on) locks the page's scroll, and that is all it does — the focus trap and the spotlight do not depend on it.autoScroll: when the step changes and the next target sits outside the viewport (inset byscrollOffset, 100px top and bottom), the tour scrolls the target's nearest scrollable ancestor —AppShell's<main>, for instance — until the target sitsscrollOffset.topbelow the top of the viewport. Smooth, or instant under reduced motion.
What the port guarantees
Most of these only show up when you use the tour, not when you look at it.
- The card is a named dialog.
role="dialog", labelled byTourTitleand described byTourDescription. Upstream's card was a bare<div>: the focus trap pulled people into it and a screen reader said only “Close tour, button”. There is deliberately noaria-modal— the target being explained sits outside the card and has to stay readable. - Steps keep their place when a prop changes. Upstream removed and re-added a step whenever any of its props changed — an inline
onStepEnter={() => …}changes on every render — which appended it to the end; at the old index nothing matched and the tour went blank. Steps now register once and update in place. - Every close is reported, and step callbacks reach the right step. Upstream called
onSkiponly for a close before the last step, so a tour closed on its last step reported neitheronSkipnoronComplete— and an app that records “seen” in those two showed the tour again next time. It also ranonStepLeaveon the step being entered. Both fixed in3.0.1. - Auto-scroll works inside a scroll container. Upstream only called
window.scrollTo, and inAppShellthe window never scrolls — the frame ish-dvh overflow-hiddenand content scrolls in<main>— so targets below the fold were silently skipped. The spotlight follows such scrolling too: it listens in the capture phase, since an element's scroll event never bubbles to the window. - The focus trap exists only while a card shows. Upstream mounted its two invisible focus guards from every step unconditionally, so a tour sitting closed in the layout — the normal onboarding setup — added two invisible Tab stops to the start and end of
<body>for the life of the app. The trap also skips disabled buttons; upstream could aim at the disabled Previous on the first step, fail silently, and count the next Tab as an outside interaction — closing the tour on keyboard users. - One Escape closes one layer. The tour listens in the capture phase and marks the key as used, as Radix's dismissable layers do; upstream's bubbling listener let the same Escape close an open Action Bar too — wiping the user's row selection. An Escape pressed mid-composition (Telex, VNI) or already used by a layer above is ignored.
- Visible labels are the names.
TourPrev,TourNextandTourSkipdropped upstream's hard-codedaria-label, which made Next read “Next step” while showing “Finish” (WCAG 2.5.3). An icon-only button of your own needs its own label. - Package surfaces. The card is the
PopoverContentsurface; the spotlight is the Dialog overlay colour without its blur, so the context around the target stays legible; the × is Dialog's close button with a ~24px hit area instead of 16px (WCAG 2.5.8); the counter never wraps (“Bước 1/3” broke onto two lines beside the buttons, measured in a browser); and nothing readswindowduring render, so it is safe to server-render.
Language
Six strings live in the tour namespace and follow the nearest ComitorLocaleProvider — English when there is none. The step text is yours. Switch the language, then start the tour: counter, Skip, Previous, Next / Finish and the × name all follow.
| Key | English | Tiếng Việt | Where |
|---|---|---|---|
close | Close tour | Đóng hướng dẫn | TourClose — its only name |
prev | Previous | Quay lại | TourPrev |
next | Next | Tiếp theo | TourNext, every step but the last |
finish | Finish | Hoàn tất | TourNext, last step |
skip | Skip | Bỏ qua | TourSkip |
stepCounter(2, 4) | Step 2 of 4 | Bước 2/4 | TourStepCounter — a function: the two languages do not even share a shape |
- Precedence, per key: a part's own override (children,
aria-labelonTourClose,formatonTourStepCounter) →labelson theTour→ the provider → English. DEFAULT_TOUR_LABELSis the English table since3.0.0and the component no longer reads it; the Vietnamese table iscomitorMessagesVi.tour.stepCounter(current, total)is a function because the languages disagree on the whole shape — “Step 2 of 4” against “Bước 2/4”. Functions cannot cross from a Server Component, so declare custom labels in a client component.- The cards are portalled to the end of
<body>, outside anylangattribute you wrap around the tour, so the demo passeslangto each step. In an app,<html lang>from the root layout covers them. Setting the language once for the whole app: Get started → Set the language.
import { comitorMessagesVi, DEFAULT_TOUR_LABELS, Tour, TourClose, TourNext, TourStepCounter, type TourLabels } from '@comitor/ui'
DEFAULT_TOUR_LABELS // the ENGLISH table since 3.0.0 — the component reads the provider, not this
comitorMessagesVi.tour // the Vietnamese one
// A whole tour, a key or two. stepCounter is a function, and functions cannot cross
// from a Server Component — declare this in a client component.
const LABELS: Partial<TourLabels> = {
finish: 'Got it',
stepCounter: (current, total) => `${current} / ${total}`,
}
<Tour labels={LABELS}>…</Tour>
// Or one spot — each part also takes a local override, which beats labels:
<TourClose aria-label="Dismiss the guide" />
<TourStepCounter format={(current, total) => `${current} of ${total}`} />
<TourNext>Continue</TourNext> // ⚠ children replace BOTH "Next" and "Finish"Tour props
On top of every <div> prop, and asChild.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
open | boolean | — | Whether the tour is showing. Pair with onOpenChange to own it. |
defaultOpen | boolean | false | Open on mount when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Every open and every close — Finish, Skip, ×, Escape, a click or focus outside. The one callback that sees all of them. |
value | number | — | The current step, from 0. Controlling it turns autoScroll off (see Known limitations). |
defaultValue | number | 0 | The step to start on when uncontrolled. |
onValueChange | (step: number) => void | — | The new step index. Controlled, it also receives steps.length when Finish is pressed, just before the tour closes. |
onComplete | () => void | — | Next was pressed on the last step (the Finish button). Fires before the tour closes. |
onSkip | () => void | — | The tour closed without Finish — by Skip, ×, Escape or a click outside, on any step. Since 3.0.1 that includes the last step; in 3.0.0 a close there fired neither this nor onComplete. |
stepFooter | ReactElement | — | A footer rendered into every step that has no TourFooter of its own. |
dismissible | boolean | true | A pointer-down or focus outside both the card and the target closes the tour. Escape is not affected — see onEscapeKeyDown. |
modal | boolean | true | Locks page scroll (body overflow hidden, scrollbar width compensated). That is all it does: the focus trap is on either way. |
autoScroll | boolean | true | On a step change, scrolls the target's nearest scrollable ancestor (or the window) when the target is outside the viewport inset by scrollOffset. Uncontrolled value only; never on open. |
scrollBehavior | ScrollBehavior | 'smooth' | 'auto' instead when the user asks for reduced motion — read at scroll time, not at render. |
scrollOffset | { top?, bottom?, left?, right? } | { top: 100, bottom: 100 } | How far inside the viewport the target must sit to count as visible, and where it lands (top). |
sideOffset | number | 16 | Gap between target and card, for every step that does not set its own. |
alignOffset | number | 0 | Shift along the aligned edge, for every step. |
spotlightPadding | number | 4 | Space between the target and the edge of the spotlight hole, in px. |
onEscapeKeyDown | (event: KeyboardEvent) => void | — | Runs before Escape closes the tour; event.preventDefault() keeps it open. |
onPointerDownOutside | (event: CustomEvent) => void | — | A pointer-down outside card and target; preventDefault() keeps the tour open. |
onInteractOutside | (event: CustomEvent) => void | — | Pointer or focus outside card and target; preventDefault() keeps the tour open. |
onOpenAutoFocus | (event: CustomEvent) => void | — | Each time a step appears, before focus moves into it. preventDefault() and focus something yourself — the first demo focuses Next. |
onCloseAutoFocus | (event: CustomEvent) => void | — | After close, before focus returns to where it was. preventDefault() to put it elsewhere. |
labels | Partial<TourLabels> | from the provider | Per-key override of the tour namespace. Beats ComitorLocaleProvider; missing keys still come from it. |
dir | 'ltr' | 'rtl' | from DirectionProvider | Reading direction for every part. |
TourStep props
On top of every <div> prop — className, lang and the rest land on the card.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
targetbắt buộc | string | RefObject<HTMLElement | null> | HTMLElement | — | What the step points at: a selector (document.querySelector, first match on the page), a ref, or an element. Looked up after every render of the current step. |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred side. |
align | 'start' | 'center' | 'end' | 'center' | Alignment along that side. |
sideOffset / alignOffset | number | the Tour's | Per-step versions of the Tour props. |
avoidCollisions | boolean | true | Flip to the opposite side and shift along the edge to stay in view. data-side / data-align on the card report where it landed. |
collisionBoundary | Element | null | (Element | null)[] | the viewport | Boundaries to stay inside instead of the viewport. |
collisionPadding | number | Partial<Record<Side, number>> | 0 | Distance to keep from the boundary. |
arrowPadding | number | 0 | Keeps the arrow away from the card's corners. |
sticky | 'partial' | 'always' | 'partial' | 'partial' stops shifting once the card would leave the target; 'always' keeps it in view. |
hideWhenDetached | boolean | false | Hide the card while its target is scrolled out of view. |
forceMount | boolean | false | Render the card even when the target cannot be found — it then has nothing to anchor to. |
onStepEnter / onStepLeave | () => void | — | Called on step changes: onStepLeave on the step being left, then onStepEnter on the one entered — A → B → C runs leave A, enter B, leave B, enter C. Finish runs the last step's onStepLeave, then onComplete. Opening a tour at its current step runs no onStepEnter, and closing runs no onStepLeave. Fixed in 3.0.1 — in 3.0.0 onStepLeave ran on the step being entered. |
required | boolean | false | Stored with the step and read by nothing in this version: it has no effect. |
The other parts
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
TourPortal | container?: HTMLElement | null | document.body | Where the spotlight, ring and cards render. Renders nothing on the server. |
TourSpotlight | forceMount?: boolean | false | A fixed full-screen layer in the Dialog overlay colour (ink at 60%, 80% in dark) with a clip-path hole around the current target. |
TourSpotlightRing | forceMount?: boolean | false | A 3px ring in --ring at 50% around the hole, pointer-events none. For a border, pass both width and colour: border-2 border-primary-ink. |
TourArrow | width?: number · height?: number | 10 · 5 | Points at the target from the placed side; hidden when it cannot be centred. aria-hidden. |
TourClose | button props · asChild | — | Icon-only ×, named by labels.close — an aria-label you pass wins. Children replace the icon. Closes the tour with onSkip — on the last step too, since 3.0.1. |
TourPrev / TourNext / TourSkip | Button props | — | Package Buttons (Prev and Skip outline). Children replace the label — on TourNext for Next and Finish alike. TourPrev computes disabled (first step) and ignores yours. onClick runs first; preventDefault() cancels. |
TourStepCounter | format?: (current, total) => string · asChild | labels.stepCounter | Plain text, counting from 1, never wraps. Children replace it. |
TourHeader / TourTitle / TourDescription / TourFooter | div props · asChild | — | Layout parts. TourTitle and TourDescription carry the ids the card's aria-labelledby / aria-describedby point at. |
Known limitations
A missing target leaves only the overlay
A step whose target is not in the DOM — a control behind a permission, a collapsed sidebar — renders no card, but the spotlight keeps dimming the page. With no card on screen there is nothing to click and no outside-click to catch: only Escape closes the tour.
// A step whose target is not in the DOM renders nothing — but the spotlight still
// dims the page, and with no card on screen only Escape closes the tour.
// Mount steps only for targets this user actually has:
<TourPortal>
<TourSpotlight />
<TourStep target={searchRef}>…</TourStep>
{canInvite && <TourStep target={inviteRef}>…</TourStep>}
</TourPortal>Scrolling happens on step changes only
autoScroll runs when the step changes and value is uncontrolled. It never runs on open — a first target below the fold leaves its card off screen too, with the page's scroll locked — and controlling value switches it off, because the controlled path returns before it gets there.
// Controlling value turns autoScroll off — bring each target into view yourself:
<Tour
value={step}
onValueChange={(next) => {
setStep(next)
targets[next]?.current?.scrollIntoView({ block: 'center' })
}}
>…</Tour>
// Opening never scrolls, controlled or not. If the first target may be below the
// fold, bring it up before you open — scrolling works even under the scroll lock:
searchRef.current?.scrollIntoView({ block: 'center' })
setOpen(true)requireddoes nothing. It is accepted and stored with the step, and nothing reads it.
Accessibility
| Keys | While a card shows |
|---|---|
| Tab · Shift + Tab | Cycle through the card's controls. Focus cannot leave the card while it shows. |
| Enter · Space | Press the focused button. |
| Escape | Close the tour, with onSkip — on any step, the last one too since 3.0.1. dismissible does not change this. |
- Each card is
role="dialog"witharia-labelledby→TourTitleandaria-describedby→TourDescription. Moving to the next step moves focus into a new dialog, so a screen reader announces the new title and description — that, not the counter, is how the change is announced. The counter is plain text, not a live region. - Closing returns focus to whatever had it before the tour opened — usually the button that started it — if that element is still on the page.
onCloseAutoFocuswithpreventDefault()takes over. The tour runs its own focus handling, not Dialog’s, so it does not resolve a menu item to its menu’s trigger button: started from a Help menu item, it has nothing to return to — the item unmounted with the menu — and focus falls to<body>. Focus the menu’s trigger inonCloseAutoFocusthere. TourCloseis icon-only, solabels.close(“Close tour”) is its only name; the icon isaria-hidden. So areTourArrowand the spotlight's decoration.- Give every step a title and a description — they are the dialog's name and description, and without them a screen reader lands in an unnamed dialog.
- Make sure the target exists and is on screen when its step starts, and offer a way to replay the tour (a Help menu item) instead of reopening it on every visit.
- Run one tour at a time: every open tour owns a document-level Escape listener and its own spotlight.
Related
- Web Application — the app shell, header and toolbar a first-run tour walks through.
- Action Bar — the bulk-action bar whose selection the capture-phase Escape protects.
- Popover and Dialog — the surface the card borrows and the overlay the spotlight matches.