Mantine Onboarding Tour

Logo

@gfazioli/mantine-onboarding-tour

A Mantine component enables you to create a onboarding-tour effect using overlays, popovers, and onboarding tours, which enhances element visibility and interactivity.

Migrating from v2 to v3

Breaking changes

1. onOnboardingTourClose has been removed

Replace it with onOnboardingTourComplete, onOnboardingTourSkip, or onOnboardingTourEnd:

// v2
<OnboardingTour onOnboardingTourClose={() => setStarted(false)} />

// v3
<OnboardingTour
  onOnboardingTourComplete={() => markAsCompleted()}
  onOnboardingTourSkip={() => markAsSkipped()}
  onOnboardingTourEnd={() => setStarted(false)}
/>

If you don't need to distinguish between completion and skip, use onOnboardingTourEnd alone — it fires in both cases.

2. responsive, mobileBreakpoint, and mobilePosition have been removed

The popover is now always responsive. Use responsive objects on popoverProps instead:

// v2
<OnboardingTour responsive mobileBreakpoint="sm" mobilePosition="bottom" />

// v3 — responsive by default, no props needed
// Default position: { base: 'bottom', sm: 'left' }
// To customize:
<OnboardingTour
  focusRevealProps={{
    popoverProps: {
      position: { base: 'bottom', sm: 'right' },
    },
  }}
/>

Per-step responsive positioning via focusRevealProps:

const steps: OnboardingTourStep[] = [
  {
    id: 'step1',
    title: 'Welcome',
    content: 'Hello!',
    focusRevealProps: {
      popoverProps: {
        position: { base: 'top', md: 'right' },
        offset: { base: 8, md: -4 },
      },
    },
  },
];

3. useOnboardingTour hook has been removed from the public API

This hook was internal and created isolated state disconnected from the <OnboardingTour> component. All tour control is available through props and render functions:

// v2
import { useOnboardingTour } from '@gfazioli/mantine-onboarding-tour';

// v3 — remove the import, use component props instead
<OnboardingTour
  content={(controller) => (
    <div>Step {controller.currentStepIndex}</div>
  )}
/>;

4. OnboardingTourStep now requires a generic for custom properties

// v2
const steps: OnboardingTourStep[] = [
  { id: 'step1', title: 'Hello', price: 9.99 },
];

// v3
const steps: OnboardingTourStep<{ price: number }>[] = [
  { id: 'step1', title: 'Hello', price: 9.99 },
];

5. popoverProps type changed to ResponsivePopoverProps

The position, offset, width, and arrowSize properties now accept ResponsiveProp<T> (a scalar or a breakpoint-to-value object). All other PopoverProps work as before.

Installation

yarn add @gfazioli/mantine-onboarding-tour

After installation import package styles at the root of your application:

import '@gfazioli/mantine-onboarding-tour/styles.css';

You can import styles within a layer @layer mantine-onboarding-tour by importing @gfazioli/mantine-onboarding-tour/styles.layer.css file.

import '@gfazioli/mantine-onboarding-tour/styles.layer.css';

Example

Here is a full page example of an Onboarding Tour.

Open Onboarding Tour example page

Usage

The OnboardingTour component allows to create onboarding experiences for your users.

A simple example of the Onboarding Tour component

👉 New amazing Mantine extension component

John Doe

Scroll the page 👆up or 👇down to remove the focus from the card. In practice, make this component invisible so that the onBlur event will be triggered.

Jane Doe

Scroll the page 👆up or 👇down to remove the focus from the card. In practice, make this component invisible so that the onBlur event will be triggered.

Cutout padding
Cutout radius
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Code, Divider, Group, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
function Demo() {
const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'welcome',
      title: 'Welcome to the Onboarding Tour Component',
      content:
        'This is a demo of the Onboarding Tour component, which allows to create onboarding experiences for your users.',
    },
    {
      id: 'subtitle',
      title: 'Subtitle',
      content: (
        <Text>
          You can select any component by using the <Code>data-onboarding-tour-id</Code> attribute
        </Text>
      ),
    },
    {
      id: 'button-see-all',
      title: 'New Features',
      content: 'Now you can click on the button "See all" to display all the testimonials',
    },
    {
      id: 'testimonial-2',
      title: 'New Testimonial Layout',
      content: 'We have improved the Testimonial layout',
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      maw={400}
      header={(tourController: OnboardingTourController) => (
        <Image
          mah={150}
          radius="md"
          src={`https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-${tourController.currentStepIndex + 1}.png`}
        />
      )}

    >
      <Stack justify="center" align="center">
        <Title data-onboarding-tour-id="welcome" order={4}>
          A simple example of the Onboarding Tour component
        </Title>
        <Text data-onboarding-tour-id="subtitle">👉 New amazing Mantine extension component</Text>

        <Center>
          <Button data-onboarding-tour-id="button-see-all">
            See all testimonials
          </Button>
        </Center>

        <Group justify="center">
          <Testimonials testimonial={0} />
          <Testimonials data-onboarding-tour-id="testimonial-2" testimonial={1} />
        </Group>
      </Stack>
    </OnboardingTour>
  );
}

OnboardingTourStep

The OnboardingTourStep interface defines the structure of each step in the tour.

export type OnboardingTourStep<
  T extends Record<string, unknown> = Record<string, unknown>,
> = {
  /** Unique id of the tour. Will be use for the data-onboarding-tour-id attribute */
  id: string;

  /** Header of the tour. You can also pass a React component here */
  header?:
    | React.ReactNode
    | ((
        tourController: OnboardingTourController<T>
      ) => React.ReactNode);

  /** Title of the tour. You can also pass a React component here */
  title?:
    | React.ReactNode
    | ((
        tourController: OnboardingTourController<T>
      ) => React.ReactNode);

  /** Custom Content of the tour. You can also pass a React component here */
  content?:
    | React.ReactNode
    | ((
        tourController: OnboardingTourController<T>
      ) => React.ReactNode);

  /** Footer of the tour. You can also pass a React component here */
  footer?:
    | React.ReactNode
    | ((
        tourController: OnboardingTourController<T>
      ) => React.ReactNode);

  /** Props passed to FocusReveal */
  focusRevealProps?:
    | OnboardingTourFocusRevealProps
    | ((
        tourController: OnboardingTourController<T>
      ) => OnboardingTourFocusRevealProps);

  /** Padding around the cutout highlight area for this step (px). Overrides tour-level cutoutPadding. */
  cutoutPadding?: number;

  /** Border radius of the cutout highlight area for this step (px). Overrides tour-level cutoutRadius. */
  cutoutRadius?: number;
} & T;

The type is generic: custom properties are type-safe via the T parameter (e.g., OnboardingTourStep<{ price: number }>). See the Custom entry section for an example.

Both header, title, content, and footer can be a string, a React component, or a function that receives tourController and returns a React component. You can use the OnboardingTourController to access the current step and its properties, such as currentStep.id, currentStep.title, and so on.

You may also set up the focusRevealProps to customize the OnboardingTour.FocusReveal component for each step. This allows you to control the focus and reveal behavior of the tour step. In this case, focusRevealProps can be either an object of type OnboardingTourFocusRevealProps or a function that receives tourController and returns an object of type OnboardingTourFocusRevealProps.

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step 1 Title',
      content: 'Content for step 1',
      focusRevealProps: {
        popoverProps: {
          position: 'top',
        },
      },
    },
    {
      id: 'step-2',
      title: 'Step 2 Title',
      content: 'Content for step 2',
      focusRevealProps: (tourController: OnboardingTourController) => {
        return {
          overlayProps: {
            color: '#f00',
          },
          popoverProps: {
            position: 'bottom',
          },
        };
      },
    },
    {
      id: 'step-3',
      title: 'Step 3 Title',
      content: 'Content for step 3',
      focusRevealProps: {
        popoverProps: {
          position: 'top',
          shadow: '0 0 16px 8px rgba(0, 0, 255, 1)',
        },
      },
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Title
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Description
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Content
          </Button>
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

OnboardingTourController

The OnboardingTourController interface provides the current state of the tour. It also provides a series of actions to interact with the tour, such as the action to go to the next step nextStep() or to end the tour endTour().

export type OnboardingTourController = Readonly<{
  /** List of tour steps */
  tour: OnboardingTourStep[];

  /** Current step */
  currentStep: OnboardingTourStep | undefined;

  /** Current step index of the tour. Zero-based index */
  currentStepIndex: number | undefined;

  /** ID of the selected tour */
  selectedStepId: string | undefined;

  /** Set the current index */
  setCurrentStepIndex: (index: number) => void;

  /** Start the tour */
  startTour: () => void;

  /** End the tour programmatically */
  endTour: () => void;

  /** Skip the tour (user dismissed) */
  skipTour: () => void;

  /** Go to the next tour */
  nextStep: () => void;

  /** Go to the previous tour */
  prevStep: () => void;

  /** Options of the tour */
  options: OnboardingTourOptions;
}>;

Tour Lifecycle Callbacks

The OnboardingTour component provides callbacks to distinguish between different tour endings:

CallbackWhen it fires
onOnboardingTourStartThe tour starts
onOnboardingTourChangeThe active step changes
onOnboardingTourComplete

The user finishes the last step (clicks "End")

onOnboardingTourSkipThe user clicks the "Skip" button
onOnboardingTourEnd

Always fires when the tour ends, whether completed or skipped

Use onOnboardingTourEnd if you don't need to distinguish between completion and skip. Use onOnboardingTourComplete and onOnboardingTourSkip when you need different behavior (e.g., saving progress, showing a different message).

<OnboardingTour
  tour={onboardingSteps}
  started={started}
  onOnboardingTourComplete={() => {
    // User finished all steps
    markTourAsCompleted();
  }}
  onOnboardingTourSkip={() => {
    // User dismissed the tour early
    markTourAsSkipped();
  }}
  onOnboardingTourEnd={() => {
    // Always called — close the tour UI
    close();
  }}
>
  {/* Your content */}
</OnboardingTour>

Define the onboarding tour

You can define your onboarding tour by using the OnboardingTourStep array.

const onboardingSteps: OnboardingTourStep[] = [
  {
    id: 'welcome',
    // Simple string
    title: 'Welcome to the Onboarding Tour Component',
    // Component
    content: <Text size="lg">Hello world!</Text>,
  },
  {
    id: 'subtitle',
    title: 'Another title',
    content: (tourController: OnboardingTourController) => (
      <Text size="lg">
        Hello world! {tourController.currentStep.id}
      </Text>
    ),
  },
];
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: (
        <Title order={4} c="lime">
          Title in a <Code>Title</Code> component
        </Title>
      ),
      content: (
        <Text c="red">
          Description in a <Code>Text</Code> component with color red
        </Text>
      ),
    },
    {
      id: 'step-2',
      title: 'Simple Title String',
      content: (tourController: OnboardingTourController) => (
        <Text>
          Description by using the function <Code>(tourController: OnboardingTourController)</Code> so we can get some more information such as the step: {tourController.currentStepIndex}
        </Text>
      ),
    },
    {
      id: 'step-3',
      content: <LoginForm />,
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Title
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Description
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Content
          </Button>
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

You may also handle the header, title, content, and footer by using the OnBoardingTour component props. In this case the OnboardingTourStep will be ignored.

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: 'Content of the Step 1',
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: 'Content of the Step 2',
      myCustomInfo: <Badge>Free trial available</Badge>,
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: 'Content of the Step 3',
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      title="The Title for all steps"
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Step 2
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Step 3
          </Button>
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

Anyway, the OnboardingTourStep are always available in the OnboardingTourController and you can use them to display any variant. Fo example,

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: 'Content of the Step 1',
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: 'Content of the Step 2',
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: 'Content of the Step 3',
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      focusRevealProps={{
        popoverProps: {
          position: 'top',
        },
      }}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      title={(tourController: OnboardingTourController) => (
        <Title c="blue" order={4}>
          {tourController.currentStep?.title as string}
        </Title>
      )}
      content={(tourController: OnboardingTourController) => (
        <Text c="lime" size="lg">
          {tourController.currentStep?.content as string}
        </Text>
      )}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Group>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Step 2
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Step 3
          </Button>
        </Group>
      </Stack>
    </OnboardingTour>
  );
}

Custom entry

You may use any custom entry in the OnboardingTourStep list to customize the tour. For example, here we're going to display some extra information in the footer, by using a custom property price:

const onboardingSteps: OnboardingTourStep[] = [
  {
    id: 'step-2',
    title: 'Step-2',
    description: 'Description of the Step 2',
    price: 12,
  },
];

Then we can use the footer prop to display the price:

<OnboardingTour
  footer={(onboardingTour: OnboardingTourController) => {
    if (onboardingTour.currentStep?.price) {
      return onboardingTour.currentStep?.price;
    }
    return null;
  }}
/>
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep<{ price?: number }>[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: 'Description of the Step 1',
      price: 12,
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: 'Description of the Step 2',
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: 'Description of the Step 3',
      price: 32,
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      footer={(onboardingTour: OnboardingTourController) => {
        if (onboardingTour.currentStep?.price) {
          return (
            <Center>
              <Group gap={4}>
                <Text size="xs">Price:</Text>
                <Badge color="orange">${onboardingTour.currentStep?.price}</Badge>
              </Group>
            </Center>
          );
        }
        return (
          <Group grow>
            <Badge color="green">Included in all plans</Badge>
          </Group>
        );
      }}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Step 2
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Step 3
          </Button>
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

Custom Popover Content

You may use the controller to override the default behavior of the tour or to add custom logic to the tour. For example, you could replace the default Popover content with a custom one:

import { OnboardingTour, type OnboardingTourStep } from '@gfazioli/mantine-onboarding-tour';
import { Button, Code, Divider, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

/** Your custom popover content */
import { customPopoverContent } from './customPopoverContent';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: 'Description of the Step 1',
      image: 'https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-1.png',
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: 'Description of the Step 2',
      freeTrialBadge: (
        <Center>
          <Badge color="lime">Free trial available</Badge>
        </Center>
      ),
      image: 'https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-2.png',
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: 'Description of the Step 3',
      image: 'https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-3.png',
    },
  ];  

  /** Since we are using the FocusReveal component, here we can interact and set all its props. */
  const focusRevealProps: FocusRevealProps = {
    popoverProps: {
      arrowSize: 20,
      position: 'right',
      styles: {
        dropdown: {
          padding: 0,
        },
      },
    },
  };

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      title={() => null}
      content={customPopoverContent}
      withStepper={false}
      withSkipButton={false}
      withPrevButton={false}
      withNextButton={false}
      focusRevealProps={focusRevealProps}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1">Title</Button>
          <Button data-onboarding-tour-id="step-2">Description</Button>
          <Button data-onboarding-tour-id="step-3">Content</Button>
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

Custom Stepper

The stepper prop allows you to customize the stepper of the tour. For example, you could use it to display a progress bar or a custom list of steps. Currently, the OnboardingTour component use the Mantine Stepper component. You can use the stepperProps and stepperStepProps props to customize the stepper. Of course, you can build your own using the stepper prop.

import { FocusRevealProps } from '@gfazioli/mantine-focus-reveal';
import {
  OnboardingTour,
  OnboardingTourController,
  type OnboardingTourStep,
} from '@gfazioli/mantine-onboarding-tour';
import { Badge, Button, Center, Divider, Group, Rating, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const focusRevealProps: FocusRevealProps = {
    popoverProps: {
      position: 'top',
    },
  };

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: 'Content of the Step 1',
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: 'Content of the Step 2',
      freeTrialBadge: (
        <Center>
          <Badge color="lime">Free trial available</Badge>
        </Center>
      ),
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: 'Content of the Step 3',
    },
  ];

  const customStepper = (tourController: OnboardingTourController) => (
    <Center>
      <Rating
        count={tourController.tour.length}
        value={(tourController.currentStepIndex ?? 0) + 1}
        onChange={(value) => tourController.setCurrentStepIndex(value - 1)}
      />
    </Center>
  );

  return (
    <OnboardingTour
      tour={onboardingSteps}
      focusRevealProps={focusRevealProps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      stepper={customStepper}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />

        <Group>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <Button data-onboarding-tour-id="step-2" onClick={open}>
            Step 2
          </Button>
          <Button data-onboarding-tour-id="step-3" onClick={open}>
            Step 3
          </Button>
        </Group>
      </Stack>
    </OnboardingTour>
  );
}

OnboardingTour.Target

You have to use the OnboardingTour.Target when your tour component are not visible as children of the OnboardingTour component. For example, here is an example of a tour component that is not a child of the OnboardingTour component.

function AnotherComponent() {
  return (
    <Group>
      {/* ❌ This won't work */}
      <Button data-onboarding-tour-id="my-button">Click me</Button>
      <Button>Cancel</Button>
    </Group>
  );
}

function Demo() {
  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'welcome',
      title: 'Welcome to the Onboarding Tour Component',
      content:
        'This is a demo of the Onboarding Tour component, which allows to create onboarding experiences for your users.',
    },
    {
      id: 'my-button',
      title: 'The New Action Button',
      content: 'This is the content for my button',
    },
  ];

  return (
    <OnboardingTour tour={onboardingSteps} started={true}>
      <div>
        <Title data-onboarding-tour-id="welcome">
          Welcome to the Onboarding Tour Component
        </Title>
        <AnotherComponent />
      </div>
    </OnboardingTour>
  );
}

The above example won't work because we can't get the data-onboarding-tour-id attribute of the AnotherComponent component. In this case you have to use the OnboardingTour.Target component to make it work. In short, instead of using the data-onboarding-tour-id attribute of the component, you have to wrap your component with OnboardingTour.Target.

function AnotherComponent() {
  return (
    <Group>
      {/* ✅ This will work */}
      <OnboardingTour.Target id="my-button">
        <Button>Click me</Button>
      </OnboardingTour.Target>
      <Button>Cancel</Button>
    </Group>
  );
}

function Demo() {
  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'welcome',
      title: 'Welcome to the Onboarding Tour Component',
      content:
        'This is a demo of the Onboarding Tour component, which allows to create onboarding experiences for your users.',
    },
    {
      id: 'my-button',
      title: 'The New Action Button',
      content: 'This is the content for my button',
    },
  ];

  return (
    <OnboardingTour tour={onboardingSteps} started={true}>
      <div>
        <Title data-onboarding-tour-id="welcome">
          Welcome to the Onboarding Tour Component
        </Title>
        <AnotherComponent />
      </div>
    </OnboardingTour>
  );
}
import { OnboardingTour, type OnboardingTourStep } from '@gfazioli/mantine-onboarding-tour';
import { Badge, Button, Divider, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function AnotherComponent({ open }: { open: () => void }) {
  return (
    <>
      <OnboardingTour.Target id="step-2">
        <Button onClick={open}>Step 2</Button>
      </OnboardingTour.Target>
      <OnboardingTour.Target id="step-3">
        <Button onClick={open}>Step 3</Button>
      </OnboardingTour.Target>
    </>
  );
}

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: <Badge color="lime">I'm a direct child of OnboardingTour</Badge>,
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: <Badge color="yellow">I'm not a direct child of OnboardingTour</Badge>,
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: <Badge color="yellow">I'm not a direct child of OnboardingTour</Badge>,
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      title="The Title for all steps"
      withSkipButton={false}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />
        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <AnotherComponent open={open} />
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

You may also set up the focusRevealProps to customize the OnboardingTour.FocusReveal component for each target. This allows you to control the focus and reveal behavior of the tour step. In this case, focusRevealProps can be either an object of type OnboardingTourFocusRevealProps or a function that receives tourController and returns an object of type OnboardingTourFocusRevealProps.

import { OnboardingTour, type OnboardingTourStep } from '@gfazioli/mantine-onboarding-tour';
import { Badge, Button, Divider, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function AnotherComponent({ open }: { open: () => void }) {
  return (
    <>
      <OnboardingTour.Target
        id="step-2"
        focusRevealProps={{ popoverProps: { position: 'top-end' } }}
      >
        <Button onClick={open}>Step 2</Button>
      </OnboardingTour.Target>
      <OnboardingTour.Target
        id="step-3"
        focusRevealProps={{ popoverProps: { position: 'right-end' }, overlayProps: { blur: 16 } }}
      >
        <Button onClick={open}>Step 3</Button>
      </OnboardingTour.Target>
    </>
  );
}

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'step-1',
      title: 'Step-1',
      content: <Badge color="lime">I'm a direct child of OnboardingTour</Badge>,
    },
    {
      id: 'step-2',
      title: 'Step-2',
      content: <Badge color="yellow">I'm not a direct child of OnboardingTour</Badge>,
    },
    {
      id: 'step-3',
      title: 'Step-3',
      content: <Badge color="yellow">I'm not a direct child of OnboardingTour</Badge>,
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      title="The Title for all steps"
      withSkipButton={false}
      maw={400}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          👉 Click here to Start the Tour 👈
        </Button>

        <Divider my={32} />
        <Stack w={200} gap={32}>
          <Button data-onboarding-tour-id="step-1" onClick={open}>
            Step 1
          </Button>
          <AnotherComponent open={open} />
        </Stack>
      </Stack>
    </OnboardingTour>
  );
}

OnboardingTour.FocusReveal

The OnboardingTour.FocusReveal component allows highlighting and making any component on your page more visible. The highlighting process can be controlled by three main properties:

  • withOverlay: displays a dark overlay across the entire page except for the highlighted component
  • withReveal: scrolls the page to make the component to be highlighted visible
  • focusEffect: applies a series of predefined effects when the component is highlighted

Naturally, we have the focused prop that controls when the component should be highlighted.

Note: In all examples, we use the onBlur event to remove focus from the component for demonstration purposes. In a real-world scenario, you might also use the focused prop to control the focus state.

A simple example of the Focus Reveal component

👉 Scroll up the page to remove the focus

John Doe

Scroll the page 👆up or 👇down to remove the focus from the card. In practice, make this component invisible so that the onBlur event will be triggered.

import { OnboardingTour, type OnboardingTourFocusRevealProps} from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Group, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>A simple example of the Focus Reveal component</Title>
      <Text>👉 Scroll up the page to remove the focus</Text>

      <Center>
        <Button onClick={open}>Set the Focus to the below component</Button>
      </Center>

      <Divider my={256} />

      <Group justify="center">
        <Testimonials testimonial={0} />
        <OnboardingTour.FocusReveal focused={focused}  onBlur={close}>
          <Testimonials testimonial={1} />
        </OnboardingTour.FocusReveal>
      </Group>
    </Stack>
  );
}

Uncontrolled Mode

The OnboardingTour.FocusReveal component can also be used in an uncontrolled mode. In this mode, the component will automatically highlight the children.

Simple (uncontrolled) Example

The defaultFocused props is set to true, card below is focused by default

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Center, Code, Divider, Stack, Text, Title } from '@mantine/core';

function Demo() {

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Simple (uncontrolled) Example</Title>

      <Center>
        <Text>
          The <Code>defaultFocused</Code> props is set to <Code>true</Code>, card below is focused
          by default
        </Text>
      </Center>

      <Divider mb={600}
        label={
          <>
            <Text fz={48}>👇</Text>
            <Text>Scroll down</Text>
          </>
        }
      />

      <Center>
        <OnboardingTour.FocusReveal defaultFocused={true} withReveal={false}>
          <Testimonials testimonial={0} />
        </OnboardingTour.FocusReveal>
      </Center>

      <Divider my={200} />
    </Stack>
  );
}

Disable target interaction

When the target is focused, you can prevent mouse/keyboard interaction with the highlighted element by setting disableTargetInteraction. This is useful when you want users to read the popover content and navigate the tour without interacting with the underlying component.

Disable target interactions

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Group, Stack, Switch, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { open, close }] = useDisclosure(false);
  const [disableTargetInteraction, setDisableTargetInteraction] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Disable target interactions</Title>

      <Center>
        <Group>
          <Button onClick={open}>Focus the component</Button>
          <Switch
            label="Disable target interactions"
            checked={disableTargetInteraction}
            onChange={() => setDisableTargetInteraction.toggle()}
          />
        </Group>
      </Center>

      <Divider my={100} />

      <Group justify="center">
        <OnboardingTour.FocusReveal
          focused={focused}
          onBlur={close}
          disableTargetInteraction={disableTargetInteraction}
          withOverlay
        >
          <Stack align="center">
            <Testimonials testimonial={1} withButton />
            <Button>Click inside (will not fire when focused)</Button>
            <Text size="sm" c="dimmed">
              Interaction disabled while focused: pointer events are blocked.
            </Text>
          </Stack>
        </OnboardingTour.FocusReveal>
      </Group>

      <Divider my={100} />
    </Stack>
  );
}

OnboardingTour.FocusReveal.Group

If you want to highlight multiple components, you can use the OnboardingTour.FocusReveal.Group component. This component allows you to highlight multiple components at the same time. This also allows for controlling multiple OnboardingTour.FocusReveal components simultaneously, setting some common properties for all the components.

export interface OnboardingTourFocusRevealGroupProps {
  /** FocusReveal mode/effects when focused */
  focusedMode?: OnboardingTourFocusRevealFocusedMode;

  /** Indicator if element should be revealed. Default `false` */
  withReveal?: boolean;

  /** Will render overlay if set to `true` */
  withOverlay?: boolean;

  /** Props passed down to `Overlay` component */
  overlayProps?: OverlayProps & ElementProps<'div'>;

  /** Props passed down to the `Transition` component that used to animate the Overlay, use to configure duration and animation type, `{ duration: 150, transition: 'fade' }` by default */
  transitionProps?: TransitionOverride;

  /** Content */
  children?: React.ReactNode;
}

Note: The OnboardingTour.FocusReveal.Group component does not have the focused prop. Instead, use the focused prop in the OnboardingTour.FocusReveal component. In addition, the withReveal prop is set to false by default. The defaultFocused prop is set to true by default.

Group Example

The defaultFocused props is set to true, card below is focused by default

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Center, Code, Divider, Stack, Text, Title } from '@mantine/core';

function Demo() {
  return (
    <>
      <Stack justify="center" align="center">
        <Title order={4}>Group Example</Title>

        <Center>
          <Text>
            The <Code>defaultFocused</Code> props is set to <Code>true</Code>, card below is focused
            by default
          </Text>
        </Center>

        <Divider mb={600}
          label={
            <>
              <Text fz={48}>👇</Text>
              <Text>Scroll down</Text>
            </>
          }
        />
      </Stack>

      <OnboardingTour.FocusReveal.Group focusedMode="scale">
        <Stack>
          <Center>
            <OnboardingTour.FocusReveal>
              <Testimonials testimonial={0} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={200} />

          <Center>
            <OnboardingTour.FocusReveal>
              <Testimonials testimonial={1} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={200} />

          <Center>
            <OnboardingTour.FocusReveal>
              <Testimonials testimonial={2} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={200} />
        </Stack>
      </OnboardingTour.FocusReveal.Group>
    </>
  );
}

Below another example of using the OnboardingTour.FocusReveal.Group component.

Group Example

The defaultFocused props is set to true, card below is focused by default

import { useState } from 'react';
import { FocusRevealFocusedMode, focusRevealModes } from '@gfazioli/mantine-focus-reveal';
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Center, Code, Divider, Group, Select, Stack, Text, Title } from '@mantine/core';

function Demo() {
  const [focusedMode, setFocusedMode] = useState<string | null>('scale');

  return (
    <>
      <Stack justify="center" align="center">
        <Title order={4}>Group Example</Title>

        <Group justify="left">
          <Select
            data={[
              'border',
              'elastic',
              'glow',
              'glow-blue',
              'glow-green',
              'glow-red',
              'none',
              'pulse',
              'rotate',
              'scale',
              'shake',
              'zoom',
            ]}
            value={focusedMode}
            onChange={setFocusedMode}
            label="Focused mode"
          />
        </Group>

        <Center>
          <Text>
            The <Code>defaultFocused</Code> props is set to <Code>true</Code>, card below is focused
            by default
          </Text>
        </Center>

        <Divider
          mb={600}
          label={
            <>
              <Text fz={48}>👇</Text>
              <Text>Scroll down</Text>
            </>
          }
        />
      </Stack>

      <OnboardingTour.FocusReveal.Group focusedMode={focusedMode as FocusRevealFocusedMode}>
        <Stack>
          <Center>
            <OnboardingTour.FocusReveal>
              <Testimonials testimonial={0} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={100} />

          <Center>
            <OnboardingTour.FocusReveal defaultFocused={false}>
              <Testimonials testimonial={1} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={100} />

          <Center>
            <OnboardingTour.FocusReveal>
              <Testimonials testimonial={2} />
            </OnboardingTour.FocusReveal>
          </Center>

          <Divider my={100} />
        </Stack>
      </OnboardingTour.FocusReveal.Group>
    </>
  );
}

withReveal

The withReveal props scrolls the page to make the component to be highlighted visible. This is useful when the component is not visible on the screen. Internally, the component uses the scrollIntoView method to make the component visible, from the Mantine useScrollIntoView() hook.

Of course, you can customize the scroll behavior by using the revealProps prop. This prop accepts the same properties as the useScrollIntoView() method.

interface ScrollIntoViewParams {
  /** callback fired after scroll */
  // onScrollFinish?: () => void; // See below

  /** duration of scroll in milliseconds */
  duration?: number;

  /** axis of scroll */
  axis?: 'x' | 'y';

  /** custom mathematical easing function */
  easing?: (t: number) => number;

  /** additional distance between nearest edge and element */
  offset?: number;

  /** indicator if animation may be interrupted by user scrolling */
  cancelable?: boolean;

  /** prevents content jumping in scrolling lists with multiple targets */
  isList?: boolean;
}

Custom Reveal Props Example

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Group, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Custom Reveal Props Example</Title>

      <Center>
        <Button onClick={open}>Set the Focus to the below component</Button>
      </Center>

      <Divider my={200} />

      <Group justify="center">
        <OnboardingTour.FocusReveal
          focused={focused}
          onBlur={close}
          revealProps={{
            duration: 500,
          }}
        >
          <Testimonials testimonial={1} />
        </OnboardingTour.FocusReveal>
      </Group>

      <Divider my={200} />
    </Stack>
  );
}

Note: The onScrollFinish callback is not available in the revealsProps prop. Instead, use the onRevealFinish prop.

withOverlay

The withOverlay prop displays a dark overlay across the entire page except for the highlighted component. This is useful when you want to focus the user's attention on a specific component. The overlay is customizable by using the overlayProps prop.

interface OverlayProps {
  /** Controls overlay background-color opacity 0–1, disregarded when gradient prop is set, 0.6 by default */
  backgroundOpacity?: number;

  /** Overlay background blur, 0 by default */
  blur?: string | number;

  /** Determines whether content inside overlay should be vertically and horizontally centered, false by default */
  center?: boolean;

  /** Content inside overlay */
  children?: React.ReactNode;

  /** Overlay background-color, #000 by default */
  color?: BackgroundColor;

  /** Determines whether overlay should have fixed position instead of absolute, false by default */
  fixed?: boolean;

  /** Changes overlay to gradient. If set, color prop is ignored */
  gradient?: string;

  /** Key of theme.radius or any valid CSS value to set border-radius, 0 by default */
  radius?: MantineRadius | number;

  /** Overlay z-index, 200 by default */
  zIndex?: string | number;
}

Overlay Example

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Group, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Overlay Example</Title>

      <Center>
        <Button onClick={open}>Set the Focus to the below component</Button>
      </Center>

      <Divider my={200} />

      <Group justify="center">
        <OnboardingTour.FocusReveal
          focused={focused}
          onBlur={close}
          overlayProps={{
            color: 'rgba(255,0,0,1)',
            blur: 0,
          }}
        >
          <Testimonials testimonial={1} />
        </OnboardingTour.FocusReveal>
      </Group>

      <Divider my={200} />
    </Stack>
  );
}

scrollableRef

The scrollableRef prop allows you to specify a custom scrollable element. This is useful when the component to be highlighted is inside a scrollable container. The scrollableRef prop accepts a React.RefObject<HTMLElement>.

With simple Paper container

Paper container Example

import { useRef } from 'react';
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Paper, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);
  const scrollRef = useRef<HTMLDivElement>(null);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Paper container Example</Title>

      <Paper
        shadow="sm"
        withBorder
        radius={16}
        p={16}
        ref={scrollRef}
        h={500}
        style={{ overflow: 'auto' }}
      >
        <Center>
          <Button onClick={open}>Reveal the Bottom Card</Button>
        </Center>

        <Divider my={400} label="Divider" />

        <Center>
          <OnboardingTour.FocusReveal
            scrollableRef={scrollRef as React.RefObject<HTMLDivElement>}
            focused={focused}
            onBlur={close}
          >
            <Testimonials testimonial={0} />
          </OnboardingTour.FocusReveal>
        </Center>
      </Paper>
    </Stack>
  );
}

Below, by using the ScrollArea component, we can create a scrollable container.

ScrollArea

import { useRef } from 'react';
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, ScrollArea } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);
  const scrollRef = useRef<HTMLDivElement>(null);

  return (
    <ScrollArea viewportRef={scrollRef} h={500} style={{ position: 'relative' }}>
      <Center>
        <Button onClick={open}>Reveal the Bottom Card</Button>
      </Center>

      <Divider my={400} label="Divider" />

      <Center>
        <OnboardingTour.FocusReveal
          scrollableRef={scrollRef as React.RefObject<HTMLDivElement>}
          focused={focused}
          onBlur={close}
        >
          <Testimonials testimonial={0} />
        </OnboardingTour.FocusReveal>
      </Center>
    </ScrollArea>
  );
}

Custom Focus Mode

The OnboardingTour.FocusReveal component allows you to create a custom focus mode. This is useful when you want to create a custom focus effect.

Custom Focused Mode Example

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Group, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import classes from './CustomMode.module.css';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Custom Focused Mode Example</Title>

      <Center>
        <Button onClick={open}>Set the Focus to the below component</Button>
      </Center>

      <Divider my={100} label="Divider" />

      <Group justify="center">
        <OnboardingTour.FocusReveal focused={focused} onBlur={close} className={classes.custom}>
          <Testimonials testimonial={1} />
        </OnboardingTour.FocusReveal>
      </Group>
    </Stack>
  );
}

With Popover

In addition to focus and reveal, you can add a Popover display when the element gains focus by using the popoverContent prop.

Popover example

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Popover example</Title>

      <Center>
        <Button onClick={open}>Reveal the Bottom Card</Button>
      </Center>

      <Divider my={200} label="Divider" />

      <Center>
        <OnboardingTour.FocusReveal focused={focused} onBlur={close} popoverContent={<h1>Hello, World!</h1>}>
          <Testimonials testimonial={0} />
        </OnboardingTour.FocusReveal>
      </Center>
    </Stack>
  );
}

You can use the popoverProps prop (are the same as the Mantine Popover component. You can find the documentation here) to manipulate the Mantine Popover component.

Popover example

import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Center, Divider, Stack, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';

function Demo() {
  const [focused, { close, open }] = useDisclosure(false);

  return (
    <Stack justify="center" align="center">
      <Title order={4}>Popover example</Title>

      <Center>
        <Button onClick={open}>Reveal the Bottom Card</Button>
      </Center>

      <Divider my={200} label="Divider" />

      <Center>
        <OnboardingTour.FocusReveal
          focused={focused}
          onBlur={close}
          popoverContent={<h1>Hello, World!</h1>}
          popoverProps={{
            position: 'top',
            withArrow: false,
            shadow: 'md',
            radius: 256,
          }}
        >
          <Testimonials testimonial={0} />
        </OnboardingTour.FocusReveal>
      </Center>
    </Stack>
  );
}

Example: Cycle

Here are some examples of how to use the OnboardingTour.FocusReveal component in different scenarios.

Cycle Example

Use the arrow keys to cycle through the testimonials

import { useState } from 'react';
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Group, Stack, Text, Title } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';

function Demo() {
  const [focusIndex, setFocusIndex] = useState(-1);
  const MAX_TESTIMONIALS = 3;

  useHotkeys([
    [
      'ArrowRight',
      () =>
        focusIndex >= 0 && setFocusIndex(focusIndex + 1 < MAX_TESTIMONIALS ? focusIndex + 1 : 0),
    ],
    [
      'ArrowLeft',
      () =>
        focusIndex >= 0 &&
        setFocusIndex(focusIndex - 1 >= 0 ? focusIndex - 1 : MAX_TESTIMONIALS - 1),
    ],
  ]);

  return (
    <Stack justify="center" align="center">
      <Title order={1}>Cycle Example</Title>
      <Text fs="italic">Use the arrow keys to cycle through the testimonials</Text>
      <Button onClick={() => setFocusIndex(0)}>Start</Button>

      <Group justify="center">
        {testimonials.map(
          (_, index) =>
            index < MAX_TESTIMONIALS && (
              <OnboardingTour.FocusReveal
                key={`focus-reveal-${index}`}
                focused={focusIndex === index}
                transitionProps={{ duration: 0, exitDuration: 0 }}
                onBlur={() => setFocusIndex(-1)}
                focusedMode="zoom"
              >
                <Testimonials key={`box-${index}`} testimonial={0}>
                  <Group justify="center">
                    <Button
                      size="xs"
                      variant="gradient"
                      onClick={() => setFocusIndex(index + 1 < MAX_TESTIMONIALS ? index + 1 : 0)}
                    >
                      Next
                    </Button>
                  </Group>
                </Testimonials>
              </OnboardingTour.FocusReveal>
            )
        )}
      </Group>
    </Stack>
  );
}

Example: Multiple Focus components

Multiple components Example

Use the arrow keys to cycle through the testimonials

import { useState } from 'react';
import { OnboardingTour } from '@gfazioli/mantine-onboarding-tour';
import { Button, Group, Paper, Stack, Text, Title } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';

function Demo() {
  const [focusIndex, setFocusIndex] = useState(-1);
  const MAX_TESTIMONIALS = 3;

  useHotkeys([
    [
      'ArrowRight',
      () =>
        focusIndex >= 0 && setFocusIndex(focusIndex + 1 < MAX_TESTIMONIALS ? focusIndex + 1 : 0),
    ],
    [
      'ArrowLeft',
      () =>
        focusIndex >= 0 &&
        setFocusIndex(focusIndex - 1 >= 0 ? focusIndex - 1 : MAX_TESTIMONIALS - 1),
    ],
  ]);

  const descriptions = [
    'This is the first description.',
    'This is the second description.',
    'This is the third description.',
    'This is the fourth description.',
  ];

  return (
    <Stack justify="center" align="center">
      <Title order={1}>Multiple components Example</Title>
      <Text fs="italic">Use the arrow keys to cycle through the testimonials</Text>
      <Button onClick={() => setFocusIndex(0)}>Start</Button>

      <Group justify="center">
        {testimonials.map(
          (_, index) =>
            index < MAX_TESTIMONIALS && (
              <OnboardingTour.FocusReveal
                key={`focus-reveal-${index}`}
                focused={focusIndex === index}
                transitionProps={{ duration: 0, exitDuration: 0 }}
                onBlur={() => setFocusIndex(-1)}
                focusedMode="zoom"
              >
                <Testimonials key={`box-${index}`} testimonial={0}>
                  <Group justify="center">
                    <Button
                      size="xs"
                      variant="gradient"
                      onClick={() => setFocusIndex(index + 1 < MAX_TESTIMONIALS ? index + 1 : 0)}
                    >
                      Next
                    </Button>
                  </Group>
                </Testimonials>
              </OnboardingTour.FocusReveal>
            )
        )}
      </Group>
      {focusIndex >= 0 && (
        <OnboardingTour.FocusReveal defaultFocused={true} withReveal={false}>
          <Paper withBorder shadow="sm" p={16} mt={32}>
            <Group>
              <Text>{descriptions[focusIndex]}</Text>
              <Button size="xs" onClick={() => setFocusIndex(-1)}>
                Stop
              </Button>
            </Group>
          </Paper>
        </OnboardingTour.FocusReveal>
      )}
    </Stack>
  );
}

Responsive Behavior

The OnboardingTour component is responsive by default. Popover position, offset, width, and arrowSize accept responsive objects that map Mantine breakpoints to values, powered by useMatches() under the hood.

By default, the popover appears at the bottom on mobile (base) and on the left on larger screens (sm+):

// Default popover props (you don't need to set these explicitly)
popoverProps: {
  position: { base: 'bottom', sm: 'left' },
  withArrow: true,
  arrowSize: 16,
  offset: -4,
  middlewares: { shift: { padding: 20 }, flip: true },
}

Responsive Demo

Try the full-page responsive demo to see the behavior at different screen sizes:

Open Responsive Onboarding Tour example page

Responsive Props

The following popover props accept responsive objects (ResponsiveProp<T>):

  • position: FloatingPosition | { base: 'bottom', sm: 'left', lg: 'top' }
  • offset: number | { base: 8, sm: -4 }
  • width: PopoverWidth | { base: 'target', sm: 300 }
  • arrowSize: number | { base: 12, sm: 16 }

All other PopoverProps (e.g., withArrow, radius, shadow, middlewares) work as usual.

Per-Step Responsive Configuration

Each step can define its own responsive positioning via focusRevealProps:

const steps: OnboardingTourStep[] = [
  {
    id: 'step1',
    title: 'Bottom on mobile, right on desktop',
    content: 'Position adapts to screen size',
    focusRevealProps: {
      popoverProps: {
        position: { base: 'bottom', sm: 'right' },
        offset: { base: 8, sm: -4 },
      },
    },
  },
  {
    id: 'step2',
    title: 'Top on mobile, left on large screens',
    content: 'Different breakpoints per step',
    focusRevealProps: {
      popoverProps: {
        position: { base: 'top', lg: 'left' },
        width: { base: 'target', md: 350 },
      },
    },
  },
];

Scroll Behavior

When a step activates, the target element is automatically scrolled into view and centered in the viewport. The Floating UI shift and flip middlewares then ensure the popover stays fully visible regardless of the resolved position.

Cutout Highlight

When the tour is active, a persistent overlay covers the page with a cutout hole around the focused element. By default the cutout has 8px padding and 8px border radius, but you can customize both values at the tour level and per step.

Props

PropTypeDefaultDescription
cutoutPaddingnumber8

Padding around the cutout highlight area (px)

cutoutRadiusnumber8

Border radius of the cutout. Use 9999 for circular elements.

Both props can be set on <OnboardingTour> (tour-level default) and on individual OnboardingTourStep objects (per-step override). The resolution order is: step > tour > default (8).

Circular and custom cutouts

For circular elements like avatars or icon buttons, set cutoutRadius: 9999 on the step. For pill-shaped buttons, use a moderate radius like 24. Steps that don't specify these props inherit the tour-level values.

const steps: OnboardingTourStep[] = [
  {
    id: 'avatar',
    title: 'Your Profile',
    content: 'Click your avatar to open settings.',
    cutoutPadding: 4,
    cutoutRadius: 9999, // circular cutout
  },
  {
    id: 'action-button',
    title: 'Get Started',
    content: 'This uses a pill-shaped cutout.',
    cutoutPadding: 6,
    cutoutRadius: 24,
  },
  {
    id: 'panel',
    title: 'Dashboard',
    content: 'Default rectangular cutout (8px padding, 8px radius).',
    // inherits tour-level defaults
  },
];

The avatar and settings icon use cutoutRadius: 9999 for a circular cutout. The notification icon uses the default rectangular cutout.

import {
  OnboardingTour,
  type OnboardingTourStep,
} from '@gfazioli/mantine-onboarding-tour';
import { Avatar, Button, Divider, Group, Stack, Text, ThemeIcon } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconBell, IconSettings } from '@tabler/icons-react';

function Demo() {
  const [started, { open, close }] = useDisclosure(false);

  const onboardingSteps: OnboardingTourStep[] = [
    {
      id: 'avatar',
      title: 'Your Profile',
      content: 'Click on your avatar to access profile settings.',
      // Circular cutout for round elements
      cutoutPadding: 4,
      cutoutRadius: 9999,
    },
    {
      id: 'settings',
      title: 'Settings',
      content: 'This icon button uses a circular cutout too.',
      cutoutPadding: 4,
      cutoutRadius: 9999,
    },
    {
      id: 'notifications',
      title: 'Notifications',
      content: 'This step uses the default rectangular cutout.',
      // Uses tour-level defaults (cutoutPadding: 8, cutoutRadius: 8)
    },
    {
      id: 'action',
      title: 'Get Started',
      content: 'This button uses a pill-shaped cutout with a larger radius.',
      cutoutPadding: 6,
      cutoutRadius: 24,
    },
  ];

  return (
    <OnboardingTour
      tour={onboardingSteps}
      started={started}
      onOnboardingTourEnd={close}
      onOnboardingTourSkip={close}
      maw={350}
    >
      <Stack justify="center" align="center">
        <Button size="md" radius={256} variant="gradient" onClick={open}>
          Start the Tour
        </Button>

        <Divider my={16} w="100%" />

        <Group justify="center" gap="xl">
          <Avatar
            data-onboarding-tour-id="avatar"
            src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-1.png"
            radius="xl"
            size="lg"
          />

          <ThemeIcon
            data-onboarding-tour-id="settings"
            variant="light"
            radius="xl"
            size="xl"
          >
            <IconSettings size={24} />
          </ThemeIcon>

          <ThemeIcon
            data-onboarding-tour-id="notifications"
            variant="light"
            size="xl"
          >
            <IconBell size={24} />
          </ThemeIcon>
        </Group>

        <Button data-onboarding-tour-id="action" radius="xl" size="md">
          Get Started
        </Button>
      </Stack>
    </OnboardingTour>
  );
}