shadimage
tour
uitoursource: ok · hit

Tour

A tour component.

npx shadcn add "https://onboarding-tour.vercel.app/r/tour.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

buttoncardpopover

Files1 · source cached 10/8/2026, 7:43:48 PM

registry/new-york-v4/ui/tour.tsx
"use client"

import type { Content } from "@radix-ui/react-popover"
import { XIcon } from "lucide-react"
import Link from "next/link"
import * as React from "react"
import { createPortal } from "react-dom"

import { cn } from "@/lib/utils"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
    Card,
    CardAction,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
    Popover,
    PopoverAnchor,
    PopoverContent,
} from "@/registry/new-york-v4/ui/popover"

const TourContext = React.createContext<{
    start: (tourId: string) => void
    close: () => void
} | null>(null)

function useTour() {
    const context = React.useContext(TourContext)
    if (!context) {
        throw new Error("useTour must be used within a TourProvider")
    }
    return context
}

interface Step {
    id: string
    title: React.ReactNode
    content: React.ReactNode
    nextRoute?: string
    previousRoute?: string
    nextLabel?: React.ReactNode
    previousLabel?: React.ReactNode
    side?: React.ComponentProps<typeof Content>["side"]
    sideOffset?: React.ComponentProps<typeof Content>["sideOffset"]
    align?: React.ComponentProps<typeof Content>["align"]
    alignOffset?: React.ComponentProps<typeof Content>["alignOffset"]
    className?: string
}

interface Tour {
    id: string
    steps: Step[]
}

function TourProvider({
    tours,
    children,
}: {
    tours: Tour[]
    children: React.ReactNode
}) {
    const [isOpen, setIsOpen] = React.useState(false)
    const [activeTourId, setActiveTourId] = React.useState<string | null>(null)
    const [currentStepIndex, setCurrentStepIndex] = React.useState(0)

    const activeTour = tours.find((tour) => tour.id === activeTourId)
    const steps = activeTour?.steps || []

    function next() {
        if (currentStepIndex < steps.length - 1) {
            setCurrentStepIndex((prev) => prev + 1)
        } else {
            setIsOpen(false)
            setCurrentStepIndex(0)
            setActiveTourId(null)
        }
    }

    function previous() {
        if (currentStepIndex > 0) {
            setCurrentStepIndex((prev) => prev - 1)
        }
    }

    function close() {
        setIsOpen(false)
        setCurrentStepIndex(0)
        setActiveTourId(null)
    }

    function start(tourId: string) {
        const tour = tours.find((tour) => tour.id === tourId)
        if (tour) {
            if (tour.steps.length > 0) {
                setActiveTourId(tourId)
                setIsOpen(true)
                setCurrentStepIndex(0)
            } else {
                console.error(`Tour with id '${tourId}' has no steps.`)
            }
        } else {
            console.error(`Tour with id '${tourId}' not found.`)
        }
    }

    return (
        <TourContext.Provider
            value={{
                start,
                close,
            }}>
            {children}
            {isOpen && activeTour && steps.length > 0 && (
                <TourOverlay
                    step={steps[currentStepIndex]}
                    currentStepIndex={currentStepIndex}
                    totalSteps={steps.length}
                    onNext={next}
                    onPrevious={previous}
                    onClose={close}
                />
            )}
        </TourContext.Provider>
    )
}

function TourOverlay({
    step,
    currentStepIndex,
    totalSteps,
    onNext,
    onPrevious,
    onClose,
}: {
    step: Step
    currentStepIndex: number
    totalSteps: number
    onNext: () => void
    onPrevious: () => void
    onClose: () => void
}) {
    const [targets, setTargets] = React.useState<
        { rect: DOMRect; radius: number }[]
    >([])

    React.useEffect(() => {
        let needsScroll = true

        function updatePosition() {
            const elements = document.querySelectorAll(
                `[data-tour-step-id*='${step.id}']`
            )

            if (elements.length > 0) {
                const validElements: {
                    rect: {
                        width: number
                        height: number
                        x: number
                        y: number
                        left: number
                        top: number
                        right: number
                        bottom: number
                        toJSON: () => void
                    }
                    radius: number
                    element: Element
                }[] = []

                Array.from(elements).forEach((element) => {
                    const rect = element.getBoundingClientRect()
                    if (rect.width === 0 && rect.height === 0) return

                    const style = window.getComputedStyle(element)
                    const radius = Number(style.borderRadius) || 4

                    validElements.push({
                        rect: {
                            width: rect.width,
                            height: rect.height,
                            x: rect.left,
                            y: rect.top,
                            left: rect.left,
                            top: rect.top,
                            right: rect.right,
                            bottom: rect.bottom,
                            toJSON: () => {},
                        },
                        radius,
                        element,
                    })
                })

                setTargets(
                    validElements.map(({ rect, radius }) => ({ rect, radius }))
                )

                if (validElements.length > 0 && needsScroll) {
                    validElements[0].element.scrollIntoView({
                        behavior: "smooth",
                        block: "center",
                    })
                    needsScroll = false
                }
            } else {
                setTargets([])
            }
        }

        updatePosition()
        const handleResizeOrScroll = () => updatePosition()

        window.addEventListener("resize", handleResizeOrScroll)
        window.addEventListener("scroll", handleResizeOrScroll, true)

        const observer = new MutationObserver(() => updatePosition())
        observer.observe(document.body, {
            attributes: true,
            childList: true,
            subtree: true,
        })

        const resizeObserver = new ResizeObserver(() => updatePosition())
        resizeObserver.observe(document.body)

        return () => {
            window.removeEventListener("resize", handleResizeOrScroll)
            window.removeEventListener("scroll", handleResizeOrScroll, true)
            observer.disconnect()
            resizeObserver.disconnect()
        }
    }, [step])

    React.useEffect(() => {
        document.body.style.overflow = "hidden"
        return () => {
            document.body.style.overflow = ""
        }
    }, [])

    if (!document) return null
    if (targets.length === 0) return null

    return createPortal(
        <div className="fixed inset-0 z-50">
            <svg className="absolute inset-0 size-full">
                <defs>
                    <mask id="tour-mask">
                        <rect
                            x="0"
                            y="0"
                            width="100%"
                            height="100%"
                            fill="white"
                        />
                        {targets.map((target, i) => (
                            <rect
                                key={i}
                                x={target.rect.left}
                                y={target.rect.top}
                                width={target.rect.width}
                                height={target.rect.height}
                                rx={target.radius}
                                fill="black"
                            />
                        ))}
                    </mask>
                </defs>
                <rect
                    width="100%"
                    height="100%"
                    mask="url(#tour-mask)"
                    className="fill-black opacity-20"
                />
                {targets.map((target, i) => {
                    return (
                        <rect
                            key={i}
                            x={target.rect.left}
                            y={target.rect.top}
                            width={target.rect.width}
                            height={target.rect.height}
                            rx={target.radius}
                            className="stroke-primary fill-none stroke-2"
                        />
                    )
                })}
            </svg>
            {targets.length > 0 && (
                <Popover key={step.id} open={true}>
                    <PopoverAnchor
                        virtualRef={{
                            current: {
                                getBoundingClientRect: () =>
                                    targets[0]?.rect || {
                                        top: 0,
                                        left: 0,
                                        width: 0,
                                        height: 0,
                                        bottom: 0,
                                        right: 0,
                                        x: 0,
                                        y: 0,
                                        toJSON: () => {},
                                    },
                            },
                        }}
                    />
                    <PopoverContent
                        className={cn("px-0", step.className)}
                        side={step.side}
                        sideOffset={step.sideOffset}
                        align={step.align}
                        alignOffset={step.alignOffset}
                        onOpenAutoFocus={(e) => e.preventDefault()}
                        onCloseAutoFocus={(e) => e.preventDefault()}
                        asChild>
                        <Card>
                            <CardHeader>
                                <CardTitle>{step.title}</CardTitle>
                                <CardDescription>
                                    Step {currentStepIndex + 1} of {totalSteps}
                                </CardDescription>
                                <CardAction>
                                    <Button
                                        variant="ghost"
                                        size="icon"
                                        onClick={onClose}>
                                        <XIcon />
                                    </Button>
                                </CardAction>
                            </CardHeader>
                            <CardContent>{step.content}</CardContent>
                            <CardFooter className="justify-between">
                                {currentStepIndex > 0 &&
                                    (step.previousRoute ? (
                                        <Button
                                            variant="outline"
                                            onClick={onPrevious}
                                            asChild>
                                            <Link href={step.previousRoute}>
                                                {step.previousLabel ??
                                                    "Previous"}
                                            </Link>
                                        </Button>
                                    ) : (
                                        <Button
                                            variant="outline"
                                            onClick={onPrevious}>
                                            {step.previousLabel ?? "Previous"}
                                        </Button>
                                    ))}
                                {step.nextRoute ? (
                                    <Button
                                        className="ml-auto"
                                        onClick={onNext}
                                        asChild>
                                        <Link href={step.nextRoute}>
                                            {step.nextLabel ??
                                                (currentStepIndex ===
                                                totalSteps - 1
                                                    ? "Finish"
                                                    : "Next")}
                                        </Link>
                                    </Button>
                                ) : (
                                    <Button
                                        className="ml-auto"
                                        onClick={onNext}>
                                        {step.nextLabel ??
                                            (currentStepIndex === totalSteps - 1
                                                ? "Finish"
                                                : "Next")}
                                    </Button>
                                )}
                            </CardFooter>
                        </Card>
                    </PopoverContent>
                </Popover>
            )}
        </div>,
        document.body
    )
}

export { TourProvider, useTour, type Step, type Tour }