Aura Design System

Tour

A short stepped guide that highlights one target at a time.

Preview

Loading...
import { Tour, TourTarget } from "@/components/ui/Tour";

export function TourDemo() {
  return (
  <Tour
    defaultOpen
    steps={[
      { id: "tour-color", title: "Color", body: "Accent steps carry the brand." },
      { id: "tour-type", title: "Type", body: "Headings use the fluid scale." },
    ]}
  >
    <div className="flex gap-1">
      <TourTarget id="tour-color">Color</TourTarget>
      <TourTarget id="tour-type">Type</TourTarget>
    </div>
  </Tour>
)

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/tour

Manual

Copy and paste the button component into your components/ui/Button.tsx file.

/**
 * @description Displays a button or a component that looks like a button.
 */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";
import { Spinner } from "@/components/ui/Spinner";

const buttonVariants = cva("button", {
  variants: {
    variant: {
      default: "button-fill",
      fill: "button-fill",
      pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3",
      link: "button-link",
      menu: "button-menu",
    },
    size: {
      default: "h-4",
      xs: "h-2.5",
      sm: "h-3",
      md: "h-4",
      lg: "h-5",
      xl: "h-6",
      icon: "w-3 h-3 p-0",
      "icon-md": "w-4 h-4 p-0",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "default",
  },
});

interface ButtonProps
  extends React.ComponentProps<"button">,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  isDisabled?: boolean;
  isLoading?: boolean;
  isLoadingText?: string | React.ReactNode;
  mode?: VariantProps<typeof buttonVariants>["variant"];
  label?: string | React.ReactNode;
}


const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props: ButtonProps, ref) => {
    const {
      className,
      variant,
      mode,
      size,
      asChild = false,
      isDisabled,
      isLoading,
      isLoadingText,
      children,
      label,
      ...rest
    } = props;
    const Comp = asChild ? Slot : "button";
    const disabled = isDisabled || isLoading || props.disabled;
    const effectiveVariant = variant ?? mode;

    return (
      <Comp
        data-slot="button"
        className={cn(
          buttonVariants({ variant: effectiveVariant, size, className }),
          disabled && "opacity-50 cursor-not-allowed"
        )}
        ref={ref}
        disabled={disabled}
        {...rest}
      >
        {asChild ? (
          children
        ) : (
          <>
      
            {isLoading && isLoadingText ? (
              isLoadingText
            ) : (
              <>
                {isLoading ? <Spinner /> : null}
                {label}
                {children}
              </>
            )}
          </>
        )}
      </Comp>
    );
  }
);

Button.displayName = "Button";

export { Button, buttonVariants };
export type { ButtonProps };
export default Button;

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the Tour component into your components/ui/Tour.tsx file.

"use client";

import * as React from "react";
import { cn } from "@/utils/class-names";
import { Button } from "@/components/ui/Button";

type TourStep = {
  id: string;
  title: string;
  body: string;
};

type TourContextValue = {
  activeId?: string;
};

const TourContext = React.createContext<TourContextValue>({});

function Tour({
  className,
  steps,
  defaultOpen = false,
  children,
  label = "Tour",
}: {
  className?: string;
  steps: TourStep[];
  defaultOpen?: boolean;
  children?: React.ReactNode;
  label?: string;
}) {
  const [open, setOpen] = React.useState(defaultOpen);
  const [index, setIndex] = React.useState(0);
  const dialogRef = React.useRef<HTMLDivElement>(null);
  const titleId = React.useId();
  const step = steps[index];

  React.useEffect(() => {
    if (!open) return;
    dialogRef.current?.focus();
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false);
      if (event.key === "ArrowRight") setIndex((current) => Math.min(steps.length - 1, current + 1));
      if (event.key === "ArrowLeft") setIndex((current) => Math.max(0, current - 1));
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, steps.length, index]);

  return (
    <TourContext.Provider value={{ activeId: open ? step?.id : undefined }}>
      <div data-slot="tour" className={cn("flex flex-col gap-1", className)}>
        {children}
        {open && step ? (
          <div
            ref={dialogRef}
            role="dialog"
            aria-modal="false"
            aria-label={label}
            aria-labelledby={titleId}
            tabIndex={-1}
            className="flex flex-col gap-0.5 rounded-sm border border-gray-6 bg-gray-1 p-1 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8"
          >
            <p id={titleId} className="m-0 font-medium">
              {step.title}
            </p>
            <p className="m-0 text-gray-11">{step.body}</p>
            <div className="flex items-center gap-0.5">
              <Button
                type="button"
                variant="pill"
                size="sm"
                disabled={index === 0}
                onClick={() => setIndex((current) => Math.max(0, current - 1))}
              >
                Back
              </Button>
              {index < steps.length - 1 ? (
                <Button
                  type="button"
                  size="sm"
                  onClick={() => setIndex((current) => Math.min(steps.length - 1, current + 1))}
                >
                  Next
                </Button>
              ) : (
                <Button type="button" size="sm" onClick={() => setOpen(false)}>
                  Done
                </Button>
              )}
              <span className="text-gray-11">
                {index + 1} / {steps.length}
              </span>
            </div>
          </div>
        ) : (
          <Button
            type="button"
            variant="pill"
            size="sm"
            className="w-fit"
            onClick={() => {
              setIndex(0);
              setOpen(true);
            }}
          >
            Start tour
          </Button>
        )}
      </div>
    </TourContext.Provider>
  );
}

function TourTarget({
  id,
  className,
  children,
}: {
  id: string;
  className?: string;
  children?: React.ReactNode;
}) {
  const { activeId } = React.useContext(TourContext);
  const active = activeId === id;
  return (
    <div
      id={id}
      data-slot="tour-target"
      className={cn(
        "rounded-sm border border-gray-6 p-1",
        active && "ring-2 ring-accent-8",
        className,
      )}
    >
      {children}
    </div>
  );
}

export { Tour, TourTarget };
export type { TourStep };