# Coachmark

An accessible, unstyled React primitive for product tours, built on top of Base UI.

When I looked for a way to build product tours, I couldn't find anything that fit my needs.  
So I built Coachmark.

[GitHub](https://github.com/sglza/coachmark)

## Getting started

To get started you just need to add Coachmark to your React project.

```sh
pnpm add coachmark
```

The composition of Coachmark is very similar to that of the Popover in Base UI, and it's no coincidence. But you'll also notice there are a couple of extra parts to the component anatomy, like `Coachmark.Step` and `Coachmark.Stepper`. These exist because unlike a regular Popover, a product tour requires an explicit order and continuity.

Here's a basic example:

```tsx
import { type Ref, useRef } from "react";
import { Coachmark } from "coachmark";
import { cn } from "@/lib/utils";

export function ProductTour() {
  const statusRef = useRef<HTMLDivElement>(null);
  const domainsRef = useRef<HTMLDivElement>(null);
  const sourceRef = useRef<HTMLDivElement>(null);
  const logsRef = useRef<HTMLButtonElement>(null);
  const visitRef = useRef<HTMLButtonElement>(null);
  const targetRefs = {
    domains: domainsRef,
    logs: logsRef,
    source: sourceRef,
    status: statusRef,
    visit: visitRef,
  };

  return (
    <Coachmark.Root>
      <Coachmark.Trigger>Start tour</Coachmark.Trigger>
      <Coachmark.Backdrop
        className={cn(
          "fixed inset-0 z-60 bg-black/48 data-starting-style:opacity-0",
          "data-ending-style:opacity-0 motion-reduce:transition-none",
        )}
      />

      {deploymentTourSteps.map((step) => (
        <Coachmark.Step
          key={step.key}
          spotlightPadding={step.padding}
          spotlightRadius={step.radius}
          target={targetRefs[step.key]}
        >
          <Coachmark.Positioner
            align={step.align}
            className="z-70 motion-reduce:transition-none"
            side={step.side}
            sideOffset={12 + step.padding}
          >
            <Coachmark.Popup
              className={cn(
                "relative box-border flex max-w-[calc(100vw-2rem)]",
                "origin-(--transform-origin) flex-col rounded-xl border bg-popover",
                "text-popover-foreground shadow-xl motion-reduce:transition-none",
                "w-[min(19rem,calc(100vw-2rem))] p-4",
              )}
            >
              <div
                className="grid gap-3"
              >
                <Coachmark.Stepper
                  className={cn(
                    "text-[0.68rem] font-semibold tracking-[0.12em] text-muted-foreground",
                    "uppercase",
                  )}
                >
                  {({ stepCount, stepIndex }) => `Step ${stepIndex + 1} of ${stepCount}`}
                </Coachmark.Stepper>
                <div
                  className="grid gap-1.5"
                >
                  <Coachmark.Title
                    className="text-base font-semibold"
                  >{step.title}</Coachmark.Title>
                  <Coachmark.Description
                    className="text-sm leading-5 text-muted-foreground"
                  >
                    {step.description}
                  </Coachmark.Description>
                </div>
                <div
                  className="mt-1 flex items-center justify-between gap-3"
                >
                  <Coachmark.Close>Skip</Coachmark.Close>
                  <div
                    className="flex items-center gap-1"
                  >
                    <Coachmark.Previous>Back</Coachmark.Previous>
                    <Coachmark.Next>
                      {({ isLastStep }) => (isLastStep ? "Finish" : "Next")}
                    </Coachmark.Next>
                  </div>
                </div>
              </div>
            </Coachmark.Popup>
          </Coachmark.Positioner>
        </Coachmark.Step>
      ))}

      <DeploymentDetailsCard targetRefs={targetRefs} />
    </Coachmark.Root>
  );
}

type DeploymentDetailsTargetRefs = {
  domains: Ref<HTMLDivElement>;
  logs: Ref<HTMLButtonElement>;
  source: Ref<HTMLDivElement>;
  status: Ref<HTMLDivElement>;
  visit: Ref<HTMLButtonElement>;
};

function DeploymentDetailsCard({
  targetRefs,
}: {
  targetRefs: DeploymentDetailsTargetRefs;
}) {
  return (
    <section aria-labelledby="deployment-details-title">
      <header>
        <h2 id="deployment-details-title">Deployment details</h2>
        <button ref={targetRefs.logs} type="button">
          Logs
        </button>
        <button ref={targetRefs.visit} type="button">
          Visit
        </button>
      </header>

      <dl>
        <div ref={targetRefs.status}>
          <dt>Status</dt>
          <dd>Ready</dd>
        </div>
        <div ref={targetRefs.domains}>
          <dt>Domains</dt>
          <dd>preview.example.com</dd>
        </div>
        <div ref={targetRefs.source}>
          <dt>Source</dt>
          <dd>
            <code>feat/product-tour</code>
          </dd>
        </div>
      </dl>
    </section>
  );
}

const deploymentTourSteps = [
  {
    align: "start",
    description: "Check that the latest deployment finished successfully.",
    key: "status",
    padding: 8,
    radius: 8,
    side: "bottom",
    title: "Confirm deployment status",
  },
  {
    align: "start",
    description: "Find the generated domain for this preview deployment.",
    key: "domains",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Find the deployment URL",
  },
  {
    align: "start",
    description: "Review the branch to confirm what was deployed.",
    key: "source",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Verify the source",
  },
  {
    align: "center",
    description: "Open the build output to inspect each deployment step.",
    key: "logs",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Inspect the build logs",
  },
  {
    align: "end",
    description: "Open the latest preview and verify the deployed changes.",
    key: "visit",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Visit the preview",
  },
] as const;
```

## Animating the coach marks

Coachmark exposes motion states on the backdrop, positioner, and popup. Use those data attributes to define enter, exit, and step transitions. This example uses opacity.

```tsx
import { type Ref, useRef } from "react";
import { Coachmark } from "coachmark";
import { cn } from "@/lib/utils";

export function ProductTour() {
  const statusRef = useRef<HTMLDivElement>(null);
  const domainsRef = useRef<HTMLDivElement>(null);
  const sourceRef = useRef<HTMLDivElement>(null);
  const logsRef = useRef<HTMLButtonElement>(null);
  const visitRef = useRef<HTMLButtonElement>(null);
  const targetRefs = {
    domains: domainsRef,
    logs: logsRef,
    source: sourceRef,
    status: statusRef,
    visit: visitRef,
  };

  return (
    <Coachmark.Root>
      <Coachmark.Trigger>Start tour</Coachmark.Trigger>
      <Coachmark.Backdrop
        className={cn(
          "fixed inset-0 z-60 bg-black/48 data-starting-style:opacity-0",
          "data-ending-style:opacity-0 motion-reduce:transition-none",
          "[transition:opacity_180ms_ease-out]",
          "data-[motion-state=entering]:[--coachmark-cutout-path:none]",
          "data-[motion-state=exiting]:[--coachmark-cutout-path:none]",
          "data-[motion-state=repositioning]:[--coachmark-cutout-path:none]",
        )}
      />

      {deploymentTourSteps.map((step) => (
        <Coachmark.Step
          key={step.key}
          spotlightPadding={step.padding}
          spotlightRadius={step.radius}
          target={targetRefs[step.key]}
        >
          <Coachmark.Positioner
            align={step.align}
            className={cn(
              "z-70 motion-reduce:transition-none [transition:opacity_180ms_ease-out]",
              "data-[motion-state=entering]:opacity-0",
              "data-[motion-state=exiting]:opacity-0",
              "data-[motion-state=repositioning]:opacity-0",
            )}
            side={step.side}
            sideOffset={12 + step.padding}
          >
            <Coachmark.Popup
              className={cn(
                "relative box-border flex max-w-[calc(100vw-2rem)]",
                "origin-(--transform-origin) flex-col rounded-xl border bg-popover",
                "text-popover-foreground shadow-xl motion-reduce:transition-none",
                "w-[min(19rem,calc(100vw-2rem))] p-4 [transition:opacity_180ms_ease-out]",
                "data-starting-style:opacity-0 data-ending-style:opacity-0",
                "data-[motion-state=entering]:opacity-0",
                "data-[motion-state=exiting]:opacity-0",
                "data-[motion-state=repositioning]:opacity-0",
              )}
            >
              <div
                className="grid gap-3"
              >
                <Coachmark.Stepper
                  className={cn(
                    "text-[0.68rem] font-semibold tracking-[0.12em] text-muted-foreground",
                    "uppercase",
                  )}
                >
                  {({ stepCount, stepIndex }) => `Step ${stepIndex + 1} of ${stepCount}`}
                </Coachmark.Stepper>
                <div
                  className="grid gap-1.5"
                >
                  <Coachmark.Title
                    className="text-base font-semibold"
                  >{step.title}</Coachmark.Title>
                  <Coachmark.Description
                    className="text-sm leading-5 text-muted-foreground"
                  >
                    {step.description}
                  </Coachmark.Description>
                </div>
                <div
                  className="mt-1 flex items-center justify-between gap-3"
                >
                  <Coachmark.Close>Skip</Coachmark.Close>
                  <div
                    className="flex items-center gap-1"
                  >
                    <Coachmark.Previous>Back</Coachmark.Previous>
                    <Coachmark.Next>
                      {({ isLastStep }) => (isLastStep ? "Finish" : "Next")}
                    </Coachmark.Next>
                  </div>
                </div>
              </div>
            </Coachmark.Popup>
          </Coachmark.Positioner>
        </Coachmark.Step>
      ))}

      <DeploymentDetailsCard targetRefs={targetRefs} />
    </Coachmark.Root>
  );
}

type DeploymentDetailsTargetRefs = {
  domains: Ref<HTMLDivElement>;
  logs: Ref<HTMLButtonElement>;
  source: Ref<HTMLDivElement>;
  status: Ref<HTMLDivElement>;
  visit: Ref<HTMLButtonElement>;
};

function DeploymentDetailsCard({
  targetRefs,
}: {
  targetRefs: DeploymentDetailsTargetRefs;
}) {
  return (
    <section aria-labelledby="deployment-details-title">
      <header>
        <h2 id="deployment-details-title">Deployment details</h2>
        <button ref={targetRefs.logs} type="button">
          Logs
        </button>
        <button ref={targetRefs.visit} type="button">
          Visit
        </button>
      </header>

      <dl>
        <div ref={targetRefs.status}>
          <dt>Status</dt>
          <dd>Ready</dd>
        </div>
        <div ref={targetRefs.domains}>
          <dt>Domains</dt>
          <dd>preview.example.com</dd>
        </div>
        <div ref={targetRefs.source}>
          <dt>Source</dt>
          <dd>
            <code>feat/product-tour</code>
          </dd>
        </div>
      </dl>
    </section>
  );
}

const deploymentTourSteps = [
  {
    align: "start",
    description: "Check that the latest deployment finished successfully.",
    key: "status",
    padding: 8,
    radius: 8,
    side: "bottom",
    title: "Confirm deployment status",
  },
  {
    align: "start",
    description: "Find the generated domain for this preview deployment.",
    key: "domains",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Find the deployment URL",
  },
  {
    align: "start",
    description: "Review the branch to confirm what was deployed.",
    key: "source",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Verify the source",
  },
  {
    align: "center",
    description: "Open the build output to inspect each deployment step.",
    key: "logs",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Inspect the build logs",
  },
  {
    align: "end",
    description: "Open the latest preview and verify the deployed changes.",
    key: "visit",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Visit the preview",
  },
] as const;
```

## Detached triggers

This is probably why you're here. This is also the main thing that I was searching for when looking at existing solutions, and it's arguably the main reason why I built this on top of Base UI. Each step provides a target ref to the positioner. Coachmark forwards the active target to Base UI's anchor prop, allowing the same popup to smoothly reposition when the step changes.

```tsx
import { type Ref, useRef } from "react";
import { Coachmark } from "coachmark";
import { cn } from "@/lib/utils";

export function ProductTour() {
  const statusRef = useRef<HTMLDivElement>(null);
  const domainsRef = useRef<HTMLDivElement>(null);
  const sourceRef = useRef<HTMLDivElement>(null);
  const logsRef = useRef<HTMLButtonElement>(null);
  const visitRef = useRef<HTMLButtonElement>(null);
  const targetRefs = {
    domains: domainsRef,
    logs: logsRef,
    source: sourceRef,
    status: statusRef,
    visit: visitRef,
  };

  return (
    <Coachmark.Root>
      <Coachmark.Trigger>Start tour</Coachmark.Trigger>
      <Coachmark.Backdrop
        className={cn(
          "fixed inset-0 z-60 bg-black/48 data-starting-style:opacity-0",
          "data-ending-style:opacity-0 motion-reduce:transition-none",
          "[transition:clip-path_240ms_ease-in-out,opacity_180ms_ease-out]",
          "data-concealed:transition-none",
        )}
      />

      {deploymentTourSteps.map((step) => (
        <Coachmark.Step
          key={step.key}
          spotlightPadding={step.padding}
          spotlightRadius={step.radius}
          target={targetRefs[step.key]}
        >
          <Coachmark.Positioner
            align={step.align}
            className={cn(
              "z-70 motion-reduce:transition-none",
              "[transition:top_240ms_ease-in-out,left_240ms_ease-in-out,transform_240ms_ease-in-out,opacity_180ms_ease-out]",
              "[&[data-concealed][data-motion-state=repositioning]]:transition-none",
            )}
            side={step.side}
            sideOffset={12 + step.padding}
          >
            <Coachmark.Popup
              className={cn(
                "relative box-border flex max-w-[calc(100vw-2rem)]",
                "origin-(--transform-origin) flex-col rounded-xl border bg-popover",
                "text-popover-foreground shadow-xl motion-reduce:transition-none",
                "w-[min(19rem,calc(100vw-2rem))] p-4",
                "[transition:opacity_180ms_ease-out,transform_180ms_ease-out]",
                "data-starting-style:[transform:scale(0.97)]",
                "data-starting-style:opacity-0 data-ending-style:[transform:scale(0.97)]",
                "data-ending-style:opacity-0",
              )}
            >
              <div
                className="grid gap-3"
              >
                <Coachmark.Stepper
                  className={cn(
                    "text-[0.68rem] font-semibold tracking-[0.12em] text-muted-foreground",
                    "uppercase",
                  )}
                >
                  {({ stepCount, stepIndex }) => `Step ${stepIndex + 1} of ${stepCount}`}
                </Coachmark.Stepper>
                <div
                  className="grid gap-1.5"
                >
                  <Coachmark.Title
                    className="text-base font-semibold"
                  >{step.title}</Coachmark.Title>
                  <Coachmark.Description
                    className="text-sm leading-5 text-muted-foreground"
                  >
                    {step.description}
                  </Coachmark.Description>
                </div>
                <div
                  className="mt-1 flex items-center justify-between gap-3"
                >
                  <Coachmark.Close>Skip</Coachmark.Close>
                  <div
                    className="flex items-center gap-1"
                  >
                    <Coachmark.Previous>Back</Coachmark.Previous>
                    <Coachmark.Next>
                      {({ isLastStep }) => (isLastStep ? "Finish" : "Next")}
                    </Coachmark.Next>
                  </div>
                </div>
              </div>
            </Coachmark.Popup>
          </Coachmark.Positioner>
        </Coachmark.Step>
      ))}

      <DeploymentDetailsCard targetRefs={targetRefs} />
    </Coachmark.Root>
  );
}

type DeploymentDetailsTargetRefs = {
  domains: Ref<HTMLDivElement>;
  logs: Ref<HTMLButtonElement>;
  source: Ref<HTMLDivElement>;
  status: Ref<HTMLDivElement>;
  visit: Ref<HTMLButtonElement>;
};

function DeploymentDetailsCard({
  targetRefs,
}: {
  targetRefs: DeploymentDetailsTargetRefs;
}) {
  return (
    <section aria-labelledby="deployment-details-title">
      <header>
        <h2 id="deployment-details-title">Deployment details</h2>
        <button ref={targetRefs.logs} type="button">
          Logs
        </button>
        <button ref={targetRefs.visit} type="button">
          Visit
        </button>
      </header>

      <dl>
        <div ref={targetRefs.status}>
          <dt>Status</dt>
          <dd>Ready</dd>
        </div>
        <div ref={targetRefs.domains}>
          <dt>Domains</dt>
          <dd>preview.example.com</dd>
        </div>
        <div ref={targetRefs.source}>
          <dt>Source</dt>
          <dd>
            <code>feat/product-tour</code>
          </dd>
        </div>
      </dl>
    </section>
  );
}

const deploymentTourSteps = [
  {
    align: "start",
    description: "Check that the latest deployment finished successfully.",
    key: "status",
    padding: 8,
    radius: 8,
    side: "bottom",
    title: "Confirm deployment status",
  },
  {
    align: "start",
    description: "Find the generated domain for this preview deployment.",
    key: "domains",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Find the deployment URL",
  },
  {
    align: "start",
    description: "Review the branch to confirm what was deployed.",
    key: "source",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Verify the source",
  },
  {
    align: "center",
    description: "Open the build output to inspect each deployment step.",
    key: "logs",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Inspect the build logs",
  },
  {
    align: "end",
    description: "Open the latest preview and verify the deployed changes.",
    key: "visit",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Visit the preview",
  },
] as const;
```

## Animating the content

`Coachmark.Viewport` extends Base UI's `Popover.Viewport` which allows us to create content transitions between steps. It's only really needed for displaying content transitions.

```tsx
import { type Ref, useRef } from "react";
import { Coachmark } from "coachmark";
import { cn } from "@/lib/utils";

export function ProductTour() {
  const statusRef = useRef<HTMLDivElement>(null);
  const domainsRef = useRef<HTMLDivElement>(null);
  const sourceRef = useRef<HTMLDivElement>(null);
  const logsRef = useRef<HTMLButtonElement>(null);
  const visitRef = useRef<HTMLButtonElement>(null);
  const targetRefs = {
    domains: domainsRef,
    logs: logsRef,
    source: sourceRef,
    status: statusRef,
    visit: visitRef,
  };

  return (
    <Coachmark.Root>
      <Coachmark.Trigger>Start tour</Coachmark.Trigger>
      <Coachmark.Backdrop
        className={cn(
          "fixed inset-0 z-60 bg-black/48 data-starting-style:opacity-0",
          "data-ending-style:opacity-0 motion-reduce:transition-none",
          "[transition:clip-path_240ms_ease-in-out,opacity_180ms_ease-out]",
          "data-concealed:transition-none",
        )}
      />

      {deploymentTourSteps.map((step) => (
        <Coachmark.Step
          key={step.key}
          spotlightPadding={step.padding}
          spotlightRadius={step.radius}
          target={targetRefs[step.key]}
        >
          <Coachmark.Positioner
            align={step.align}
            className={cn(
              "z-70 motion-reduce:transition-none",
              "[transition:top_240ms_ease-in-out,left_240ms_ease-in-out,transform_240ms_ease-in-out,opacity_180ms_ease-out]",
              "[&[data-concealed][data-motion-state=repositioning]]:transition-none",
              "h-[var(--positioner-height,max-content)]",
              "w-[var(--positioner-width,max-content)]",
            )}
            side={step.side}
            sideOffset={12 + step.padding}
          >
            <Coachmark.Popup
              className={cn(
                "relative box-border flex max-w-[calc(100vw-2rem)]",
                "origin-(--transform-origin) flex-col rounded-xl border bg-popover",
                "text-popover-foreground shadow-xl motion-reduce:transition-none",
                "[transition:width_240ms_ease-in-out,height_240ms_ease-in-out,opacity_180ms_ease-out,transform_180ms_ease-out]",
                "data-starting-style:[transform:scale(0.97)]",
                "data-starting-style:opacity-0 data-ending-style:[transform:scale(0.97)]",
                "data-ending-style:opacity-0 h-[var(--popup-height,auto)]",
                "w-[var(--popup-width,var(--coachmark-step-width))]",
              )}
            >
              <Coachmark.Viewport
                className={cn(
                  "relative min-h-0 min-w-0 flex-1 overflow-clip p-4",
                  "[--coachmark-current-x:0] [--coachmark-current-y:0]",
                  "[--coachmark-previous-x:0] [--coachmark-previous-y:0]",
                  "data-[activation-direction~=right]:[--coachmark-current-x:0.5rem]",
                  "data-[activation-direction~=right]:[--coachmark-previous-x:-0.5rem]",
                  "data-[activation-direction~=left]:[--coachmark-current-x:-0.5rem]",
                  "data-[activation-direction~=left]:[--coachmark-previous-x:0.5rem]",
                  "data-[activation-direction~=down]:[--coachmark-current-y:0.5rem]",
                  "data-[activation-direction~=down]:[--coachmark-previous-y:-0.5rem]",
                  "data-[activation-direction~=up]:[--coachmark-current-y:-0.5rem]",
                  "data-[activation-direction~=up]:[--coachmark-previous-y:0.5rem]",
                  "[&>[data-current]]:w-full [&>[data-previous]]:absolute",
                  "[&>[data-previous]]:inset-0 [&>[data-previous]]:p-4",
                  "[&>[data-current]]:[transition:opacity_240ms_ease-in-out,transform_240ms_ease-in-out,filter_240ms_ease-in-out]",
                  "[&>[data-previous]]:[transition:opacity_240ms_ease-in-out,transform_240ms_ease-in-out,filter_240ms_ease-in-out]",
                  "[&>[data-current][data-starting-style]]:[transform:translate(var(--coachmark-current-x),var(--coachmark-current-y))]",
                  "[&>[data-current][data-starting-style]]:opacity-0",
                  "[&>[data-current][data-starting-style]]:[filter:blur(3px)]",
                  "[&>[data-previous][data-ending-style]]:[transform:translate(var(--coachmark-previous-x),var(--coachmark-previous-y))]",
                  "[&>[data-previous][data-ending-style]]:opacity-0",
                  "[&>[data-previous][data-ending-style]]:[filter:blur(3px)]",
                  "motion-reduce:[&>[data-current]]:transition-none",
                  "motion-reduce:[&>[data-previous]]:transition-none",
                )}
              >
                <div
                  className="grid gap-3"
                >
                  <Coachmark.Stepper
                    className={cn(
                      "text-[0.68rem] font-semibold tracking-[0.12em] text-muted-foreground",
                      "uppercase",
                    )}
                  >
                    {({ stepCount, stepIndex }) => `Step ${stepIndex + 1} of ${stepCount}`}
                  </Coachmark.Stepper>
                  <div
                    className="grid gap-1.5"
                  >
                    <Coachmark.Title
                      className="text-base font-semibold"
                    >{step.title}</Coachmark.Title>
                    <Coachmark.Description
                      className="text-sm leading-5 text-muted-foreground"
                    >
                      {step.description}
                    </Coachmark.Description>
                  </div>
                </div>
              </Coachmark.Viewport>
              <div
                className="flex items-center justify-between gap-3 p-4 pt-0"
              >
                <Coachmark.Close>Skip</Coachmark.Close>
                <div
                  className="flex items-center gap-1"
                >
                  <Coachmark.Previous>Back</Coachmark.Previous>
                  <Coachmark.Next>
                    {({ isLastStep }) => (isLastStep ? "Finish" : "Next")}
                  </Coachmark.Next>
                </div>
              </div>
            </Coachmark.Popup>
          </Coachmark.Positioner>
        </Coachmark.Step>
      ))}

      <DeploymentDetailsCard targetRefs={targetRefs} />
    </Coachmark.Root>
  );
}

type DeploymentDetailsTargetRefs = {
  domains: Ref<HTMLDivElement>;
  logs: Ref<HTMLButtonElement>;
  source: Ref<HTMLDivElement>;
  status: Ref<HTMLDivElement>;
  visit: Ref<HTMLButtonElement>;
};

function DeploymentDetailsCard({
  targetRefs,
}: {
  targetRefs: DeploymentDetailsTargetRefs;
}) {
  return (
    <section aria-labelledby="deployment-details-title">
      <header>
        <h2 id="deployment-details-title">Deployment details</h2>
        <button ref={targetRefs.logs} type="button">
          Logs
        </button>
        <button ref={targetRefs.visit} type="button">
          Visit
        </button>
      </header>

      <dl>
        <div ref={targetRefs.status}>
          <dt>Status</dt>
          <dd>Ready</dd>
        </div>
        <div ref={targetRefs.domains}>
          <dt>Domains</dt>
          <dd>preview.example.com</dd>
        </div>
        <div ref={targetRefs.source}>
          <dt>Source</dt>
          <dd>
            <code>feat/product-tour</code>
          </dd>
        </div>
      </dl>
    </section>
  );
}

const deploymentTourSteps = [
  {
    align: "start",
    description: "Check that the latest deployment finished successfully.",
    key: "status",
    padding: 8,
    radius: 8,
    side: "bottom",
    title: "Confirm deployment status",
  },
  {
    align: "start",
    description: "Find the generated domain for this preview deployment.",
    key: "domains",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Find the deployment URL",
  },
  {
    align: "start",
    description: "Review the branch to confirm what was deployed.",
    key: "source",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Verify the source",
  },
  {
    align: "center",
    description: "Open the build output to inspect each deployment step.",
    key: "logs",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Inspect the build logs",
  },
  {
    align: "end",
    description: "Open the latest preview and verify the deployed changes.",
    key: "visit",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Visit the preview",
  },
] as const;
```

## Out of view targets

It's not recommended to include targets that are out of view because it can be disorienting to the user. That being said, it's a situation that Coachmark handles automatically for you. When the next target is outside the visible area, the current coach mark is concealed, the next target is scrolled into view, and the next step is shown. This works with the page as well as nested scroll containers. You can use the `scrollIntoView` prop to modify the scroll behavior or disable it to make it instant.

## Playground

The goal of Coachmark is for you to make it your own. Below are some controls for you to get a feel of what you could do with it.

```tsx
import { type Ref, useRef } from "react";
import { Coachmark } from "coachmark";
import { cn } from "@/lib/utils";

export function ProductTour() {
  const statusRef = useRef<HTMLDivElement>(null);
  const domainsRef = useRef<HTMLDivElement>(null);
  const sourceRef = useRef<HTMLDivElement>(null);
  const logsRef = useRef<HTMLButtonElement>(null);
  const visitRef = useRef<HTMLButtonElement>(null);
  const targetRefs = {
    domains: domainsRef,
    logs: logsRef,
    source: sourceRef,
    status: statusRef,
    visit: visitRef,
  };

  return (
    <Coachmark.Root>
      <Coachmark.Trigger>Start tour</Coachmark.Trigger>
      <Coachmark.Backdrop
        className={cn(
          "fixed inset-0 z-60 bg-black/48 data-starting-style:opacity-0",
          "data-ending-style:opacity-0 motion-reduce:transition-none",
          "[transition:clip-path_240ms_ease-in-out,opacity_180ms_ease-out]",
          "data-concealed:transition-none",
        )}
      />

      {deploymentTourSteps.map((step) => (
        <Coachmark.Step
          key={step.key}
          spotlightPadding={8}
          spotlightRadius={8}
          target={targetRefs[step.key]}
        >
          <Coachmark.Positioner
            align={step.align}
            alignOffset={0}
            className={cn(
              "z-70 motion-reduce:transition-none",
              "[transition:top_240ms_ease-in-out,left_240ms_ease-in-out,transform_240ms_ease-in-out,opacity_180ms_ease-out]",
              "[&[data-concealed][data-motion-state=repositioning]]:transition-none",
              "h-[var(--positioner-height,max-content)]",
              "w-[var(--positioner-width,max-content)]",
            )}
            side={step.side}
            sideOffset={12}
          >
            <Coachmark.Popup
              className={cn(
                "relative box-border flex max-w-[calc(100vw-2rem)]",
                "origin-(--transform-origin) flex-col rounded-xl border bg-popover",
                "text-popover-foreground shadow-xl motion-reduce:transition-none",
                "[transition:width_240ms_ease-in-out,height_240ms_ease-in-out,opacity_180ms_ease-out,transform_180ms_ease-out]",
                "data-starting-style:[transform:scale(0.97)]",
                "data-starting-style:opacity-0 data-ending-style:[transform:scale(0.97)]",
                "data-ending-style:opacity-0 h-[var(--popup-height,auto)]",
                "w-[var(--popup-width,var(--coachmark-step-width))]",
              )}
            >
              <Coachmark.Viewport
                className={cn(
                  "relative min-h-0 min-w-0 flex-1 overflow-clip p-4",
                  "[--coachmark-current-x:0] [--coachmark-current-y:0]",
                  "[--coachmark-previous-x:0] [--coachmark-previous-y:0]",
                  "data-[activation-direction~=right]:[--coachmark-current-x:0.5rem]",
                  "data-[activation-direction~=right]:[--coachmark-previous-x:-0.5rem]",
                  "data-[activation-direction~=left]:[--coachmark-current-x:-0.5rem]",
                  "data-[activation-direction~=left]:[--coachmark-previous-x:0.5rem]",
                  "data-[activation-direction~=down]:[--coachmark-current-y:0.5rem]",
                  "data-[activation-direction~=down]:[--coachmark-previous-y:-0.5rem]",
                  "data-[activation-direction~=up]:[--coachmark-current-y:-0.5rem]",
                  "data-[activation-direction~=up]:[--coachmark-previous-y:0.5rem]",
                  "[&>[data-current]]:w-full [&>[data-previous]]:absolute",
                  "[&>[data-previous]]:inset-0 [&>[data-previous]]:p-4",
                  "[&>[data-current]]:[transition:opacity_240ms_ease-in-out,transform_240ms_ease-in-out,filter_240ms_ease-in-out]",
                  "[&>[data-previous]]:[transition:opacity_240ms_ease-in-out,transform_240ms_ease-in-out,filter_240ms_ease-in-out]",
                  "[&>[data-current][data-starting-style]]:[transform:translate(var(--coachmark-current-x),var(--coachmark-current-y))]",
                  "[&>[data-current][data-starting-style]]:opacity-0",
                  "[&>[data-current][data-starting-style]]:[filter:blur(3px)]",
                  "[&>[data-previous][data-ending-style]]:[transform:translate(var(--coachmark-previous-x),var(--coachmark-previous-y))]",
                  "[&>[data-previous][data-ending-style]]:opacity-0",
                  "[&>[data-previous][data-ending-style]]:[filter:blur(3px)]",
                  "motion-reduce:[&>[data-current]]:transition-none",
                  "motion-reduce:[&>[data-previous]]:transition-none",
                )}
              >
                <div
                  className="grid gap-3"
                >
                  <Coachmark.Stepper
                    className={cn(
                      "text-[0.68rem] font-semibold tracking-[0.12em] text-muted-foreground",
                      "uppercase",
                    )}
                  >
                    {({ stepCount, stepIndex }) => `Step ${stepIndex + 1} of ${stepCount}`}
                  </Coachmark.Stepper>
                  <div
                    className="grid gap-1.5"
                  >
                    <Coachmark.Title
                      className="text-base font-semibold"
                    >{step.title}</Coachmark.Title>
                    <Coachmark.Description
                      className="text-sm leading-5 text-muted-foreground"
                    >
                      {step.description}
                    </Coachmark.Description>
                  </div>
                </div>
              </Coachmark.Viewport>
              <div
                className="flex items-center justify-between gap-3 p-4 pt-0"
              >
                <Coachmark.Close>Skip</Coachmark.Close>
                <div
                  className="flex items-center gap-1"
                >
                  <Coachmark.Previous>Back</Coachmark.Previous>
                  <Coachmark.Next>
                    {({ isLastStep }) => (isLastStep ? "Finish" : "Next")}
                  </Coachmark.Next>
                </div>
              </div>
            </Coachmark.Popup>
          </Coachmark.Positioner>
        </Coachmark.Step>
      ))}

      <DeploymentDetailsCard targetRefs={targetRefs} />
    </Coachmark.Root>
  );
}

type DeploymentDetailsTargetRefs = {
  domains: Ref<HTMLDivElement>;
  logs: Ref<HTMLButtonElement>;
  source: Ref<HTMLDivElement>;
  status: Ref<HTMLDivElement>;
  visit: Ref<HTMLButtonElement>;
};

function DeploymentDetailsCard({
  targetRefs,
}: {
  targetRefs: DeploymentDetailsTargetRefs;
}) {
  return (
    <section aria-labelledby="deployment-details-title">
      <header>
        <h2 id="deployment-details-title">Deployment details</h2>
        <button ref={targetRefs.logs} type="button">
          Logs
        </button>
        <button ref={targetRefs.visit} type="button">
          Visit
        </button>
      </header>

      <dl>
        <div ref={targetRefs.status}>
          <dt>Status</dt>
          <dd>Ready</dd>
        </div>
        <div ref={targetRefs.domains}>
          <dt>Domains</dt>
          <dd>preview.example.com</dd>
        </div>
        <div ref={targetRefs.source}>
          <dt>Source</dt>
          <dd>
            <code>feat/product-tour</code>
          </dd>
        </div>
      </dl>
    </section>
  );
}

const deploymentTourSteps = [
  {
    align: "start",
    description: "Check that the latest deployment finished successfully.",
    key: "status",
    padding: 8,
    radius: 8,
    side: "bottom",
    title: "Confirm deployment status",
  },
  {
    align: "start",
    description: "Find the generated domain for this preview deployment.",
    key: "domains",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Find the deployment URL",
  },
  {
    align: "start",
    description: "Review the branch to confirm what was deployed.",
    key: "source",
    padding: 8,
    radius: 8,
    side: "top",
    title: "Verify the source",
  },
  {
    align: "center",
    description: "Open the build output to inspect each deployment step.",
    key: "logs",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Inspect the build logs",
  },
  {
    align: "end",
    description: "Open the latest preview and verify the deployed changes.",
    key: "visit",
    padding: 2,
    radius: 12,
    side: "bottom",
    title: "Visit the preview",
  },
] as const;
```

