Modals
Open any modal from anywhere with await openModal(key, props) and get its result back, fully typed. Modals are registered by key, code-split until first opened, and stack: opening one from inside another pushes it on top while the ones beneath recede.
@dentil/modals—'use client';
import { useState } from 'react';
import { useModal } from '@/components/modals';
import { Button } from '@/components/ui/button';
export function ModalsOverviewDemo() {
const { openModal } = useModal();
const [result, setResult] = useState('—');
// `onConfirm` keeps the modal open with a spinner until the promise settles.
const archive = async () => {
const archived = await openModal('confirm', {
title: 'Archive invoice?',
description: 'Archived invoices are hidden from the default view. You can restore them later.',
confirmLabel: 'Archive',
onConfirm: () => new Promise((resolve) => setTimeout(resolve, 800)),
});
setResult(archived === undefined ? 'dismissed' : `archived: ${archived}`);
};
// A modal opened from inside another stacks on top. Throwing shows the error inline in the first one.
const deleteCustomer = async () => {
const deleted = await openModal('confirm', {
title: 'Delete customer?',
description: 'This customer has 3 open invoices. Deleting them needs a second confirmation.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
const voidInvoices = await openModal('confirm', {
title: 'Also void 3 invoices?',
description: 'Open invoices will be voided. This cannot be undone.',
confirmLabel: 'Void and delete',
destructive: true,
});
if (!voidInvoices) throw new Error('Cancelled: the customer was not deleted.');
},
});
setResult(deleted === undefined ? 'dismissed' : `deleted: ${deleted}`);
};
return (
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap gap-2">
<Button onClick={archive}>Archive invoice</Button>
<Button variant="destructive" onClick={deleteCustomer}>
Delete customer
</Button>
</div>
<span className="text-muted-foreground text-sm">
Result: <code className="text-foreground">{result}</code>
</span>
</div>
);
}Installation
pnpm dlx shadcn@latest add @dentil/modalsnpx shadcn@latest add @dentil/modalsyarn dlx shadcn@latest add @dentil/modalsbunx --bun shadcn@latest add @dentil/modalsFiles land in components/modals/, including registry.tsx, which you edit to add modals. Uses the stock shadcn dialog and button. Requires React 19. Without the @dentil registry in components.json, install by URL: https://dentil.dev/r/modals.json.
Setup
Wrap your app once in ModalProvider. It renders the modal stack itself, so there's nothing else to mount.
// app/layout.tsx (or your root component)
import { ModalProvider } from '@/components/modals';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ModalProvider
// Optional: one place to track every modal.
onOpen={(key) => analytics.track('modal_opened', { key })}
onClose={(key, { value, durationMs }) =>
analytics.track('modal_closed', { key, completed: value !== undefined, durationMs })
}>
{children}
</ModalProvider>
</body>
</html>
);
}Usage
A confirm modal ships with the registry.
'use client';
import { useModal } from '@/components/modals';
export function DeleteButton({ id }: { id: string }) {
const { openModal } = useModal();
const onClick = async () => {
// Props are type-checked against the modal; `confirmed` is `boolean | undefined`.
const confirmed = await openModal('confirm', {
title: 'Delete webhook?',
description: 'This action cannot be undone.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => deleteWebhook(id), // errors show inline and keep the modal open
});
if (confirmed) toast('Webhook deleted');
};
return <Button onClick={onClick}>Delete</Button>;
}Adding a modal
Write the content
A component rendered inside DialogContent. Type its props with ModalContentProps<TReturn>; render a DialogTitle for screen readers.
// components/modals/contents/rename.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import type { ModalContentProps } from '../types'; // ModalContentProps<TReturn> adds `modalId` and `close(value?: TReturn)`. type RenameModalProps = ModalContentProps<{ name: string }> & { name: string }; export default function RenameModal({ name: initial, close }: RenameModalProps) { const [name, setName] = useState(initial); return ( <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); close({ name }); }}> <DialogHeader> <DialogTitle>Rename</DialogTitle> </DialogHeader> <Input value={name} onChange={(event) => setName(event.target.value)} autoFocus /> <DialogFooter> <Button type="button" variant="outline" onClick={() => close()}> Cancel </Button> <Button type="submit">Save</Button> </DialogFooter> </form> ); }Register it
Keys, props and return types are inferred from the loader.
// components/modals/registry.tsx const modalLoaders = { confirm: () => import('./contents/confirm'), rename: () => import('./contents/rename'), // ← the only line to add } as const;Open it
const result = await openModal('rename', { name: 'Acme' }, { size: 'md' }); // ^? { name: string } | undefined
Examples
Confirm with an async action
onConfirm runs before the modal closes. Dismiss with Esc to see the undefined result.
—'use client';
import { useState } from 'react';
import { useModal } from '@/components/modals';
import { Button } from '@/components/ui/button';
export function ModalsBasicDemo() {
const { openModal } = useModal();
const [result, setResult] = useState<string>('—');
const archive = async () => {
const confirmed = await openModal('confirm', {
title: 'Archive invoice?',
description: 'Archived invoices are hidden from the default view. You can restore them later.',
confirmLabel: 'Archive',
onConfirm: () => new Promise((resolve) => setTimeout(resolve, 800)),
});
setResult(confirmed === undefined ? 'dismissed' : String(confirmed));
};
return (
<div className="flex items-center gap-4">
<Button onClick={archive}>Archive invoice</Button>
<span className="text-muted-foreground text-sm">
Result: <code className="text-foreground">{result}</code>
</span>
</div>
);
}Stacking
The first modal opens a second one from its onConfirm. Only the top modal is interactive, and Esc closes it alone.
'use client';
import { useModal } from '@/components/modals';
import { Button } from '@/components/ui/button';
export function ModalsStackingDemo() {
const { openModal } = useModal();
const deleteCustomer = () =>
openModal('confirm', {
title: 'Delete customer?',
description: 'This customer has 3 open invoices. Deleting them needs a second confirmation.',
confirmLabel: 'Delete',
destructive: true,
// Runs while the first modal is still open, so the second one stacks on top of it.
onConfirm: async () => {
const really = await openModal('confirm', {
title: 'Also void 3 invoices?',
description: 'Open invoices will be voided. This cannot be undone.',
confirmLabel: 'Void and delete',
destructive: true,
});
if (!really) throw new Error('Cancelled: the customer was not deleted.');
},
});
return (
<Button variant="destructive" onClick={deleteCustomer}>
Delete customer
</Button>
);
}Opening a modal from a modal
// Inside any modal content: useModal works there too.
const { openModal } = useModal();
const onDelete = async () => {
const sure = await openModal('confirm', { title: 'Are you sure?', destructive: true });
if (sure) close({ deleted: true });
};API reference
useModal()
| Prop | Type | Default | Description |
|---|---|---|---|
| openModal | (key, props?, options?) => Promise<Return | undefined> | — | Opens a modal on top of the stack. Props are required when the modal has required props. Resolves with the value passed to close, or undefined when dismissed with Esc, the overlay or the close button. |
| closeModal | (id: string, value?: unknown) => void | — | Closes a modal by id. Contents usually call their own close prop instead. |
| stack | ModalDescriptor[] | — | Open modals, bottom to top, including ones animating out. |
openModal options
| Prop | Type | Default | Description |
|---|---|---|---|
| size | 'default' | 'md' | 'lg' | 'xl' | 'full' | 'default' | Max width from the sm breakpoint: 32rem (stock dialog), 36rem, 42rem, 48rem or 56rem. |
ModalProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| onOpen | (key: ModalKey) => void | — | Called when any modal opens. |
| onClose | (key: ModalKey, info: { value, durationMs }) => void | — | Called when any modal closes. value is undefined when it was dismissed. |
ModalContentProps<TReturn>
Injected into every modal content.
| Prop | Type | Default | Description |
|---|---|---|---|
| modalId | string | — | This modal’s id in the stack. |
| close | (value?: TReturn) => void | — | Closes this modal and resolves openModal. Stable for the life of the modal. |
confirm
Resolves true when confirmed, false when cancelled.
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Dialog title. |
| description | ReactNode | — | Optional supporting text. |
| confirmLabel | string | 'Confirm' | Confirm button label. |
| cancelLabel | string | 'Cancel' | Cancel button label (closes with false). |
| destructive | boolean | false | Use the destructive button variant. |
| onConfirm | () => Promise<void> | void | — | Runs before closing with true. Buttons are disabled while it runs; a thrown error is shown inline. |