OnboardingTour

A guided tour that highlights one element per step with a card beside it, and remembers that it is done.

Installation

pnpm dlx shadcn@latest add @chelone/onboarding-tour
Loading preview…

<OnboardingTour> walks a user through the admin one element at a time. A dark scrim covers the page with a cut-out around the step's target, and a card beside the target holds the title, the text and the Back, Next and Skip buttons. When the user finishes or skips, the tour stores that in the store and does not open again.

Mounting it

Mount it once, anywhere under <Admin>. It renders into document.body, so where it sits in the tree does not matter. A target is any element a CSS selector finds, so the elements you point at need an id or a data- attribute to select.

import { Admin, Layout, OnboardingTour, Resource } from "@chelone/react";
 
const AppLayout = ({ children }) => (
  <>
    <OnboardingTour
      id="welcome"
      steps={[
        {
          target: "#new-post",
          title: "Create a post",
          content: "Start here to write your first post.",
        },
        {
          target: "[data-tour='search']",
          content: "Find a post by its title.",
          placement: "bottom",
        },
      ]}
      onComplete={(reason) => console.log(reason)}
    />
    <Layout>{children}</Layout>
  </>
);
 
<Admin dataProvider={dataProvider}>
  <Admin.Layout>
    <AppLayout />
  </Admin.Layout>
  <Resource name="posts" list={PostList} />
</Admin>;

In Vue the tour goes in the #layout slot. It emits complete where React calls onComplete.

<script setup lang="ts">
import { Admin, Layout, OnboardingTour, Resource } from "@chelone/vue";
 
const steps = [
  {
    target: "#new-post",
    title: "Create a post",
    content: "Start here to write your first post.",
  },
  {
    target: "[data-tour='search']",
    content: "Find a post by its title.",
    placement: "bottom",
  },
];
</script>
 
<template>
  <Admin :data-provider="dataProvider">
    <Resource name="posts" :list="PostList" />
 
    <template #layout>
      <OnboardingTour id="welcome" :steps="steps" @complete="console.log($event)" />
      <Layout>
        <router-view />
      </Layout>
    </template>
  </Admin>
</template>

complete and onComplete get "finished" when the user clicks Finish on the last step, and "skipped" for Skip, the Escape key or a click on the scrim.

Steps

A step has these fields:

FieldTypeMeaning
targetstringCSS selector of the element to highlight.
titleReactNode in React, string in VueA heading for the card. The card is named after it.
contentReactNode in React, string in VueThe text of the card.
placement"auto", "top", "bottom", "left" or "right"Overrides the tour's placement for this step.

placement on the tour defaults to "auto", which picks the side with the most room. The card moves to stay inside the viewport.

In Vue a #step slot replaces the title and the content. It is given { step, index }, and the counter and the buttons stay.

<OnboardingTour id="welcome" :steps="steps">
  <template #step="{ step, index }">
    <p>Step {{ index + 1 }}: {{ step.content }}</p>
  </template>
</OnboardingTour>

React has no such slot: title and content take any node.

A missing target

A step whose element is not on the page still shows. Its card sits in the centre of the screen and the scrim has no cut-out. An invalid selector counts as a missing target and does not throw. The tour looks the target up again every 250 ms, so an element that appears late gets its highlight.

A present target is scrolled to the centre of the viewport.

Keyboard and focus

  • The card is a modal dialog. Focus moves to its Next button, and Tab stays inside the card.
  • Escape skips the tour.
  • When the tour closes, focus returns to the element that had it before.

Restarting

The tour stores true under onboardingTourKey(id), which is onboarding-tour.<id>.completed. Setting the key to anything else opens the tour again while it is mounted, so a "Show the tour again" button writes false with useStore.

import { onboardingTourKey } from "@chelone/core";
import { useStore } from "@chelone/react";
 
const RestartTour = () => {
  const [, setDone] = useStore(onboardingTourKey("welcome"), false);
  return <button onClick={() => setDone(false)}>Show the tour again</button>;
};

In Vue:

<script setup lang="ts">
import { onboardingTourKey } from "@chelone/core";
import { useStore } from "@chelone/vue";
 
const [, setDone] = useStore(onboardingTourKey("welcome"), false);
</script>
 
<template>
  <button @click="setDone(false)">Show the tour again</button>
</template>

autoStart={false} stops the tour opening by itself, on mount and when its key is cleared. A tour with no steps never opens.

Starting it on demand

start() opens the tour on its first step whatever autoStart and the stored flag say, so it opens a tour with autoStart={false} and replays one that is stored as done. Calling it while the tour is open restarts it from step one. Finishing the replay stores the flag again.

In React, pass a ref typed OnboardingTourHandle:

import { OnboardingTour, type OnboardingTourHandle } from "@chelone/react";
import { useRef } from "react";
 
const AppLayout = ({ children }) => {
  const tour = useRef<OnboardingTourHandle>(null);
  return (
    <>
      <OnboardingTour ref={tour} id="welcome" steps={steps} autoStart={false} />
      <button onClick={() => tour.current?.start()}>Take the tour</button>
      <Layout>{children}</Layout>
    </>
  );
};

In Vue, use a template ref:

<script setup lang="ts">
import { OnboardingTour } from "@chelone/vue";
import { useTemplateRef } from "vue";
 
const tour = useTemplateRef("tour");
</script>
 
<template>
  <OnboardingTour ref="tour" id="welcome" :steps="steps" :auto-start="false" />
  <button @click="tour?.start()">Take the tour</button>
</template>

The ref points at the mounted tour, so the button and the tour need a common parent that holds it.

A different id is a different tour with its own stored flag. Changing id, autoStart or the number of steps starts the tour afresh.

Translation

Each button goes through translate with an English default, so i18n messages override it.

KeyEnglish default
chelone.onboarding_tour.skipSkip
chelone.onboarding_tour.backBack
chelone.onboarding_tour.nextNext
chelone.onboarding_tour.finishFinish

Props

PropTypeDefaultDescription
autoStartboolean—Open on mount while the tour is not stored as done. Default `true`.
idstring—Names the tour in the store: finishing or skipping it stores `onboarding-tour.<id>.completed`.
onComplete(reason: TourEnd) => void—
placementTourPlacement—Where the card sits relative to its target. Default `"auto"`.
stepsTourStep[]—

The table shows the React props. Vue emits complete where React takes onComplete, and a Vue title and content are strings. The other differences are on the Differences from React page.