All components
Intro Disclosure
modalsIntro disclosure component with expandable content and smooth animations
responsive · 680px
Install
Same command in any shadcn project — Next.js:
$
npx shadcn@latest add https://your-domain/r/intro-disclosure.jsonUsage
"use client"
import { useState } from "react"
import { IntroDisclosure } from "@/registry/cult-ui/intro-disclosure"
const steps = [
{
title: "Welcome to the App",
short_description: "Let us show you around in just a few steps.",
full_description: "This quick tour will walk you through the key features that help you get the most out of your experience.",
media: {
type: "image" as const,
src: "https://picsum.photos/seed/intro1/800/450",
alt: "Welcome screen",
},
},
{
title: "Explore the Dashboard",
short_description: "Your central hub for all activity and insights.",
full_description: "The dashboard gives you an at-a-glance view of your projects, recent activity, and key metrics all in one place.",
media: {
type: "image" as const,
src: "https://picsum.photos/seed/intro2/800/450",
alt: "Dashboard overview",
},
},
{
title: "Collaborate with Your Team",
short_description: "Invite teammates and work together in real time.",
full_description: "Share projects, assign tasks, and communicate with your team without ever leaving the app.",
action: {
label: "Invite Teammates",
onClick: () => console.log("Invite clicked"),
},
media: {
type: "image" as const,
src: "https://picsum.photos/seed/intro3/800/450",
alt: "Team collaboration",
},
},
]
export default function Demo() {
const [open, setOpen] = useState(true)
return (
<div className="flex flex-col items-center justify-center w-full min-h-48 gap-4 p-8">
{!open && (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium"
onClick={() => setOpen(true)}
>
Open Feature Tour
</button>
)}
<IntroDisclosure
steps={steps}
featureId="demo-tour"
open={open}
setOpen={setOpen}
onComplete={() => console.log("Tour complete")}
onSkip={() => console.log("Tour skipped")}
/>
</div>
)
}Component source
"use client"
import * as React from "react"
import Image from "next/image"
import { CheckIcon, ExternalLinkIcon } from "lucide-react"
import {
AnimatePresence,
motion,
useAnimation,
type PanInfo,
} from "motion/react"
import { cn } from "@/lib/utils"
import { AspectRatio } from "@/components/ui/aspect-ratio"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Progress } from "@/components/ui/progress"
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState<boolean | null>(null)
React.useEffect(() => {
const media = window.matchMedia(query)
setMatches(media.matches)
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
}, [query])
return matches ?? false
}
function useFeatureVisibility(
featureId: string,
variant: "desktop" | "mobile"
) {
const storageKey = `feature_${featureId}_${variant}`
const [isVisible, setIsVisible] = React.useState<boolean | null>(null)
React.useEffect(() => {
const storedValue = localStorage.getItem(storageKey)
setIsVisible(storedValue ? JSON.parse(storedValue) : true)
}, [storageKey])
const hideFeature = () => {
localStorage.setItem(storageKey, JSON.stringify(false))
setIsVisible(false)
}
return { isVisible: isVisible === null ? false : isVisible, hideFeature }
}
function useSwipe(onSwipe: (direction: "left" | "right") => void) {
const handleDragEnd = (
event: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo
) => {
if (info.offset.x > 100) {
onSwipe("right")
} else if (info.offset.x < -100) {
onSwipe("left")
}
}
return { handleDragEnd }
}
const fadeInScale = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] as const },
}
const slideInOut = (direction: 1 | -1) => ({
initial: { opacity: 0, x: 20 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 * direction },
transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const },
})
const hoverScale = {
whileHover: { scale: 1.01 },
whileTap: { scale: 0.95 },
transition: { duration: 0.2 },
}
function StepPreview({ step, direction }: { step: Step; direction: 1 | -1 }) {
const controls = useAnimation()
React.useEffect(() => {
controls.start({
opacity: 1,
y: 0,
transition: { delay: 0.2, duration: 0.3 },
})
}, [controls, step])
return (
<motion.div
{...slideInOut(direction)}
className="relative h-full w-full overflow-hidden rounded-sm rounded-rb-lg rounded-tl-xl ring-2 ring-black/10 dark:ring-black/10 dark:ring-offset-black ring-offset-8"
>
{step.media ? (
<div className="relative bg-black h-full w-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={controls}
className="h-full w-full max-h-[700px]"
>
{step.media.type === "image" ? (
<Image
src={step.media.src || "/placeholder.svg"}
alt={step.media.alt || ""}
fill
className="object-cover"
/>
) : (
<video
src={step.media.src}
controls
className="h-full w-full object-cover"
/>
)}
</motion.div>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={controls}
className="absolute bottom-0 left-0 right-0 p-6"
>
<h3 className="mb-2 text-2xl font-semibold text-white">
{step.title}
</h3>
<p className="text-white hidden md:block">
{step.full_description}
</p>
</motion.div>
</div>
) : (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={controls}
className="text-center"
>
<h3 className="mb-2 text-2xl font-semibold text-primary">
{step.title}
</h3>
<p className="text-muted-foreground">{step.full_description}</p>
</motion.div>
</div>
)}
</motion.div>
)
}
interface StepTabProps {
step: Step
isActive: boolean
onClick: () => void
isCompleted: boolean
}
function StepTab({ step, isActive, onClick, isCompleted }: StepTabProps) {
return (
<motion.button
{...hoverScale}
onClick={onClick}
className={cn(
"flex flex-col items-start rounded-lg px-4 py-2 text-left transition-colors w-full",
isActive ? "bg-muted border border-border" : "hover:bg-muted/70",
"relative"
)}
aria-current={isActive ? "step" : undefined}
aria-label={`${step.title}${isCompleted ? " (completed)" : ""}`}
>
<div className="mb-1 text-sm font-medium">{step.title}</div>
<div className="text-xs hidden md:block text-muted-foreground line-clamp-2">
{step.short_description}
</div>
{isCompleted && (
<motion.div {...fadeInScale} className="absolute right-2 top-2">
<div className="rounded-full bg-primary p-1">
<CheckIcon className="w-2 h-2 text-primary-foreground" />
</div>
</motion.div>
)}
</motion.button>
)
}
interface Step {
title: string
short_description: string
full_description: string
action?: {
label: string
onClick?: () => void
href?: string
}
media?: {
type: "image" | "video"
src: string
alt?: string
}
}
interface FeatureDisclosureProps {
steps: Step[]
featureId: string
onComplete?: () => void
onSkip?: () => void
showProgressBar?: boolean
open: boolean
setOpen: (open: boolean) => void
forceVariant?: "mobile" | "desktop"
}
interface StepContentProps {
steps: Step[]
currentStep: number
onSkip: () => void
onNext: () => void
onPrevious: () => void
hideFeature: () => void
completedSteps: number[]
onStepSelect: (index: number) => void
direction: 1 | -1
isDesktop: boolean
}
function StepContent({
steps,
currentStep,
onSkip,
onNext,
onPrevious,
hideFeature,
completedSteps,
onStepSelect,
direction,
isDesktop,
stepRef,
}: StepContentProps & { stepRef: React.RefObject<HTMLButtonElement | null> }) {
const [skipNextTime, setSkipNextTime] = React.useState(false)
const renderActionButton = (action: Step["action"]) => {
if (!action) return null
if (action.href) {
return (
<Button asChild className="w-full " size="sm" variant="link">
<a href={action.href} target="_blank" rel="noopener noreferrer">
<span className="flex items-center gap-2">
{action.label}
<ExternalLinkIcon className="w-4 h-4" />
</span>
</a>
</Button>
)
}
return (
<Button
className="w-full rounded-full"
size="sm"
variant="secondary"
onClick={action.onClick}
>
{action.label}
</Button>
)
}
return (
<div className="flex h-full flex-col max-w-3xl mx-auto">
{isDesktop && (
<div className="flex-1 px-2 py-3">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="space-y-2 flex flex-col justify-center items-center px-1"
>
{steps.map((step, index) => (
<StepTab
key={index}
step={step}
isActive={currentStep === index}
onClick={() => onStepSelect(index)}
isCompleted={completedSteps.includes(index)}
/>
))}
</motion.div>
</div>
)}
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentStep}
{...slideInOut(direction)}
className="mt-6 space-y-4 "
>
{!isDesktop && steps[currentStep]?.media && (
<AspectRatio
ratio={16 / 9}
className="lg:overflow-hidden rounded-lg bg-muted "
>
{steps[currentStep]?.media?.type === "image" ? (
<Image
src={steps[currentStep]?.media?.src || "/placeholder.svg"}
alt={steps[currentStep]?.media?.alt || ""}
fill
className="object-cover "
/>
) : (
<video
src={steps[currentStep]?.media?.src}
controls
className="h-full w-full object-cover"
/>
)}
</AspectRatio>
)}
{steps[currentStep]?.action ? (
<div className=" px-2">
{renderActionButton(steps[currentStep]?.action)}
</div>
) : (
<div className="h-10" />
)}
{/* Navigation buttons */}
<div className="flex items-center justify-between pr-4">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground hover:bg-card rounded-full"
>
Skip all
</Button>
<div className="space-x-2">
{currentStep > 0 && (
<Button
onClick={onPrevious}
size="sm"
variant="ghost"
className="rounded-full hover:bg-transparent"
>
Previous
</Button>
)}
<Button
onClick={() => {
if (skipNextTime) {
hideFeature()
}
onNext()
}}
size="sm"
ref={stepRef}
className="rounded-full"
>
{currentStep === steps.length - 1 ? "Done" : "Next"}
</Button>
</div>
{/* Don't show again checkbox */}
</div>
<div className="flex items-center space-x-2 pb-4 px-4">
<Checkbox
id="skipNextTime"
checked={skipNextTime}
onCheckedChange={(checked) => setSkipNextTime(checked as boolean)}
/>
<label
htmlFor="skipNextTime"
className="text-sm text-muted-foreground"
>
Don't show this again
</label>
</div>
</motion.div>
</AnimatePresence>
</div>
)
}
export function IntroDisclosure({
steps,
open,
setOpen,
featureId,
onComplete,
onSkip,
showProgressBar = true,
forceVariant,
}: FeatureDisclosureProps) {
const [currentStep, setCurrentStep] = React.useState(0)
const [completedSteps, setCompletedSteps] = React.useState<number[]>([0])
const [direction, setDirection] = React.useState<1 | -1>(1)
const isDesktopQuery = useMediaQuery("(min-width: 768px)")
const isDesktop = forceVariant ? forceVariant === "desktop" : isDesktopQuery
const variant = isDesktop ? "desktop" : "mobile"
const { isVisible, hideFeature } = useFeatureVisibility(featureId, variant)
const stepRef = React.useRef<HTMLButtonElement>(null)
// Close the dialog if feature is hidden
React.useEffect(() => {
if (!isVisible) {
setOpen(false)
}
}, [isVisible, setOpen])
// Focus management
React.useEffect(() => {
if (open && stepRef.current) {
stepRef.current.focus()
}
}, [open, currentStep])
// Early return if feature should be hidden
if (!isVisible || !open) {
return null
}
const handleNext = () => {
setDirection(1)
setCompletedSteps((prev) =>
prev.includes(currentStep) ? prev : [...prev, currentStep]
)
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
setOpen(false)
onComplete?.()
}
}
const handlePrevious = () => {
setDirection(-1)
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
setOpen(false)
onSkip?.()
}
const handleStepSelect = (index: number) => {
setDirection(index > currentStep ? 1 : -1)
// Mark all steps up to and including the selected step as completed
setCompletedSteps((prev) => {
const newCompletedSteps = new Set(prev)
// If moving forward, mark all steps up to the selected one as completed
if (index > currentStep) {
for (let i = currentStep; i <= index; i++) {
newCompletedSteps.add(i)
}
}
return Array.from(newCompletedSteps)
})
setCurrentStep(index)
}
const handleSwipe = (swipeDirection: "left" | "right") => {
if (swipeDirection === "left") {
handleNext()
} else {
handlePrevious()
}
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
handleNext()
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
handlePrevious()
}
}
const { handleDragEnd } = useSwipe(handleSwipe)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-[calc(100dvw-2rem)] p-0 gap-0 overflow-hidden sm:max-w-5xl"
onKeyDown={handleKeyDown}
>
<DialogHeader className="p-6 space-y-2 bg-muted border-b border-border">
<DialogTitle>Feature Tour</DialogTitle>
{showProgressBar && (
<div className="flex mt-2 w-full justify-center ">
<Progress
value={((currentStep + 1) / steps.length) * 100}
className=" h-1 "
/>
</div>
)}
</DialogHeader>
<div className="grid grid-cols-2 h-full">
<div className="p-2 pr-[18px] ">
<StepContent
steps={steps}
currentStep={currentStep}
onSkip={handleSkip}
onNext={handleNext}
onPrevious={handlePrevious}
hideFeature={hideFeature}
completedSteps={completedSteps}
onStepSelect={handleStepSelect}
direction={direction}
isDesktop={isDesktop}
stepRef={stepRef}
/>
</div>
<AnimatePresence mode="wait" initial={false}>
<StepPreview
key={currentStep}
step={steps[currentStep]}
direction={direction}
/>
</AnimatePresence>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="h-[95vh] max-h-[95vh] ">
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={handleDragEnd}
onKeyDown={handleKeyDown}
className="h-full flex flex-col max-w-3xl mx-auto"
>
<DrawerHeader className="text-left pb-4 space-y-4">
{showProgressBar && (
<Progress
value={((currentStep + 1) / steps.length) * 100}
className="mb-4"
/>
)}
<DrawerTitle>{steps[currentStep]?.title}</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4 pb-32">
{/* Step tabs */}
<div className="grid grid-cols-2 gap-2 mb-6">
{steps.map((step, index) => (
<StepTab
key={index}
step={step}
isActive={currentStep === index}
onClick={() => handleStepSelect(index)}
isCompleted={completedSteps.includes(index)}
/>
))}
</div>
{/* Preview */}
<div className="relative aspect-[16/9] ring-2 ring-border ring-offset-8 ring-offset-background rounded-lg overflow-hidden">
<StepPreview step={steps[currentStep]} direction={direction} />
</div>
{/* Step content */}
<div className="space-y-4 border border-border p-3 rounded-lg">
<p className="text-muted-foreground">
{steps[currentStep]?.short_description}
</p>
{steps[currentStep]?.action && (
<Button
asChild
className="w-full"
variant={
steps[currentStep]?.action?.href ? "outline" : "default"
}
>
{steps[currentStep]?.action?.href ? (
<a
href={steps[currentStep]?.action?.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2"
>
{steps[currentStep]?.action?.label}
<ExternalLinkIcon className="h-4 w-4" />
</a>
) : (
<button onClick={steps[currentStep]?.action?.onClick}>
{steps[currentStep]?.action?.label}
</button>
)}
</Button>
)}
</div>
</div>
</div>
{/* Fixed bottom navigation */}
<div className="absolute bottom-0 left-0 right-0 border-t bg-background">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground hover:bg-card rounded-full"
>
Skip all
</Button>
<div className="space-x-2">
{currentStep > 0 && (
<Button
onClick={handlePrevious}
size="sm"
variant="ghost"
className="rounded-full hover:bg-transparent"
>
Previous
</Button>
)}
<Button
onClick={() => {
handleNext()
}}
size="sm"
ref={stepRef}
className="rounded-full"
>
{currentStep === steps.length - 1 ? "Done" : "Next"}
</Button>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="skipNextTime"
onCheckedChange={(checked) => {
hideFeature()
}}
/>
<label
htmlFor="skipNextTime"
className="text-sm text-muted-foreground"
>
Don't show this again
</label>
</div>
</div>
</div>
</motion.div>
</DrawerContent>
</Drawer>
)
}
export default IntroDisclosureDependencies
motionreact-use-measure
Registry dependencies
buttonaspect-ratio
Source: Cult UI