Dialog Stack
Composable stacked dialog component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring multi-step wizards, navigation controls, and Radix UI portal integration.
Powered by
'use client';import { DialogStack, DialogStackBody, DialogStackContent, DialogStackDescription, DialogStackFooter, DialogStackHeader, DialogStackNext, DialogStackOverlay, DialogStackPrevious, DialogStackTitle, DialogStackTrigger,} from '@/components/ui/shadcn-io/dialog-stack';import { Button } from '@/components/ui/button';const Example = () => ( <DialogStack> <DialogStackTrigger asChild> <Button variant="outline">Show me</Button> </DialogStackTrigger> <DialogStackOverlay /> <DialogStackBody> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the first dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-end"> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the second dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the third dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> </DialogStackFooter> </DialogStackContent> </DialogStackBody> </DialogStack>);export default Example;
'use client';import { useControllableState } from '@radix-ui/react-use-controllable-state';import { Portal } from 'radix-ui';import type { ButtonHTMLAttributes, Dispatch, HTMLAttributes, MouseEvent, MouseEventHandler, ReactElement, SetStateAction,} from 'react';import { Children, cloneElement, createContext, useCallback, useContext, useEffect, useState,} from 'react';import { cn } from '@/lib/utils';type DialogStackContextType = { activeIndex: number; setActiveIndex: Dispatch<SetStateAction<number>>; totalDialogs: number; setTotalDialogs: Dispatch<SetStateAction<number>>; isOpen: boolean; setIsOpen: Dispatch<SetStateAction<boolean>>; clickable: boolean;};const DialogStackContext = createContext<DialogStackContextType>({ activeIndex: 0, setActiveIndex: () => { }, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: false, setIsOpen: () => { }, clickable: false,});type DialogStackChildProps = { index?: number;};export type DialogStackProps = HTMLAttributes<HTMLDivElement> & { open?: boolean; clickable?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};export const DialogStack = ({ children, className, open, defaultOpen = false, onOpenChange, clickable = false, ...props}: DialogStackProps) => { const [activeIndex, setActiveIndex] = useState(0); const [isOpen, setIsOpen] = useControllableState({ defaultProp: defaultOpen, prop: open, onChange: onOpenChange, }); useEffect(() => { if (onOpenChange && isOpen !== undefined) { onOpenChange(isOpen); } }, [isOpen, onOpenChange]); return ( <DialogStackContext.Provider value={{ activeIndex, setActiveIndex, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: isOpen ?? false, setIsOpen: (value) => setIsOpen(Boolean(value)), clickable, }} > <div className={className} {...props}> {children} </div> </DialogStackContext.Provider> );};export type DialogStackTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackTrigger = ({ children, className, onClick, asChild, ...props}: DialogStackTriggerProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackTrigger must be used within a DialogStack'); } const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => { context.setIsOpen(true); onClick?.(e); }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleClick(e); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm', 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2', 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'bg-primary text-primary-foreground hover:bg-primary/90', 'h-10 px-4 py-2', className )} onClick={handleClick} {...props} > {children} </button> );};export type DialogStackOverlayProps = HTMLAttributes<HTMLDivElement>;export const DialogStackOverlay = ({ className, ...props}: DialogStackOverlayProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackOverlay must be used within a DialogStack'); } const handleClick = useCallback(() => { context.setIsOpen(false); }, [context.setIsOpen]); if (!context.isOpen) { return null; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable overlay" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable overlay" <div className={cn( 'fixed inset-0 z-50 bg-black/80', 'data-[state=closed]:animate-out data-[state=open]:animate-in', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} onClick={handleClick} {...props} /> );};export type DialogStackBodyProps = HTMLAttributes<HTMLDivElement> & { children: | ReactElement<DialogStackChildProps>[] | ReactElement<DialogStackChildProps>;};export const DialogStackBody = ({ children, className, ...props}: DialogStackBodyProps) => { const context = useContext(DialogStackContext); const [totalDialogs, setTotalDialogs] = useState(Children.count(children)); if (!context) { throw new Error('DialogStackBody must be used within a DialogStack'); } if (!context.isOpen) { return null; } return ( <DialogStackContext.Provider value={{ ...context, totalDialogs, setTotalDialogs, }} > <Portal.Root> <div className={cn( 'pointer-events-none fixed inset-0 z-50 mx-auto flex w-full max-w-lg flex-col items-center justify-center', className )} {...props} > <div className="pointer-events-auto relative flex w-full flex-col items-center justify-center"> {Children.map(children, (child, index) => { const childElement = child as ReactElement<{ index: number; onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(childElement, { ...childElement.props, index, }); })} </div> </div> </Portal.Root> </DialogStackContext.Provider> );};export type DialogStackContentProps = HTMLAttributes<HTMLDivElement> & { index?: number; offset?: number;};export const DialogStackContent = ({ children, className, index = 0, offset = 10, ...props}: DialogStackContentProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackContent must be used within a DialogStack'); } if (!context.isOpen) { return null; } const handleClick = () => { if (context.clickable && context.activeIndex > index) { context.setActiveIndex(index ?? 0); } }; const distanceFromActive = index - context.activeIndex; const translateY = distanceFromActive < 0 ? `-${Math.abs(distanceFromActive) * offset}px` : `${Math.abs(distanceFromActive) * offset}px`; return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable dialog" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable dialog" <div className={cn( 'h-auto w-full rounded-lg border bg-background p-6 shadow-lg transition-all duration-300', className )} onClick={handleClick} style={{ top: 0, transform: `translateY(${translateY})`, width: `calc(100% - ${Math.abs(distanceFromActive) * 10}px)`, zIndex: 50 - Math.abs(context.activeIndex - (index ?? 0)), position: distanceFromActive ? 'absolute' : 'relative', opacity: distanceFromActive > 0 ? 0 : 1, cursor: context.clickable && context.activeIndex > index ? 'pointer' : 'default', }} {...props} > <div className={cn( 'h-full w-full transition-all duration-300', context.activeIndex !== index && 'pointer-events-none select-none opacity-0' )} > {children} </div> </div> );};export type DialogStackTitleProps = HTMLAttributes<HTMLHeadingElement>;export const DialogStackTitle = ({ children, className, ...props}: DialogStackTitleProps) => ( <h2 className={cn( 'font-semibold text-lg leading-none tracking-tight', className )} {...props} > {children} </h2>);export type DialogStackDescriptionProps = HTMLAttributes<HTMLParagraphElement>;export const DialogStackDescription = ({ children, className, ...props}: DialogStackDescriptionProps) => ( <p className={cn('text-muted-foreground text-sm', className)} {...props}> {children} </p>);export type DialogStackHeaderProps = HTMLAttributes<HTMLDivElement>;export const DialogStackHeader = ({ className, ...props}: DialogStackHeaderProps) => ( <div className={cn( 'flex flex-col space-y-1.5 text-center sm:text-left', className )} {...props} />);export type DialogStackFooterProps = HTMLAttributes<HTMLDivElement>;export const DialogStackFooter = ({ children, className, ...props}: DialogStackFooterProps) => ( <div className={cn('flex items-center justify-end space-x-2 pt-4', className)} {...props} > {children} </div>);export type DialogStackNextProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean;};export const DialogStackNext = ({ children, className, asChild, ...props}: DialogStackNextProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackNext must be used within a DialogStack'); } const handleNext = () => { if (context.activeIndex < context.totalDialogs - 1) { context.setActiveIndex(context.activeIndex + 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleNext(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex >= context.totalDialogs - 1} onClick={handleNext} type="button" {...props} > {children || 'Next'} </button> );};export type DialogStackPreviousProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackPrevious = ({ children, className, asChild, ...props}: DialogStackPreviousProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackPrevious must be used within a DialogStack'); } const handlePrevious = () => { if (context.activeIndex > 0) { context.setActiveIndex(context.activeIndex - 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handlePrevious(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex <= 0} onClick={handlePrevious} type="button" {...props} > {children || 'Previous'} </button> );};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/dialog-stack.json
npx shadcn@latest add https://www.shadcn.io/registry/dialog-stack.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/dialog-stack.json
bunx shadcn@latest add https://www.shadcn.io/registry/dialog-stack.json
Features
- Stacked dialogs - Multiple dialog layers with proper z-index management using Radix UI Portal
- Wizard navigation - Step-by-step flow controls with next/previous functionality using React state
- Consistent styling - Uniform layout and appearance across all dialog instances using Tailwind CSS
- Navigation components - Built-in controls for moving between dialog steps using TypeScript handlers
- Controlled mode - External state management with programmatic navigation for Next.js applications
- Portal rendering - Proper modal stacking with escape key and overlay handling using shadcn/ui patterns
- Open source - Free dialog stack component with customizable appearance and behavior
Examples
With six dialogs
'use client';import { DialogStack, DialogStackBody, DialogStackContent, DialogStackDescription, DialogStackFooter, DialogStackHeader, DialogStackNext, DialogStackOverlay, DialogStackPrevious, DialogStackTitle, DialogStackTrigger,} from '@/components/ui/shadcn-io/dialog-stack';import { Button } from '@/components/ui/button';const Example = () => ( <DialogStack> <DialogStackTrigger asChild> <Button variant="outline">Show me</Button> </DialogStackTrigger> <DialogStackOverlay /> <DialogStackBody> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the first dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-end"> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the second dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the third dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the fourth dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the fifth dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the sixth dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-start"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> </DialogStackFooter> </DialogStackContent> </DialogStackBody> </DialogStack>);export default Example;
'use client';import { useControllableState } from '@radix-ui/react-use-controllable-state';import { Portal } from 'radix-ui';import type { ButtonHTMLAttributes, Dispatch, HTMLAttributes, MouseEvent, MouseEventHandler, ReactElement, SetStateAction,} from 'react';import { Children, cloneElement, createContext, useCallback, useContext, useEffect, useState,} from 'react';import { cn } from '@/lib/utils';type DialogStackContextType = { activeIndex: number; setActiveIndex: Dispatch<SetStateAction<number>>; totalDialogs: number; setTotalDialogs: Dispatch<SetStateAction<number>>; isOpen: boolean; setIsOpen: Dispatch<SetStateAction<boolean>>; clickable: boolean;};const DialogStackContext = createContext<DialogStackContextType>({ activeIndex: 0, setActiveIndex: () => { }, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: false, setIsOpen: () => { }, clickable: false,});type DialogStackChildProps = { index?: number;};export type DialogStackProps = HTMLAttributes<HTMLDivElement> & { open?: boolean; clickable?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};export const DialogStack = ({ children, className, open, defaultOpen = false, onOpenChange, clickable = false, ...props}: DialogStackProps) => { const [activeIndex, setActiveIndex] = useState(0); const [isOpen, setIsOpen] = useControllableState({ defaultProp: defaultOpen, prop: open, onChange: onOpenChange, }); useEffect(() => { if (onOpenChange && isOpen !== undefined) { onOpenChange(isOpen); } }, [isOpen, onOpenChange]); return ( <DialogStackContext.Provider value={{ activeIndex, setActiveIndex, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: isOpen ?? false, setIsOpen: (value) => setIsOpen(Boolean(value)), clickable, }} > <div className={className} {...props}> {children} </div> </DialogStackContext.Provider> );};export type DialogStackTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackTrigger = ({ children, className, onClick, asChild, ...props}: DialogStackTriggerProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackTrigger must be used within a DialogStack'); } const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => { context.setIsOpen(true); onClick?.(e); }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleClick(e); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm', 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2', 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'bg-primary text-primary-foreground hover:bg-primary/90', 'h-10 px-4 py-2', className )} onClick={handleClick} {...props} > {children} </button> );};export type DialogStackOverlayProps = HTMLAttributes<HTMLDivElement>;export const DialogStackOverlay = ({ className, ...props}: DialogStackOverlayProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackOverlay must be used within a DialogStack'); } const handleClick = useCallback(() => { context.setIsOpen(false); }, [context.setIsOpen]); if (!context.isOpen) { return null; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable overlay" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable overlay" <div className={cn( 'fixed inset-0 z-50 bg-black/80', 'data-[state=closed]:animate-out data-[state=open]:animate-in', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} onClick={handleClick} {...props} /> );};export type DialogStackBodyProps = HTMLAttributes<HTMLDivElement> & { children: | ReactElement<DialogStackChildProps>[] | ReactElement<DialogStackChildProps>;};export const DialogStackBody = ({ children, className, ...props}: DialogStackBodyProps) => { const context = useContext(DialogStackContext); const [totalDialogs, setTotalDialogs] = useState(Children.count(children)); if (!context) { throw new Error('DialogStackBody must be used within a DialogStack'); } if (!context.isOpen) { return null; } return ( <DialogStackContext.Provider value={{ ...context, totalDialogs, setTotalDialogs, }} > <Portal.Root> <div className={cn( 'pointer-events-none fixed inset-0 z-50 mx-auto flex w-full max-w-lg flex-col items-center justify-center', className )} {...props} > <div className="pointer-events-auto relative flex w-full flex-col items-center justify-center"> {Children.map(children, (child, index) => { const childElement = child as ReactElement<{ index: number; onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(childElement, { ...childElement.props, index, }); })} </div> </div> </Portal.Root> </DialogStackContext.Provider> );};export type DialogStackContentProps = HTMLAttributes<HTMLDivElement> & { index?: number; offset?: number;};export const DialogStackContent = ({ children, className, index = 0, offset = 10, ...props}: DialogStackContentProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackContent must be used within a DialogStack'); } if (!context.isOpen) { return null; } const handleClick = () => { if (context.clickable && context.activeIndex > index) { context.setActiveIndex(index ?? 0); } }; const distanceFromActive = index - context.activeIndex; const translateY = distanceFromActive < 0 ? `-${Math.abs(distanceFromActive) * offset}px` : `${Math.abs(distanceFromActive) * offset}px`; return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable dialog" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable dialog" <div className={cn( 'h-auto w-full rounded-lg border bg-background p-6 shadow-lg transition-all duration-300', className )} onClick={handleClick} style={{ top: 0, transform: `translateY(${translateY})`, width: `calc(100% - ${Math.abs(distanceFromActive) * 10}px)`, zIndex: 50 - Math.abs(context.activeIndex - (index ?? 0)), position: distanceFromActive ? 'absolute' : 'relative', opacity: distanceFromActive > 0 ? 0 : 1, cursor: context.clickable && context.activeIndex > index ? 'pointer' : 'default', }} {...props} > <div className={cn( 'h-full w-full transition-all duration-300', context.activeIndex !== index && 'pointer-events-none select-none opacity-0' )} > {children} </div> </div> );};export type DialogStackTitleProps = HTMLAttributes<HTMLHeadingElement>;export const DialogStackTitle = ({ children, className, ...props}: DialogStackTitleProps) => ( <h2 className={cn( 'font-semibold text-lg leading-none tracking-tight', className )} {...props} > {children} </h2>);export type DialogStackDescriptionProps = HTMLAttributes<HTMLParagraphElement>;export const DialogStackDescription = ({ children, className, ...props}: DialogStackDescriptionProps) => ( <p className={cn('text-muted-foreground text-sm', className)} {...props}> {children} </p>);export type DialogStackHeaderProps = HTMLAttributes<HTMLDivElement>;export const DialogStackHeader = ({ className, ...props}: DialogStackHeaderProps) => ( <div className={cn( 'flex flex-col space-y-1.5 text-center sm:text-left', className )} {...props} />);export type DialogStackFooterProps = HTMLAttributes<HTMLDivElement>;export const DialogStackFooter = ({ children, className, ...props}: DialogStackFooterProps) => ( <div className={cn('flex items-center justify-end space-x-2 pt-4', className)} {...props} > {children} </div>);export type DialogStackNextProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean;};export const DialogStackNext = ({ children, className, asChild, ...props}: DialogStackNextProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackNext must be used within a DialogStack'); } const handleNext = () => { if (context.activeIndex < context.totalDialogs - 1) { context.setActiveIndex(context.activeIndex + 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleNext(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex >= context.totalDialogs - 1} onClick={handleNext} type="button" {...props} > {children || 'Next'} </button> );};export type DialogStackPreviousProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackPrevious = ({ children, className, asChild, ...props}: DialogStackPreviousProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackPrevious must be used within a DialogStack'); } const handlePrevious = () => { if (context.activeIndex > 0) { context.setActiveIndex(context.activeIndex - 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handlePrevious(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex <= 0} onClick={handlePrevious} type="button" {...props} > {children || 'Previous'} </button> );};
With clickable navigation
'use client';import { DialogStack, DialogStackBody, DialogStackContent, DialogStackDescription, DialogStackFooter, DialogStackHeader, DialogStackNext, DialogStackOverlay, DialogStackPrevious, DialogStackTitle, DialogStackTrigger,} from '@/components/ui/shadcn-io/dialog-stack';import { Button } from '@/components/ui/button';const Example = () => ( <DialogStack clickable> <DialogStackTrigger asChild> <Button variant="outline">Show me</Button> </DialogStackTrigger> <DialogStackOverlay /> <DialogStackBody> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the first dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-end"> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the second dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the third dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> </DialogStackFooter> </DialogStackContent> </DialogStackBody> </DialogStack>);export default Example;
'use client';import { useControllableState } from '@radix-ui/react-use-controllable-state';import { Portal } from 'radix-ui';import type { ButtonHTMLAttributes, Dispatch, HTMLAttributes, MouseEvent, MouseEventHandler, ReactElement, SetStateAction,} from 'react';import { Children, cloneElement, createContext, useCallback, useContext, useEffect, useState,} from 'react';import { cn } from '@/lib/utils';type DialogStackContextType = { activeIndex: number; setActiveIndex: Dispatch<SetStateAction<number>>; totalDialogs: number; setTotalDialogs: Dispatch<SetStateAction<number>>; isOpen: boolean; setIsOpen: Dispatch<SetStateAction<boolean>>; clickable: boolean;};const DialogStackContext = createContext<DialogStackContextType>({ activeIndex: 0, setActiveIndex: () => { }, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: false, setIsOpen: () => { }, clickable: false,});type DialogStackChildProps = { index?: number;};export type DialogStackProps = HTMLAttributes<HTMLDivElement> & { open?: boolean; clickable?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};export const DialogStack = ({ children, className, open, defaultOpen = false, onOpenChange, clickable = false, ...props}: DialogStackProps) => { const [activeIndex, setActiveIndex] = useState(0); const [isOpen, setIsOpen] = useControllableState({ defaultProp: defaultOpen, prop: open, onChange: onOpenChange, }); useEffect(() => { if (onOpenChange && isOpen !== undefined) { onOpenChange(isOpen); } }, [isOpen, onOpenChange]); return ( <DialogStackContext.Provider value={{ activeIndex, setActiveIndex, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: isOpen ?? false, setIsOpen: (value) => setIsOpen(Boolean(value)), clickable, }} > <div className={className} {...props}> {children} </div> </DialogStackContext.Provider> );};export type DialogStackTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackTrigger = ({ children, className, onClick, asChild, ...props}: DialogStackTriggerProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackTrigger must be used within a DialogStack'); } const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => { context.setIsOpen(true); onClick?.(e); }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleClick(e); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm', 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2', 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'bg-primary text-primary-foreground hover:bg-primary/90', 'h-10 px-4 py-2', className )} onClick={handleClick} {...props} > {children} </button> );};export type DialogStackOverlayProps = HTMLAttributes<HTMLDivElement>;export const DialogStackOverlay = ({ className, ...props}: DialogStackOverlayProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackOverlay must be used within a DialogStack'); } const handleClick = useCallback(() => { context.setIsOpen(false); }, [context.setIsOpen]); if (!context.isOpen) { return null; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable overlay" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable overlay" <div className={cn( 'fixed inset-0 z-50 bg-black/80', 'data-[state=closed]:animate-out data-[state=open]:animate-in', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} onClick={handleClick} {...props} /> );};export type DialogStackBodyProps = HTMLAttributes<HTMLDivElement> & { children: | ReactElement<DialogStackChildProps>[] | ReactElement<DialogStackChildProps>;};export const DialogStackBody = ({ children, className, ...props}: DialogStackBodyProps) => { const context = useContext(DialogStackContext); const [totalDialogs, setTotalDialogs] = useState(Children.count(children)); if (!context) { throw new Error('DialogStackBody must be used within a DialogStack'); } if (!context.isOpen) { return null; } return ( <DialogStackContext.Provider value={{ ...context, totalDialogs, setTotalDialogs, }} > <Portal.Root> <div className={cn( 'pointer-events-none fixed inset-0 z-50 mx-auto flex w-full max-w-lg flex-col items-center justify-center', className )} {...props} > <div className="pointer-events-auto relative flex w-full flex-col items-center justify-center"> {Children.map(children, (child, index) => { const childElement = child as ReactElement<{ index: number; onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(childElement, { ...childElement.props, index, }); })} </div> </div> </Portal.Root> </DialogStackContext.Provider> );};export type DialogStackContentProps = HTMLAttributes<HTMLDivElement> & { index?: number; offset?: number;};export const DialogStackContent = ({ children, className, index = 0, offset = 10, ...props}: DialogStackContentProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackContent must be used within a DialogStack'); } if (!context.isOpen) { return null; } const handleClick = () => { if (context.clickable && context.activeIndex > index) { context.setActiveIndex(index ?? 0); } }; const distanceFromActive = index - context.activeIndex; const translateY = distanceFromActive < 0 ? `-${Math.abs(distanceFromActive) * offset}px` : `${Math.abs(distanceFromActive) * offset}px`; return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable dialog" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable dialog" <div className={cn( 'h-auto w-full rounded-lg border bg-background p-6 shadow-lg transition-all duration-300', className )} onClick={handleClick} style={{ top: 0, transform: `translateY(${translateY})`, width: `calc(100% - ${Math.abs(distanceFromActive) * 10}px)`, zIndex: 50 - Math.abs(context.activeIndex - (index ?? 0)), position: distanceFromActive ? 'absolute' : 'relative', opacity: distanceFromActive > 0 ? 0 : 1, cursor: context.clickable && context.activeIndex > index ? 'pointer' : 'default', }} {...props} > <div className={cn( 'h-full w-full transition-all duration-300', context.activeIndex !== index && 'pointer-events-none select-none opacity-0' )} > {children} </div> </div> );};export type DialogStackTitleProps = HTMLAttributes<HTMLHeadingElement>;export const DialogStackTitle = ({ children, className, ...props}: DialogStackTitleProps) => ( <h2 className={cn( 'font-semibold text-lg leading-none tracking-tight', className )} {...props} > {children} </h2>);export type DialogStackDescriptionProps = HTMLAttributes<HTMLParagraphElement>;export const DialogStackDescription = ({ children, className, ...props}: DialogStackDescriptionProps) => ( <p className={cn('text-muted-foreground text-sm', className)} {...props}> {children} </p>);export type DialogStackHeaderProps = HTMLAttributes<HTMLDivElement>;export const DialogStackHeader = ({ className, ...props}: DialogStackHeaderProps) => ( <div className={cn( 'flex flex-col space-y-1.5 text-center sm:text-left', className )} {...props} />);export type DialogStackFooterProps = HTMLAttributes<HTMLDivElement>;export const DialogStackFooter = ({ children, className, ...props}: DialogStackFooterProps) => ( <div className={cn('flex items-center justify-end space-x-2 pt-4', className)} {...props} > {children} </div>);export type DialogStackNextProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean;};export const DialogStackNext = ({ children, className, asChild, ...props}: DialogStackNextProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackNext must be used within a DialogStack'); } const handleNext = () => { if (context.activeIndex < context.totalDialogs - 1) { context.setActiveIndex(context.activeIndex + 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleNext(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex >= context.totalDialogs - 1} onClick={handleNext} type="button" {...props} > {children || 'Next'} </button> );};export type DialogStackPreviousProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackPrevious = ({ children, className, asChild, ...props}: DialogStackPreviousProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackPrevious must be used within a DialogStack'); } const handlePrevious = () => { if (context.activeIndex > 0) { context.setActiveIndex(context.activeIndex - 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handlePrevious(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex <= 0} onClick={handlePrevious} type="button" {...props} > {children || 'Previous'} </button> );};
Controlled
'use client';import { DialogStack, DialogStackBody, DialogStackContent, DialogStackDescription, DialogStackFooter, DialogStackHeader, DialogStackNext, DialogStackOverlay, DialogStackPrevious, DialogStackTitle,} from '@/components/ui/shadcn-io/dialog-stack';import { Button } from '@/components/ui/button';import { useState } from 'react';const Example = () => { const [open, setOpen] = useState(false); return ( <> <Button onClick={() => setOpen(!open)} variant="outline"> Toggle Dialog </Button> <DialogStack onOpenChange={setOpen} open={open}> <DialogStackOverlay /> <DialogStackBody> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the first dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-end"> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the second dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> <DialogStackNext asChild> <Button variant="outline">Next</Button> </DialogStackNext> </DialogStackFooter> </DialogStackContent> <DialogStackContent> <DialogStackHeader> <DialogStackTitle>I'm the third dialog</DialogStackTitle> <DialogStackDescription> With a fancy description </DialogStackDescription> </DialogStackHeader> <DialogStackFooter className="justify-between"> <DialogStackPrevious asChild> <Button variant="outline">Previous</Button> </DialogStackPrevious> </DialogStackFooter> </DialogStackContent> </DialogStackBody> </DialogStack> </> );};export default Example;
'use client';import { useControllableState } from '@radix-ui/react-use-controllable-state';import { Portal } from 'radix-ui';import type { ButtonHTMLAttributes, Dispatch, HTMLAttributes, MouseEvent, MouseEventHandler, ReactElement, SetStateAction,} from 'react';import { Children, cloneElement, createContext, useCallback, useContext, useEffect, useState,} from 'react';import { cn } from '@/lib/utils';type DialogStackContextType = { activeIndex: number; setActiveIndex: Dispatch<SetStateAction<number>>; totalDialogs: number; setTotalDialogs: Dispatch<SetStateAction<number>>; isOpen: boolean; setIsOpen: Dispatch<SetStateAction<boolean>>; clickable: boolean;};const DialogStackContext = createContext<DialogStackContextType>({ activeIndex: 0, setActiveIndex: () => { }, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: false, setIsOpen: () => { }, clickable: false,});type DialogStackChildProps = { index?: number;};export type DialogStackProps = HTMLAttributes<HTMLDivElement> & { open?: boolean; clickable?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};export const DialogStack = ({ children, className, open, defaultOpen = false, onOpenChange, clickable = false, ...props}: DialogStackProps) => { const [activeIndex, setActiveIndex] = useState(0); const [isOpen, setIsOpen] = useControllableState({ defaultProp: defaultOpen, prop: open, onChange: onOpenChange, }); useEffect(() => { if (onOpenChange && isOpen !== undefined) { onOpenChange(isOpen); } }, [isOpen, onOpenChange]); return ( <DialogStackContext.Provider value={{ activeIndex, setActiveIndex, totalDialogs: 0, setTotalDialogs: () => { }, isOpen: isOpen ?? false, setIsOpen: (value) => setIsOpen(Boolean(value)), clickable, }} > <div className={className} {...props}> {children} </div> </DialogStackContext.Provider> );};export type DialogStackTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackTrigger = ({ children, className, onClick, asChild, ...props}: DialogStackTriggerProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackTrigger must be used within a DialogStack'); } const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => { context.setIsOpen(true); onClick?.(e); }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleClick(e); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm', 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2', 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'bg-primary text-primary-foreground hover:bg-primary/90', 'h-10 px-4 py-2', className )} onClick={handleClick} {...props} > {children} </button> );};export type DialogStackOverlayProps = HTMLAttributes<HTMLDivElement>;export const DialogStackOverlay = ({ className, ...props}: DialogStackOverlayProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackOverlay must be used within a DialogStack'); } const handleClick = useCallback(() => { context.setIsOpen(false); }, [context.setIsOpen]); if (!context.isOpen) { return null; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable overlay" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable overlay" <div className={cn( 'fixed inset-0 z-50 bg-black/80', 'data-[state=closed]:animate-out data-[state=open]:animate-in', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className )} onClick={handleClick} {...props} /> );};export type DialogStackBodyProps = HTMLAttributes<HTMLDivElement> & { children: | ReactElement<DialogStackChildProps>[] | ReactElement<DialogStackChildProps>;};export const DialogStackBody = ({ children, className, ...props}: DialogStackBodyProps) => { const context = useContext(DialogStackContext); const [totalDialogs, setTotalDialogs] = useState(Children.count(children)); if (!context) { throw new Error('DialogStackBody must be used within a DialogStack'); } if (!context.isOpen) { return null; } return ( <DialogStackContext.Provider value={{ ...context, totalDialogs, setTotalDialogs, }} > <Portal.Root> <div className={cn( 'pointer-events-none fixed inset-0 z-50 mx-auto flex w-full max-w-lg flex-col items-center justify-center', className )} {...props} > <div className="pointer-events-auto relative flex w-full flex-col items-center justify-center"> {Children.map(children, (child, index) => { const childElement = child as ReactElement<{ index: number; onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(childElement, { ...childElement.props, index, }); })} </div> </div> </Portal.Root> </DialogStackContext.Provider> );};export type DialogStackContentProps = HTMLAttributes<HTMLDivElement> & { index?: number; offset?: number;};export const DialogStackContent = ({ children, className, index = 0, offset = 10, ...props}: DialogStackContentProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackContent must be used within a DialogStack'); } if (!context.isOpen) { return null; } const handleClick = () => { if (context.clickable && context.activeIndex > index) { context.setActiveIndex(index ?? 0); } }; const distanceFromActive = index - context.activeIndex; const translateY = distanceFromActive < 0 ? `-${Math.abs(distanceFromActive) * offset}px` : `${Math.abs(distanceFromActive) * offset}px`; return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable dialog" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable dialog" <div className={cn( 'h-auto w-full rounded-lg border bg-background p-6 shadow-lg transition-all duration-300', className )} onClick={handleClick} style={{ top: 0, transform: `translateY(${translateY})`, width: `calc(100% - ${Math.abs(distanceFromActive) * 10}px)`, zIndex: 50 - Math.abs(context.activeIndex - (index ?? 0)), position: distanceFromActive ? 'absolute' : 'relative', opacity: distanceFromActive > 0 ? 0 : 1, cursor: context.clickable && context.activeIndex > index ? 'pointer' : 'default', }} {...props} > <div className={cn( 'h-full w-full transition-all duration-300', context.activeIndex !== index && 'pointer-events-none select-none opacity-0' )} > {children} </div> </div> );};export type DialogStackTitleProps = HTMLAttributes<HTMLHeadingElement>;export const DialogStackTitle = ({ children, className, ...props}: DialogStackTitleProps) => ( <h2 className={cn( 'font-semibold text-lg leading-none tracking-tight', className )} {...props} > {children} </h2>);export type DialogStackDescriptionProps = HTMLAttributes<HTMLParagraphElement>;export const DialogStackDescription = ({ children, className, ...props}: DialogStackDescriptionProps) => ( <p className={cn('text-muted-foreground text-sm', className)} {...props}> {children} </p>);export type DialogStackHeaderProps = HTMLAttributes<HTMLDivElement>;export const DialogStackHeader = ({ className, ...props}: DialogStackHeaderProps) => ( <div className={cn( 'flex flex-col space-y-1.5 text-center sm:text-left', className )} {...props} />);export type DialogStackFooterProps = HTMLAttributes<HTMLDivElement>;export const DialogStackFooter = ({ children, className, ...props}: DialogStackFooterProps) => ( <div className={cn('flex items-center justify-end space-x-2 pt-4', className)} {...props} > {children} </div>);export type DialogStackNextProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean;};export const DialogStackNext = ({ children, className, asChild, ...props}: DialogStackNextProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackNext must be used within a DialogStack'); } const handleNext = () => { if (context.activeIndex < context.totalDialogs - 1) { context.setActiveIndex(context.activeIndex + 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handleNext(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex >= context.totalDialogs - 1} onClick={handleNext} type="button" {...props} > {children || 'Next'} </button> );};export type DialogStackPreviousProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; };export const DialogStackPrevious = ({ children, className, asChild, ...props}: DialogStackPreviousProps) => { const context = useContext(DialogStackContext); if (!context) { throw new Error('DialogStackPrevious must be used within a DialogStack'); } const handlePrevious = () => { if (context.activeIndex > 0) { context.setActiveIndex(context.activeIndex - 1); } }; if (asChild && children) { const child = children as ReactElement<{ onClick: MouseEventHandler<HTMLButtonElement>; className?: string; }>; return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { handlePrevious(); child.props.onClick?.(e); }, className: cn(className, child.props.className), ...props, }); } return ( <button className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className )} disabled={context.activeIndex <= 0} onClick={handlePrevious} type="button" {...props} > {children || 'Previous'} </button> );};
Use Cases
- Multi-step forms - User registration and onboarding wizard interfaces
- Checkout processes - E-commerce payment and shipping information collection
- Settings panels - Nested configuration dialogs with category navigation
- Data entry workflows - Complex form splitting across multiple dialog steps
Implementation
Built with Radix UI Portal for proper stacking. Uses controlled state for navigation. Supports both linear and non-linear wizard flows. Includes escape key handling and overlay management.
Cursor
Interactive cursor indicator component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring real-time positioning, customizable appearance, and accessibility features.
Marquee
Horizontal scrolling marquee component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring smooth animations, customizable spacing, and fade effects.