dentil

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
On this page
Result: —

Installation

pnpm dlx shadcn@latest add @dentil/modals
npx shadcn@latest add @dentil/modals
yarn dlx shadcn@latest add @dentil/modals
bunx --bun shadcn@latest add @dentil/modals

Files 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

  1. 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>
      );
    }
  2. 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;
  3. 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.

Result: —

Stacking

The first modal opens a second one from its onConfirm. Only the top modal is interactive, and Esc closes it alone.

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

PropTypeDefaultDescription
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.
stackModalDescriptor[]—Open modals, bottom to top, including ones animating out.

openModal options

PropTypeDefaultDescription
size'default' | 'md' | 'lg' | 'xl' | 'full''default'Max width from the sm breakpoint: 32rem (stock dialog), 36rem, 42rem, 48rem or 56rem.

ModalProvider

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

PropTypeDefaultDescription
modalIdstring—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.

PropTypeDefaultDescription
titlestring—Dialog title.
descriptionReactNode—Optional supporting text.
confirmLabelstring'Confirm'Confirm button label.
cancelLabelstring'Cancel'Cancel button label (closes with false).
destructivebooleanfalseUse 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.