Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/dialog-stack

Files

  • dialog-stack.tsx

Manniche Lab · open source

Free tools for AI agents and web development.