Mikkel Manniche
Dansk English Deutsch

Overlays

Dialog stack

Ein Dialog mit Schritten, die sich hintereinander stapeln, gebaut auf dem nativen Dialog-Element.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

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>
  )
}

Quellcode

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>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/dialog-stack.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/dialog-stack

Dateien

  • dialog-stack.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.