Overlays
Dialog stack
A modal with steps that stack behind each other, built on the native dialog element.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
dialog-stack-demo.tsx
import { Gift, Mail, PartyPopper, Send } from 'lucide-react'
import { useState } from 'react'
import { DialogStack } from '@/components/dialog-stack'
const field = 'w-full rounded-xl border bg-background px-3 py-2.5 outline-none focus-visible:ring-2 focus-visible:ring-ring'
const primary = 'flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground font-medium text-background active:scale-[0.98]'
export default function DialogStackDemo() {
const [open, setOpen] = useState(false)
const [sent, setSent] = useState(false)
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
onClick={() => {
setSent(false)
setOpen(true)
}}
className="inline-flex min-h-11 items-center gap-2 rounded-full border bg-card px-6 font-medium shadow-sm transition-transform duration-200 hover:-translate-y-0.5"
>
<Gift className="size-5" aria-hidden />
Send a gift card
</button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{sent && 'Gift card on its way.'}
</p>
<DialogStack
open={open}
onOpenChange={setOpen}
steps={[
{
id: 'form',
title: 'Gift card',
content: ({ next, close }) => (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
setSent(true)
close()
}}
>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">Their email</span>
<input type="email" required className={field} placeholder="name@example.com" />
</label>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">A short note</span>
<textarea rows={3} className={`${field} resize-none`} />
</label>
<button type="submit" className={primary}>
Send <Send className="size-4" aria-hidden />
</button>
<button type="button" onClick={next} className="w-full text-sm text-muted-foreground hover:text-foreground">
How does it work?
</button>
</form>
),
},
{
id: 'how',
title: 'How it works',
content: ({ back }) => (
<div className="space-y-5">
<p className="text-xl font-semibold">Three easy steps</p>
{([
[Mail, 'They get an email with the card and your note.'],
[Gift, 'They spend it online or in the store, in one go or bit by bit.'],
[PartyPopper, 'The card lasts three years. No fees.'],
] as const).map(([Icon, text], i) => (
<div key={i} className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl border bg-muted">
<Icon className="size-5" aria-hidden />
</span>
<p className="pt-2 text-sm">{text}</p>
</div>
))}
<button type="button" onClick={back} className={primary}>
Got it
</button>
</div>
),
},
]}
/>
</div>
)
}Source
dialog-stack.tsx
// Based on Watermelon UI's “Dialog stack” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten on the native <dialog> with any content per step.
import { ArrowLeft, X } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type DialogStackControls = { next: () => void; back: () => void; close: () => void }
export type DialogStackStep = {
id: string
title: string
content: (controls: DialogStackControls) => ReactNode
}
export type DialogStackProps = {
open: boolean
onOpenChange: (open: boolean) => void
steps: DialogStackStep[]
closeLabel?: string
backLabel?: string
className?: string
}
/**
* A dialog where each next step lands on top and the earlier ones peek out behind it.
* Esc or the back arrow goes one step down; closing on the first step closes the dialog.
*/
export function DialogStack({ open, onOpenChange, steps, closeLabel = 'Close', backLabel = 'Back', className }: DialogStackProps) {
const dialog = useRef<HTMLDialogElement>(null)
const [index, setIndex] = useState(0)
useEffect(() => {
const d = dialog.current
if (!d) return
if (open && !d.open) {
setIndex(0)
d.showModal()
} else if (!open && d.open) d.close()
}, [open])
const close = () => onOpenChange(false)
const controls: DialogStackControls = {
next: () => setIndex((i) => Math.min(i + 1, steps.length - 1)),
back: () => setIndex((i) => Math.max(i - 1, 0)),
close,
}
return (
<dialog
ref={dialog}
aria-label={steps[index]?.title}
onCancel={(e) => {
// Esc steps back first, like the arrow.
if (index > 0) {
e.preventDefault()
controls.back()
}
}}
onClose={close}
onClick={(e) => e.target === dialog.current && close()}
className={cn(
'm-auto w-[min(24rem,calc(100vw-2rem))] overflow-visible bg-transparent p-0 pt-10 text-card-foreground',
'backdrop:bg-black/30 backdrop:backdrop-blur-[2px]',
className,
)}
>
<MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 300, damping: 28 }}>
<div className="relative grid">
<AnimatePresence initial={false}>
{steps.slice(0, index + 1).map((step, i) => {
const depth = index - i
const top = depth === 0
return (
<motion.section
key={step.id}
inert={!top}
aria-hidden={!top}
initial={{ y: 48, opacity: 0, scale: 0.96 }}
animate={{ y: depth * -18, scale: 1 - depth * 0.05, opacity: depth > 2 ? 0 : 1 - depth * 0.25 }}
exit={{ y: 48, opacity: 0, scale: 0.96 }}
style={{ zIndex: i, transformOrigin: 'top center' }}
className="col-start-1 row-start-1 self-start overflow-hidden rounded-3xl border bg-card shadow-2xl"
>
<header className="flex items-center gap-1 border-b bg-muted/50 py-1.5 pr-1.5 pl-2">
{i > 0 && (
<button
type="button"
aria-label={backLabel}
onClick={controls.back}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4.5" aria-hidden />
</button>
)}
<h2 className={cn('flex-1 text-sm font-medium text-muted-foreground', i === 0 && 'pl-3')}>{step.title}</h2>
<button
type="button"
aria-label={closeLabel}
onClick={close}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4.5" aria-hidden />
</button>
</header>
<div className="p-5">{step.content(controls)}</div>
</motion.section>
)
})}
</AnimatePresence>
</div>
</MotionConfig>
</dialog>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/dialog-stack.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/dialog-stack