Tour

A walkthrough that introduces a screen one control at a time.

A walkthrough that introduces a screen one control at a time. It dims everything, cuts a hole around one control and puts a card beside it, then moves the hole to the next control.

The hole is the point. A caption on its own has to describe where to look, and “the button at the top right” is a sentence people read twice and still get wrong.

Installation

Tour ships with the library — no separate install.

import { Tour, Avatar, Button, Card, Text, BookmarkIcon, PlusIcon } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add tour

Usage

<Tour open={onboarding} onOpenChange={setOnboarding}>
  <Tour.Step
    order={0}
    title="Your conversations"
    description="Everything waiting for a reply lands in this list."
  >
    <Card>{/* … */}</Card>
  </Tour.Step>

  <Tour.Step
    order={1}
    title="Start something new"
    description="A message to anyone, from anywhere in the app."
  >
    <Button>New message</Button>
  </Tour.Step>
</Tour>

Composition

<Tour>
  <Tour.Step order={0} title="…" description="…">
    {/* the control this step is about */}
  </Tour.Step>
</Tour>

A step stays mounted whether the tour is running or not — it is a description of a control that is already on the screen, not something that appears with the walkthrough. Its wrapper is a plain view with no sizing of its own, so put layout classes on the step rather than on the control inside it.

Examples

A walkthrough

The ordinary case, and the one to start from. Steps wrap the controls they are about wherever those live — a header button, a card in a list, an action at the bottom — and order puts them in the sequence the screen should be read in rather than the one the tree happens to mount them in.

Nothing is placed by hand. The hole travels to each target and the card settles above or below it depending on which side has room.

const [running, setRunning] = useState(false);

<Tour open={running} onOpenChange={setRunning}>
  <View className="flex-row items-center justify-between">
    <Text weight="semibold">Inbox</Text>
    <Tour.Step
      order={1}
      title="Filter what you see"
      description="Unread, flagged, or everything at once."
      shape="circle"
    >
      <Button variant="ghost" size="icon" accessibilityLabel="Filter">
        <SearchIcon size={20} />
      </Button>
    </Tour.Step>
  </View>

  <Tour.Step
    order={0}
    title="Your conversations"
    description="Everything waiting for a reply lands in this list."
    radius={16}
  >
    <Card>{/* … */}</Card>
  </Tour.Step>

  <Tour.Step
    order={2}
    title="Start something new"
    description="A message to anyone, from anywhere in the app."
  >
    <Button onPress={compose}>New message</Button>
  </Tour.Step>
</Tour>

Round targets

shape="circle" squares the hole around the target's centre, which is what keeps it round rather than letting it collapse into a slot. Set it on the tour for a screen of round controls, or on the one step that needs it.

<Tour open={running} onOpenChange={setRunning} shape="circle" padding={10}>
  <Tour.Step order={0} title="You" description="Your profile, and everything under it.">
    <Avatar fallback="KA" />
  </Tour.Step>
  <Tour.Step order={1} title="What you saved" description="Anything you bookmark shows up here.">
    <Button variant="secondary" size="icon" accessibilityLabel="Saved">
      <BookmarkIcon size={20} />
    </Button>
  </Tour.Step>
</Tour>

A step that asks you to try it

interactive leaves the spotlit control pressable, so the walkthrough can ask you to use the thing rather than read about it. Advancing stays the app's call — the target keeps its own onPress.

const [step, setStep] = useState(0);

<Tour open={running} onOpenChange={setRunning} step={step} onStepChange={setStep} interactive>
  <Tour.Step order={0} title="Press it" description="Go on — the button still works under the dim." shape="circle">
    <Button
      size="icon"
      accessibilityLabel="Add one"
      onPress={() => {
        setCount((current) => current + 1);
        if (step === 0) setStep(1);
      }}
    >
      <PlusIcon size={20} />
    </Button>
  </Tour.Step>

  <Tour.Step order={1} title="And there it is" description="The count went up by one.">
    <Text size="lg" weight="semibold">{count}</Text>
  </Tour.Step>
</Tour>

A target inside a scroller

The tour measures on the frame after onStepChange, so a scroll issued there lands first and the spotlight arrives where the target does. Have each step record where it sits during layout rather than writing the offsets down — a hand-written 320 is right until somebody adds a paragraph above it. See Across a scroll for the whole screen, and for why the scroll is not animated.

const scroller = useRef<ScrollView>(null);
const offsets = useRef<Record<number, number>>({});

<ScrollView ref={scroller}>
  <Tour
    open={running}
    onOpenChange={setRunning}
    onStepChange={(order) => {
      const y = offsets.current[order];
      if (y !== undefined) scroller.current?.scrollTo({ y, animated: false });
    }}
  >
    <Tour.Step
      order={0}
      onLayout={(event) => {
        offsets.current[0] = event.nativeEvent.layout.y;
      }}
      title="Everyone in here"
      description="Who has access, and what they can reach."
    >
      {/* the control this step is about */}
    </Tour.Step>

    {/* …the rest of the screen, and the steps further down it… */}
  </Tour>
</ScrollView>

Its own words

The card's controls read in English by default. labels replaces any of them, and showSkip and showProgress take them away.

<Tour
  open={running}
  onOpenChange={setRunning}
  showProgress={false}
  labels={{ next: 'Siguiente', back: 'Atrás', done: 'Listo', skip: 'Omitir', close: 'Cerrar' }}
>
  {/* …steps… */}
</Tour>

Versions

Across a scroll

A walkthrough of a screen taller than the screen. A target that has scrolled out of view has no rect worth measuring, so the step brings it back first: every step records where it sits during layout, and onStepChange — which fires with the step about to be shown — scrolls there before the spotlight goes looking.

The scroll is not animated, and that is deliberate. The overlay measures its target a frame after the step changes, which catches a jump and is far too early to catch a three-hundred-millisecond glide; the hole would settle over whatever the content was passing through at the time. Under a dimmed screen the jump is not what the eye is following anyway — the spotlight travelling to the new target is.

Keep the steps as direct children of the scroller. The y a step reports at layout is its offset within its parent, which is the content container only while nothing sits in between.

const scroller = useRef<ScrollView>(null);
const offsets = useRef<Record<number, number>>({});

<Tour
  open={running}
  onOpenChange={setRunning}
  onStepChange={(order) => {
    const y = offsets.current[order];
    if (y === undefined) return;
    // Short of the target, so it lands with some of its screen around it.
    scroller.current?.scrollTo({ y: Math.max(0, y - 96), animated: false });
  }}
>
  <ScrollView ref={scroller}>
    <Tour.Step
      order={0}
      onLayout={(event) => {
        offsets.current[0] = event.nativeEvent.layout.y;
      }}
      title="Everyone in here"
      description="Who has access, and what they can reach."
    >
      <Card>{/* … */}</Card>
    </Tour.Step>

    {/* …more of the screen, and the steps further down it… */}
  </ScrollView>
</Tour>

API Reference

Tour

PropTypeDefaultDescription
openbooleanWhether the walkthrough is running.
defaultOpenbooleanfalseWhether it is running when uncontrolled.
onOpenChange(open: boolean) => void
stepnumberThe current step's order, controlled. Note that this is the author's numbering and not a position in the sequence — the two differ as soon as a step is conditional.
defaultStepnumberWhere an uncontrolled tour starts. Defaults to the lowest order.
onStepChange(step: number) => voidFires with the order about to be shown, before it is. This is where a target inside a scroller is brought back into view: the step is measured on the next frame, so a scrollTo issued here lands first.
onFinish() => voidThe last step was acknowledged.
onSkip() => voidThe tour was ended early — the skip control, the backdrop, or Android back.
paddingnumberDEFAULT_PADDINGRoom left around every target, in pixels. 8 by default. A step may override it.
radiusnumberDEFAULT_RADIUSCorner radius of a rectangular cutout, in pixels. 12 by default. A step may override it.
shapeTourShape'rect'Shape of every cutout. A step may override it.
placementTourPlacement'auto'Which side of the target the card prefers. auto puts it below when below fits and above when it does not, which is the only behaviour that survives a target near an edge.
dismissiblebooleantrueEnding the tour by pressing the dimmed area, or Android back. Default true.
showProgressbooleantrueShow "2 of 5" above the step's title. Default true.
showSkipbooleantrueShow the skip control. Default true.
interactivebooleanfalseLeave the spotlit control pressable. Off by default: a tour is usually read rather than used, and a control that reacts under the dim invites people to start doing the thing before they have been told what it does. Turn it on for the walkthrough that asks you to try the step — the target keeps its own onPress, so advancing the tour from it is the app's call.
overlayColorstringDEFAULT_OVERLAYThe dim laid over everything outside the cutout. Black at 66% by default — dark enough that the hole reads as the only lit thing, light enough that the screen behind it is still recognisable as the screen you were on.
labelsTourLabelsThe words on the card's controls.
cardClassNamestringExtra classes for the card.

Tour.Step

PropTypeDefaultDescription
ordernumberWhere this step falls in the walkthrough. The author's numbering rather than the tree's, and unique within a tour — two steps sharing an order means one of them replaces the other.
titlestringThe step's heading.
descriptionstringThe sentence under it.
shapeTourShapeShape of this step's cutout, overriding the tour's.
paddingnumberRoom around this target, overriding the tour's.
radiusnumberCorner radius of this cutout, overriding the tour's.
placementTourPlacementWhich side of this target the card prefers, overriding the tour's.
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

order is the author's numbering of the walkthrough, not the tree's — a tour usually crosses a header, a list and a tab bar in an order the layout knows nothing about. It is also what the controlled step prop refers to, so the two stay in the same units. Steps sort themselves by it.

Targets are measured in window coordinates each time their step becomes current, and again when the window changes size, so a rotation mid-tour re-places the spotlight rather than stranding it. The one case this cannot fix by itself is a target that has scrolled out of view: scroll it back in onStepChange, which fires with the step about to be shown and lands before the measurement.

A step whose target cannot be measured — one whose control has gone — gets no hole and a card in the middle of the screen. Dimming everything and saying nothing about where to look is honest; cutting a hole at the origin is not.

Android's back button ends a dismissible tour rather than navigating away from it, and the whole overlay respects reduced motion: the spotlight jumps between targets instead of travelling.

On this page