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.

anatomy
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.

Tasks

open = false · value = 0 · last callback = —

  • Watch the read-out as it ends. Finish sets value to the number of steps (4) and calls onComplete before closing. Escape or × closes with onSkip on any step, the last one included — since 3.0.1; in 3.0.0 a close on the last step called neither, and only onOpenChange(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 onOpenAutoFocus puts 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).

targets
// 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 value and set it to 0 if “Start tour” should always mean the beginning, as the demos here do.
  • onComplete is the Finish button; onSkip is every other close — Skip, ×, Escape, a click outside — on any step, the last included. Record “has seen the tour” in both, or once in onOpenChange, which sees every close, as the code below does. Fixed in 3.0.1 — in 3.0.0 a close on the last step fired neither, so onOpenChange was 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.
tasks-tour.tsx
'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. onPointerDownOutside and onInteractOutside run first; preventDefault() in either keeps it open.
  • Escape always closes, whatever dismissible says. To keep a tour open, call preventDefault() in onEscapeKeyDown.
  • 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 by scrollOffset, 100px top and bottom), the tour scrolls the target's nearest scrollable ancestor — AppShell's <main>, for instance — until the target sits scrollOffset.top below 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 by TourTitle and described by TourDescription. 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 no aria-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 onSkip only for a close before the last step, so a tour closed on its last step reported neither onSkip nor onComplete — and an app that records “seen” in those two showed the tour again next time. It also ran onStepLeave on the step being entered. Both fixed in 3.0.1.
  • Auto-scroll works inside a scroll container. Upstream only called window.scrollTo, and in AppShell the window never scrolls — the frame is h-dvh overflow-hidden and 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, TourNext and TourSkip dropped upstream's hard-coded aria-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 PopoverContent surface; 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 reads window during 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.

Tasks
KeyEnglishTiếng ViệtWhere
closeClose tourĐóng hướng dẫnTourClose — its only name
prevPreviousQuay lạiTourPrev
nextNextTiếp theoTourNext, every step but the last
finishFinishHoàn tấtTourNext, last step
skipSkipBỏ quaTourSkip
stepCounter(2, 4)Step 2 of 4Bước 2/4TourStepCounter — a function: the two languages do not even share a shape
  • Precedence, per key: a part's own override (children, aria-label on TourClose, format on TourStepCounter) → labels on the Tour → the provider → English.
  • DEFAULT_TOUR_LABELS is the English table since 3.0.0 and the component no longer reads it; the Vietnamese table is comitorMessagesVi.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 any lang attribute you wrap around the tour, so the demo passes lang to 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.
labels
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.

PropTypeDefaultDescription
openboolean—Whether the tour is showing. Pair with onOpenChange to own it.
defaultOpenbooleanfalseOpen 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.
valuenumber—The current step, from 0. Controlling it turns autoScroll off (see Known limitations).
defaultValuenumber0The 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.
stepFooterReactElement—A footer rendered into every step that has no TourFooter of its own.
dismissiblebooleantrueA pointer-down or focus outside both the card and the target closes the tour. Escape is not affected — see onEscapeKeyDown.
modalbooleantrueLocks page scroll (body overflow hidden, scrollbar width compensated). That is all it does: the focus trap is on either way.
autoScrollbooleantrueOn 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.
scrollBehaviorScrollBehavior'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).
sideOffsetnumber16Gap between target and card, for every step that does not set its own.
alignOffsetnumber0Shift along the aligned edge, for every step.
spotlightPaddingnumber4Space 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.
labelsPartial<TourLabels>from the providerPer-key override of the tour namespace. Beats ComitorLocaleProvider; missing keys still come from it.
dir'ltr' | 'rtl'from DirectionProviderReading direction for every part.

TourStep props

On top of every <div> prop — className, lang and the rest land on the card.

PropTypeDefaultDescription
targetrequiredstring | 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 / alignOffsetnumberthe Tour'sPer-step versions of the Tour props.
avoidCollisionsbooleantrueFlip to the opposite side and shift along the edge to stay in view. data-side / data-align on the card report where it landed.
collisionBoundaryElement | null | (Element | null)[]the viewportBoundaries to stay inside instead of the viewport.
collisionPaddingnumber | Partial<Record<Side, number>>0Distance to keep from the boundary.
arrowPaddingnumber0Keeps 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.
hideWhenDetachedbooleanfalseHide the card while its target is scrolled out of view.
forceMountbooleanfalseRender 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.
requiredbooleanfalseStored with the step and read by nothing in this version: it has no effect.

The other parts

PropTypeDefaultDescription
TourPortalcontainer?: HTMLElement | nulldocument.bodyWhere the spotlight, ring and cards render. Renders nothing on the server.
TourSpotlightforceMount?: booleanfalseA fixed full-screen layer in the Dialog overlay colour (ink at 60%, 80% in dark) with a clip-path hole around the current target.
TourSpotlightRingforceMount?: booleanfalseA 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.
TourArrowwidth?: number · height?: number10 · 5Points at the target from the placed side; hidden when it cannot be centred. aria-hidden.
TourClosebutton 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 / TourSkipButton 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.
TourStepCounterformat?: (current, total) => string · asChildlabels.stepCounterPlain text, counting from 1, never wraps. Children replace it.
TourHeader / TourTitle / TourDescription / TourFooterdiv 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.

conditional steps
// 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.

scrolling yourself
// 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)
  • required does nothing. It is accepted and stored with the step, and nothing reads it.

Accessibility

KeysWhile a card shows
Tab · Shift + TabCycle through the card's controls. Focus cannot leave the card while it shows.
Enter · SpacePress the focused button.
EscapeClose 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" with aria-labelledby → TourTitle and aria-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. onCloseAutoFocus with preventDefault() 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 in onCloseAutoFocus there.
  • TourClose is icon-only, so labels.close (“Close tour”) is its only name; the icon is aria-hidden. So are TourArrow and 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.