Tour

Guided onboarding flow for introducing content or interface patterns.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/tour.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { useState } from "react"; import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Button } from "../button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../card"; export type TourStep = { badge?: string; description: ReactNode; hint?: ReactNode; id: string; media?: ReactNode; title: string; }; export type TourProps = { className?: string; defaultStep?: number; onComplete?: () => void; onStepChange?: (stepIndex: number, step: TourStep) => void; steps: TourStep[]; }; type TourHeaderProps = { progress: number; step: TourStep; stepIndex: number; totalSteps: number; }; function TourHeader({ progress, step, stepIndex, totalSteps, }: TourHeaderProps): ReactNode { return ( <CardHeader className="gap-4 border-b bg-background/70"> <div className="flex items-center justify-between gap-3"> <div className="space-y-1"> <div className="flex items-center gap-2"> <Badge variant="secondary">Tour</Badge> {step.badge ? <Badge variant="outline">{step.badge}</Badge> : null} </div> <CardTitle>{step.title}</CardTitle> </div> <span className="text-sm text-muted-foreground"> {stepIndex + 1}/{totalSteps} </span> </div> <div className="h-1 rounded-full bg-muted"> <div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progress}%` }} /> </div> </CardHeader> ); } type TourFooterProps = { currentStep: number; isFirstStep: boolean; isLastStep: boolean; onComplete?: () => void; onStepSelect: (stepIndex: number) => void; steps: TourStep[]; }; function TourFooter({ currentStep, isFirstStep, isLastStep, onComplete, onStepSelect, steps, }: TourFooterProps): ReactNode { return ( <CardFooter className="flex items-center justify-between gap-3 border-t bg-background/70 px-6 py-4"> <Button disabled={isFirstStep} onClick={() => { onStepSelect(currentStep - 1); }} variant="outline" > Previous </Button> <div className="flex gap-2"> {steps.map((step, index) => ( <button aria-label={`Go to ${step.title}`} className={cn( "size-2.5 rounded-full transition-colors", index === currentStep ? "bg-primary" : "bg-muted-foreground/30", )} key={step.id} onClick={() => { onStepSelect(index); }} type="button" /> ))} </div> {isLastStep ? ( <Button onClick={() => { onComplete?.(); }} > Finish </Button> ) : ( <Button onClick={() => { onStepSelect(currentStep + 1); }} > Next </Button> )} </CardFooter> ); } export function Tour({ className, defaultStep = 0, onComplete, onStepChange, steps, }: TourProps): ReactNode { const [currentStep, setCurrentStep] = useState(defaultStep); if (steps.length === 0) { return null; } const activeStep = steps[currentStep]; const isFirstStep = currentStep === 0; const isLastStep = currentStep === steps.length - 1; const progress = ((currentStep + 1) / steps.length) * 100; if (!activeStep) { return null; } const handleStepSelect = (stepIndex: number): void => { const nextStep = steps[stepIndex]; if (!nextStep) { return; } setCurrentStep(stepIndex); onStepChange?.(stepIndex, nextStep); }; return ( <Card className={cn( "my-6 overflow-hidden border-primary/20 bg-gradient-to-br from-background to-primary/5", className, )} > <TourHeader progress={progress} step={activeStep} stepIndex={currentStep} totalSteps={steps.length} /> <CardContent className="space-y-4 p-6"> {activeStep.media ? ( <div className="rounded-lg border bg-card p-4"> {activeStep.media} </div> ) : null} <div className="text-sm text-muted-foreground [&>p]:mb-3"> {activeStep.description} </div> {activeStep.hint ? ( <div className="rounded-lg border border-dashed bg-muted/50 p-3 text-sm text-muted-foreground"> {activeStep.hint} </div> ) : null} </CardContent> <TourFooter currentStep={currentStep} isFirstStep={isFirstStep} isLastStep={isLastStep} onComplete={onComplete} onStepSelect={handleStepSelect} steps={steps} /> </Card> ); }

Dependencies

  • @vllnt/ui@^0.2.1