Lag og beskeder
Dialog stack
En dialog med trin, der lægger sig bag hinanden, bygget på det indbyggede dialog-element.
Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.
Forhåndsvisning
Brug
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>
)
}Kilde
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ér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/dialog-stack.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/dialog-stack