Dialog
A modal panel for a short task, such as a form, that keeps the current page underneath.
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
import { Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'
export default function Example() {
let [isOpen, setIsOpen] = useState(false)
let [endpoints, setEndpoints] = useState<string[]>([])
function addEndpoint(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setEndpoints([...endpoints, String(new FormData(event.currentTarget).get('url'))])
setIsOpen(false)
}
return (
<>
<Button type="button" onClick={() => setIsOpen(true)}>
Add endpoint
</Button>
<ul role="list">
{endpoints.map((endpoint) => (
<li key={endpoint}>{endpoint}</li>
))}
</ul>
<Dialog open={isOpen} onClose={setIsOpen}>
<DialogTitle>Add a webhook endpoint</DialogTitle>
<DialogDescription>
Northwind sends a signed POST request to this URL for every event you subscribe to.
</DialogDescription>
<form onSubmit={addEndpoint}>
<DialogBody>
<Field>
<Label>Endpoint URL</Label>
<Input type="url" name="url" required placeholder="https://api.example.com/webhooks" />
</Field>
</DialogBody>
<DialogActions>
<Button plain onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit">Add endpoint</Button>
</DialogActions>
</form>
</Dialog>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import Dialog from '@/components/dialog.vue'
import DialogActions from '@/components/dialog-actions.vue'
import DialogBody from '@/components/dialog-body.vue'
import DialogDescription from '@/components/dialog-description.vue'
import DialogTitle from '@/components/dialog-title.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
const isOpen = ref(false)
const endpoints = ref<string[]>([])
function addEndpoint(event: Event) {
const form = event.currentTarget as HTMLFormElement
endpoints.value = [...endpoints.value, String(new FormData(form).get('url'))]
isOpen.value = false
}
</script>
<template>
<Button type="button" @click="isOpen = true">Add endpoint</Button>
<ul role="list">
<li v-for="endpoint in endpoints" :key="endpoint">{{ endpoint }}</li>
</ul>
<Dialog :open="isOpen" @close="isOpen = false">
<DialogTitle>Add a webhook endpoint</DialogTitle>
<DialogDescription>
Northwind sends a signed POST request to this URL for every event you subscribe to.
</DialogDescription>
<form @submit.prevent="addEndpoint">
<DialogBody>
<Field>
<Label>Endpoint URL</Label>
<Input type="url" name="url" required placeholder="https://api.example.com/webhooks" />
</Field>
</DialogBody>
<DialogActions>
<Button plain @click="isOpen = false">Cancel</Button>
<Button type="submit">Add endpoint</Button>
</DialogActions>
</form>
</Dialog>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { useModalStackPosition } from './modal-stack'
import { Text } from './text'
const widths = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
const enterLeave = 'transition duration-200 motion-reduce:duration-0 data-enter:ease-out data-leave:ease-in'
const backdrop = clsx(enterLeave, 'fixed inset-0 bg-zinc-950/25 focus:outline-0 data-closed:opacity-0 dark:bg-zinc-950/50')
const stackedLevel = [
'origin-top transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none',
'sm:translate-y-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-t-3xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] dark:after:bg-zinc-950/50 sm:after:rounded-2xl',
'after:transition-opacity after:duration-200 motion-reduce:after:transition-none',
]
const panel = [
'w-full min-w-0 [--gutter:--spacing(6)] p-(--gutter) max-sm:pb-[calc(var(--gutter)+env(safe-area-inset-bottom))]',
'rounded-t-3xl bg-white shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
enterLeave,
'will-change-transform data-closed:translate-y-12 data-closed:opacity-0 sm:data-closed:translate-y-0 sm:data-closed:scale-95',
]
type DialogProps = {
size?: keyof typeof widths
className?: string
children: React.ReactNode
} & Omit<Headless.DialogProps, 'as' | 'className'>
export function Dialog({ size = 'lg', className, children, ...props }: DialogProps) {
let { depth, cover, isBase, inStack } = useModalStackPosition('dialog')
let stackVariables = { '--stack-depth': depth, '--stack-cover': cover } as React.CSSProperties
return (
<Headless.Dialog {...props} inert={depth > 0 || undefined}>
{isBase && <Headless.DialogBackdrop transition className={backdrop} />}
<div className="fixed inset-0 w-screen pt-6 sm:pt-0 overflow-y-auto">
<div
className={clsx(
'grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:p-4',
inStack ? 'sm:grid-rows-[12vh_auto_1fr]' : 'sm:grid-rows-[1fr_auto_3fr]'
)}
>
<div style={stackVariables} className={clsx(widths[size], 'relative row-start-2 w-full min-w-0 sm:mb-auto', stackedLevel)}>
<Headless.DialogPanel transition className={clsx(className, panel)}>
{children}
</Headless.DialogPanel>
</div>
</div>
</div>
</Headless.Dialog>
)
}
export function DialogTitle({ className, ...props }: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'font-semibold text-zinc-950 dark:text-white text-lg/6 sm:text-base/6 text-balance')}
/>
)
}
export function DialogDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function DialogBody({ className, ...props }: React.ComponentProps<'div'>) {
return <div {...props} className={clsx(className, 'mt-6')} />
}
export function DialogActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
{...props}
className={clsx(className, 'mt-8 flex flex-col-reverse sm:flex-row items-center justify-end gap-3 *:w-full sm:*:w-auto')}
/>
)
}Also copy Text.
<script lang="ts">
import type { InjectionKey, Ref } from 'vue'
export type DialogIds = {
titleId: string
descriptionId: string
hasTitle: Ref<boolean>
hasDescription: Ref<boolean>
}
export const DialogIdsKey: InjectionKey<DialogIds> = Symbol('TrelundDialogIds')
export function focusAutofocusElement(event: Event) {
const element = (event.target as HTMLElement).querySelector<HTMLElement>('[autofocus]')
if (!element) return
event.preventDefault()
element.focus()
}
export function isEscapeForModal(event: KeyboardEvent) {
if (event.key !== 'Escape' || event.defaultPrevented) return false
const target = event.target as HTMLElement
return !(target.getAttribute('role') === 'combobox' && target.getAttribute('aria-expanded') === 'true')
}
</script>
<script setup lang="ts">
import { hideOthers } from 'aria-hidden'
import { FocusScope } from 'reka-ui'
import { onBeforeUnmount, provide, ref, useId, watch } from 'vue'
import { useModalStackPosition } from './modal-stack'
import { useScrollLock } from './use-scroll-lock'
const widths = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
const backdrop = 'fixed inset-0 z-50 bg-zinc-950/25 focus:outline-0 dark:bg-zinc-950/50'
const stackedLevel = [
'origin-top transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none',
'sm:translate-y-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-t-3xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] dark:after:bg-zinc-950/50 sm:after:rounded-2xl',
'after:transition-opacity after:duration-200 motion-reduce:after:transition-none',
]
const panel = [
'w-full min-w-0 [--gutter:--spacing(6)] p-(--gutter) max-sm:pb-[calc(var(--gutter)+env(safe-area-inset-bottom))]',
'rounded-t-3xl bg-white shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
]
const props = withDefaults(defineProps<{ open: boolean; size?: keyof typeof widths }>(), { size: 'lg' })
const emit = defineEmits<{ close: [] }>()
const id = useId()
const hasTitle = ref(false)
const hasDescription = ref(false)
const titleId = `${id}-title`
const descriptionId = `${id}-description`
provide(DialogIdsKey, { titleId, descriptionId, hasTitle, hasDescription })
const { depth, cover, isBase, inStack } = useModalStackPosition('dialog')
const locked = useScrollLock()
const rendered = ref(props.open)
const panelElement = ref<HTMLElement>()
let showOthers: (() => void) | undefined
watch(panelElement, (element) => {
showOthers?.()
showOthers = element && !inStack ? hideOthers(element) : undefined
})
function onAfterLeave() {
rendered.value = false
}
function onKeydown(event: KeyboardEvent) {
if (!isEscapeForModal(event) || cover.value > 0) return
event.stopPropagation()
emit('close')
}
function onOutsideMousedown(event: MouseEvent) {
if (event.target === event.currentTarget && cover.value === 0) emit('close')
}
watch(
() => props.open,
(open) => {
if (open) rendered.value = true
locked.value = open
},
{ immediate: true }
)
onBeforeUnmount(() => {
showOthers?.()
locked.value = false
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out motion-reduce:duration-0"
enter-from-class="opacity-0"
leave-active-class="transition duration-200 ease-in motion-reduce:duration-0"
leave-to-class="opacity-0"
>
<div v-if="open && isBase" aria-hidden="true" :class="backdrop" />
</Transition>
<div v-if="rendered" class="fixed inset-0 z-50 w-screen pt-6 sm:pt-0 overflow-y-auto" @mousedown="onOutsideMousedown">
<div
:class="[
'grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:p-4',
inStack ? 'sm:grid-rows-[12vh_auto_1fr]' : 'sm:grid-rows-[1fr_auto_3fr]',
]"
@mousedown="onOutsideMousedown"
>
<div
:style="{ '--stack-depth': depth, '--stack-cover': cover }"
:class="[widths[size], 'relative row-start-2 w-full min-w-0 sm:mb-auto', stackedLevel]"
>
<Transition
appear
enter-active-class="transition duration-200 ease-out will-change-transform motion-reduce:duration-0"
enter-from-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
leave-active-class="transition duration-200 ease-in will-change-transform motion-reduce:duration-0"
leave-to-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
@after-leave="onAfterLeave"
>
<FocusScope v-if="open" as-child trapped loop @mount-auto-focus="focusAutofocusElement">
<div
ref="panelElement"
role="dialog"
aria-modal="true"
:inert="depth > 0"
:aria-labelledby="hasTitle ? titleId : undefined"
:aria-describedby="hasDescription ? descriptionId : undefined"
:class="panel"
@keydown="onKeydown"
>
<slot />
</div>
</FocusScope>
</Transition>
</div>
</div>
</div>
</Teleport>
</template>Also copy Text.
Component API
| Prop | Default | Description |
|---|---|---|
Dialogbuilt on the Headless UI <Headless.Dialog> component | ||
open | - | Whether the dialog is on screen. The dialog never opens or closes on its own. |
onClose | - | Called when the user presses Escape or clicks outside the panel. |
size | lg | Maximum width of the panel from the sm breakpoint, from xs to 5xl. |
DialogTitlebuilt on the Headless UI <Headless.DialogTitle> component | ||
| This component does not expose any component-specific props. | ||
DialogDescriptionbuilt on the Headless UI <Headless.Description> component | ||
| This component does not expose any component-specific props. | ||
DialogBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
DialogActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Dialogbuilt on the Reka UI <FocusScope> component | ||
open | - | Whether the dialog is on screen. The dialog never opens or closes on its own. |
size | lg | Maximum width of the panel from the sm breakpoint, from xs to 5xl. |
@close | - | Emitted when the user presses Escape or clicks outside the panel. |
DialogTitlerenders a <h2> element | ||
| This component does not expose any component-specific props. | ||
DialogDescriptionbuilt on the <Text> component | ||
| This component does not expose any component-specific props. | ||
DialogBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
DialogActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
Usage
A dialog suits a task people finish in under a minute without leaving what they were looking at: a few fields, a choice, a review. When the form grows past a handful of fields or needs its own URL, give it a page. To confirm a destructive action, use an Alert instead.
Short form
The title says what the dialog does, the description what happens next, and the actions close it. Your state drives open, and onClose hands the decision back to you. Wrap the body and the actions in a form so Enter submits and native validation runs:
The title says what the dialog does, the description what happens next, and the actions close it. Your state drives open, and the close event hands the decision back to you. Wrap the body and the actions in a form so Enter submits and native validation runs:
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
import { Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'
export default function Example() {
let [isOpen, setIsOpen] = useState(false)
let [endpoints, setEndpoints] = useState<string[]>([])
function addEndpoint(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setEndpoints([...endpoints, String(new FormData(event.currentTarget).get('url'))])
setIsOpen(false)
}
return (
<>
<Button type="button" onClick={() => setIsOpen(true)}>
Add endpoint
</Button>
<ul role="list">
{endpoints.map((endpoint) => (
<li key={endpoint}>{endpoint}</li>
))}
</ul>
<Dialog open={isOpen} onClose={setIsOpen}>
<DialogTitle>Add a webhook endpoint</DialogTitle>
<DialogDescription>
Northwind sends a signed POST request to this URL for every event you subscribe to.
</DialogDescription>
<form onSubmit={addEndpoint}>
<DialogBody>
<Field>
<Label>Endpoint URL</Label>
<Input type="url" name="url" required placeholder="https://api.example.com/webhooks" />
</Field>
</DialogBody>
<DialogActions>
<Button plain onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit">Add endpoint</Button>
</DialogActions>
</form>
</Dialog>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import Dialog from '@/components/dialog.vue'
import DialogActions from '@/components/dialog-actions.vue'
import DialogBody from '@/components/dialog-body.vue'
import DialogDescription from '@/components/dialog-description.vue'
import DialogTitle from '@/components/dialog-title.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
const isOpen = ref(false)
const endpoints = ref<string[]>([])
function addEndpoint(event: Event) {
const form = event.currentTarget as HTMLFormElement
endpoints.value = [...endpoints.value, String(new FormData(form).get('url'))]
isOpen.value = false
}
</script>
<template>
<Button type="button" @click="isOpen = true">Add endpoint</Button>
<ul role="list">
<li v-for="endpoint in endpoints" :key="endpoint">{{ endpoint }}</li>
</ul>
<Dialog :open="isOpen" @close="isOpen = false">
<DialogTitle>Add a webhook endpoint</DialogTitle>
<DialogDescription>
Northwind sends a signed POST request to this URL for every event you subscribe to.
</DialogDescription>
<form @submit.prevent="addEndpoint">
<DialogBody>
<Field>
<Label>Endpoint URL</Label>
<Input type="url" name="url" required placeholder="https://api.example.com/webhooks" />
</Field>
</DialogBody>
<DialogActions>
<Button plain @click="isOpen = false">Cancel</Button>
<Button type="submit">Add endpoint</Button>
</DialogActions>
</form>
</Dialog>
</template>Opening from a menu
Render the Dialog next to the Dropdown, never inside DropdownMenu. The menu unmounts its items as soon as it closes, and a dialog placed there would go with them:
<Dropdown>
<DropdownButton outline>
Endpoints
<ChevronDownIcon />
</DropdownButton>
<DropdownMenu>
<DropdownItem onClick={() => setIsOpen(true)}>Add endpoint</DropdownItem>
<DropdownItem onClick={sendTestEvent}>Send test event</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dialog open={isOpen} onClose={setIsOpen}>
<DialogTitle>Add a webhook endpoint</DialogTitle>
{/* ... */}
</Dialog><script setup lang="ts">
import { ref } from 'vue'
import Dialog from '@/components/dialog.vue'
import DialogTitle from '@/components/dialog-title.vue'
import Dropdown from '@/components/dropdown.vue'
import DropdownButton from '@/components/dropdown-button.vue'
import DropdownItem from '@/components/dropdown-item.vue'
import DropdownMenu from '@/components/dropdown-menu.vue'
import { ChevronDownIcon } from '@heroicons/vue/16/solid'
const isOpen = ref(false)
</script>
<template>
<Dropdown>
<DropdownButton outline>
Endpoints
<ChevronDownIcon />
</DropdownButton>
<DropdownMenu>
<DropdownItem @click="isOpen = true">Add endpoint</DropdownItem>
<DropdownItem>Send test event</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dialog :open="isOpen" @close="isOpen = false">
<DialogTitle>Add a webhook endpoint</DialogTitle>
<!-- ... -->
</Dialog>
</template>Layout
From the sm breakpoint the panel floats in the upper part of the screen. Below it, the panel becomes a sheet anchored to the bottom edge, and DialogActions stacks its buttons at full width with the last one on top, so the primary action sits closest to the thumb.
Wider panel
The panel is lg by default. Go wider when fields sit side by side, and keep the width that lets the longest line of the form breathe, not more:
<Dialog size="xl" open={isOpen} onClose={setIsOpen}>
<DialogTitle>Invite a member to northwind</DialogTitle>
<DialogDescription>They get an email with a link that stays valid for 7 days.</DialogDescription>
<form onSubmit={sendInvite}>
<DialogBody>
<div className="grid gap-6 sm:grid-cols-[1fr_10rem] sm:gap-4">
<Field>
<Label>Email</Label>
<Input type="email" name="email" required placeholder="aissatou@northwind.dev" />
</Field>
<Field>
<Label>Role</Label>
<Select name="role" defaultValue="developer">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="billing">Billing</option>
</Select>
</Field>
</div>
</DialogBody>
<DialogActions>
<Button plain onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit">Send invite</Button>
</DialogActions>
</form>
</Dialog><Dialog size="xl" :open="isOpen" @close="isOpen = false">
<DialogTitle>Invite a member to northwind</DialogTitle>
<DialogDescription>They get an email with a link that stays valid for 7 days.</DialogDescription>
<form @submit.prevent="sendInvite">
<DialogBody>
<div class="grid gap-6 sm:grid-cols-[1fr_10rem] sm:gap-4">
<Field>
<Label>Email</Label>
<Input type="email" name="email" required placeholder="aissatou@northwind.dev" />
</Field>
<Field>
<Label>Role</Label>
<Select v-model="role" name="role">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="billing">Billing</option>
</Select>
</Field>
</div>
</DialogBody>
<DialogActions>
<Button plain @click="isOpen = false">Cancel</Button>
<Button type="submit">Send invite</Button>
</DialogActions>
</form>
</Dialog>size accepts xs, sm, md, lg, xl, 2xl, 3xl, 4xl and 5xl.
Content taller than the screen
Nothing to configure: when the content runs past the viewport, the overlay scrolls and the page behind stays still. The actions come after the text, once people have read it:
<Dialog size="xl" open={isOpen} onClose={setIsOpen}>
<DialogTitle>Data processing addendum</DialogTitle>
<DialogDescription>Review the terms that apply once EU data residency is on.</DialogDescription>
<DialogBody className="text-sm/6 text-zinc-900 dark:text-white">
{clauses.map((clause, index) => (
<div key={clause.heading} className="mt-6 first:mt-0">
<h3 className="font-semibold">
{index + 1}. {clause.heading}
</h3>
<p className="mt-2">{clause.text}</p>
</div>
))}
</DialogBody>
<DialogActions>
<Button plain onClick={() => setIsOpen(false)}>
Not now
</Button>
<Button onClick={() => setIsOpen(false)}>Accept and enable</Button>
</DialogActions>
</Dialog><Dialog size="xl" :open="isOpen" @close="isOpen = false">
<DialogTitle>Data processing addendum</DialogTitle>
<DialogDescription>Review the terms that apply once EU data residency is on.</DialogDescription>
<DialogBody class="text-sm/6 text-zinc-900 dark:text-white">
<div v-for="(clause, index) in clauses" :key="clause.heading" class="mt-6 first:mt-0">
<h3 class="font-semibold">{{ index + 1 }}. {{ clause.heading }}</h3>
<p class="mt-2">{{ clause.text }}</p>
</div>
</DialogBody>
<DialogActions>
<Button plain @click="isOpen = false">Not now</Button>
<Button @click="isOpen = false">Accept and enable</Button>
</DialogActions>
</Dialog>Accessibility
While the dialog is open, focus stays inside the panel and the page behind cannot be reached with the keyboard. The title and description are linked to the panel so screen readers announce both when it opens.
Initial focus
Put autoFocus on the field people fill first so they can type as soon as the dialog appears:
Put autofocus on the field people fill first so they can type as soon as the dialog appears:
<Field>
<Label>Endpoint URL</Label>
<Input autoFocus type="url" name="url" placeholder="https://api.example.com/webhooks" />
</Field><Field>
<Label>Endpoint URL</Label>
<Input autofocus type="url" name="url" placeholder="https://api.example.com/webhooks" />
</Field>Stacked dialogs
A dialog can open another one, wait for its answer and pick up where it stopped. The dialog underneath stays mounted: it moves back and dims until the one on top closes.
Wrap the app in ModalStackProvider once, then open modals from anywhere with useModalStack(). Each kind keeps its own depth: dialogs and alerts share one stack, slideovers have theirs (see stacked slideovers), and a dialog opened from a slideover sits above it without pushing it back. The whole stack shares a single backdrop.
// app/providers.tsx
'use client'
import { ModalStackProvider } from '@/components/modal-stack'
export function Providers({ children }: { children: React.ReactNode }) {
return <ModalStackProvider>{children}</ModalStackProvider>
}<script setup lang="ts">
import ModalStackProvider from '@/components/modal-stack-provider.vue'
</script>
<template>
<ModalStackProvider>
<NuxtPage />
</ModalStackProvider>
</template>| Prop | Default | Description |
|---|---|---|
| ModalStackProvider | ||
maxDepth | 3 | Depth past which the provider warns in development. |
unmountDelay | 300 | Milliseconds given to the leave transition before a modal is unmounted. |
| useModalStack() | ||
open | - | Renders a modal on top of the stack and returns a promise resolved with the value passed to close(). |
close | - | Closes the topmost modal, resolving it with the given value. |
closeAll | - | Closes every modal in the stack. |
size | - | Number of modals currently open. |
| Prop | Default | Description |
|---|---|---|
| ModalStackProvider | ||
maxDepth | 3 | Depth past which the provider warns in development. |
unmountDelay | 300 | Milliseconds given to the leave transition before a modal is unmounted. |
v-slot | - | Exposes open, close, closeAll and size to the default slot. |
| useModalStack() | ||
open | - | Mounts a modal on top of the stack and returns a promise resolved with the value it emits. |
close | - | Closes the topmost modal, resolving it with the given value. |
closeAll | - | Closes every modal in the stack. |
size | - | Number of modals currently open. |
Asking another dialog for a value
open() returns a promise that resolves with the value passed to close(). The invoice dialog asks a picker for a customer and keeps its own state while it waits. In the live demo, the picker can open a third dialog to create a customer:
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import { Dialog, DialogActions, DialogBody, DialogTitle } from '@/components/dialog'
import { useModalStack, type ModalRenderProps } from '@/components/modal-stack'
function SelectCustomerDialog({ isOpen, close }: ModalRenderProps<Customer>) {
return (
<Dialog size="md" open={isOpen} onClose={() => close()}>
<DialogTitle>Select a customer</DialogTitle>
<DialogBody>
{customers.map((customer) => (
<button key={customer.id} type="button" onClick={() => close(customer)}>
{customer.name}
</button>
))}
</DialogBody>
<DialogActions>
<Button plain onClick={() => close()}>
Cancel
</Button>
</DialogActions>
</Dialog>
)
}
function InvoiceDialog({ isOpen, close }: ModalRenderProps<void>) {
let { open } = useModalStack()
let [customer, setCustomer] = useState<Customer | null>(null)
async function selectCustomer() {
let selected = await open<Customer>((modal) => <SelectCustomerDialog {...modal} />)
if (selected) setCustomer(selected)
}
return (
<Dialog open={isOpen} onClose={() => close()}>
<DialogTitle>New invoice</DialogTitle>
<DialogBody>
<Button outline onClick={selectCustomer}>
{customer ? customer.name : 'Select a customer'}
</Button>
</DialogBody>
<DialogActions>
<Button plain onClick={() => close()}>
Cancel
</Button>
<Button onClick={() => close()}>Create invoice</Button>
</DialogActions>
</Dialog>
)
}
export default function Example() {
let { open } = useModalStack()
return (
<Button type="button" onClick={() => open((modal) => <InvoiceDialog {...modal} />)}>
New invoice
</Button>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import Dialog from '@/components/dialog.vue'
import DialogActions from '@/components/dialog-actions.vue'
import DialogBody from '@/components/dialog-body.vue'
import DialogTitle from '@/components/dialog-title.vue'
import { useModalStack } from '@/components/modal-stack'
import SelectCustomerDialog from './SelectCustomerDialog.vue'
defineProps<{ isOpen: boolean }>()
const emit = defineEmits<{ close: [] }>()
const { open } = useModalStack()
const customer = ref<Customer | null>(null)
async function selectCustomer() {
const selected = await open<Customer>(SelectCustomerDialog)
if (selected) customer.value = selected
}
</script>
<template>
<Dialog :open="isOpen" @close="emit('close')">
<DialogTitle>New invoice</DialogTitle>
<DialogBody>
<Button outline @click="selectCustomer">
{{ customer ? customer.name : 'Select a customer' }}
</Button>
</DialogBody>
<DialogActions>
<Button plain @click="emit('close')">Cancel</Button>
<Button @click="emit('close')">Create invoice</Button>
</DialogActions>
</Dialog>
</template>Closing a dialog also closes the ones stacked above it, since they only existed to answer its question. Their promises resolve with undefined.
Escape and outside clicks reach the topmost dialog only, and the ones below are inert. On small screens the top dialog covers the stack instead of scaling it back, which keeps the flow readable on a phone.
Keep stacks shallow. Past maxDepth the provider warns in development, because a flow that deep reads better as a page. To protect a form with unsaved changes, skip close() in its close handler and ask first.