Components · Overlays
Dialog
A modal on the native dialog element: focus stays inside, Escape and the backdrop close it, focus returns to the trigger.
0.78% of the 2,062 sites in The UI Index have a dialog (16 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ dialog.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ dialog.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
// Dialog behaviour, shared by the Astro, React and HTML versions. A trigger is a button with command="show-modal"
// and commandfor="<dialog id>" (the browser's own Invoker Commands; this fills them in where they are missing). On
// open, focus goes to [autofocus], else the first field, else the first footer button: never the close button. On
// close, focus returns to the trigger. A click on the backdrop closes the dialog.
declare global {
interface Window {
__caseroomDialog?: boolean;
}
}
const SELECTOR = 'dialog[data-dialog]';
const openers = new WeakMap<HTMLDialogElement, HTMLElement>();
function focusInitial(d: HTMLDialogElement) {
const target =
d.querySelector<HTMLElement>('[autofocus]') ??
d.querySelector<HTMLElement>('[data-dialog-body] :is(input:not([type=hidden]), select, textarea):not(:disabled)') ??
d.querySelector<HTMLElement>('[data-dialog-footer] :is(button, a[href]):not(:disabled)');
target?.focus();
}
export function openDialog(d: HTMLDialogElement, opener?: HTMLElement | null) {
if (opener) openers.set(d, opener);
if (!d.open) d.showModal();
focusInitial(d);
}
if (typeof window !== 'undefined' && !window.__caseroomDialog) {
window.__caseroomDialog = true;
const native = 'commandForElement' in HTMLButtonElement.prototype;
document.addEventListener(
'click',
(e) => {
const t = (e.target as Element).closest<HTMLButtonElement>('button[commandfor]');
if (t && !t.disabled) {
const d = document.getElementById(t.getAttribute('commandfor') ?? '');
if (d instanceof HTMLDialogElement && d.matches(SELECTOR)) {
const command = t.getAttribute('command');
if (command === 'show-modal') {
// Native support opens it itself; either way we remember the opener and set the first focus.
if (!native) e.preventDefault();
queueMicrotask(() => openDialog(d, t));
} else if (!native && (command === 'close' || command === 'request-close')) d.close();
}
return;
}
// A click on the backdrop lands on the dialog element itself, outside its box.
const d = e.target;
if (d instanceof HTMLDialogElement && d.matches(SELECTOR) && d.open) {
const r = d.getBoundingClientRect();
const ev = e as MouseEvent;
if (ev.clientX < r.left || ev.clientX > r.right || ev.clientY < r.top || ev.clientY > r.bottom) d.close();
}
},
true,
);
document.addEventListener(
'close',
(e) => {
const d = e.target;
if (!(d instanceof HTMLDialogElement) || !d.matches(SELECTOR)) return;
const opener = openers.get(d);
if (opener?.isConnected) opener.focus();
},
true,
);
}
export {};import { useEffect, useRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { openDialog } from './dialog.client';
import { CLOSE_PATH, descriptionId, dialog, titleId } from './dialog.variants';
export interface DialogProps {
/** Unique on the page. A trigger opens it with command="show-modal" commandfor={id}. */
id: string;
title: string;
description?: string;
/** Controlled mode: open and close it from state. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Buttons in the footer. They submit the dialog's form and close it; give them value="…" to tell them apart. */
footer?: ReactNode;
className?: string;
children?: ReactNode;
}
/** A modal on the native <dialog>: focus is trapped by the browser, Escape and the backdrop close it, and focus
* returns to the trigger. Open it with a trigger (command/commandfor) or with `open`. */
export function Dialog({ id, title, description, open, onOpenChange, footer, className, children }: DialogProps) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (!d || open === undefined) return;
if (open && !d.open) openDialog(d, document.activeElement as HTMLElement | null);
if (!open && d.open) d.close();
}, [open]);
useEffect(() => {
const d = ref.current;
if (!d || !onOpenChange) return;
const close = () => onOpenChange(false);
d.addEventListener('close', close);
return () => d.removeEventListener('close', close);
}, [onOpenChange]);
return (
<dialog ref={ref} id={id} data-dialog="" aria-labelledby={titleId(id)} aria-describedby={description ? descriptionId(id) : undefined} className={cn(dialog.root, className)}>
<div className={dialog.header}>
<div className="min-w-0">
<h2 id={titleId(id)} className={dialog.title}>{title}</h2>
{description && <p id={descriptionId(id)} className={dialog.description}>{description}</p>}
</div>
<form method="dialog" className={dialog.close}>
<button type="submit" aria-label="Close" className="inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className="size-4"><path d={CLOSE_PATH} /></svg>
</button>
</form>
</div>
<div data-dialog-body="" className={dialog.body}>{children}</div>
{footer && <form method="dialog" data-dialog-footer="" className={dialog.footer}>{footer}</form>}
</dialog>
);
}/** A modal on the native <dialog>: the browser traps focus, Escape closes, the page behind is inert. It fades and
* rises in (opacity and a 4px move; with reduced motion it only appears). */
export const dialog = {
root: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-popover p-0 text-popover-foreground shadow-2xl dark:shadow-[0_24px_64px_rgb(0_0_0/0.7)] backdrop:bg-black/50 backdrop:backdrop-blur-[2px] opacity-0 translate-y-1 transition-[opacity,translate,display,overlay] transition-discrete duration-150 ease-out open:opacity-100 open:translate-y-0 starting:open:opacity-0 starting:open:translate-y-1',
header: 'flex items-start justify-between gap-4 px-5 pt-5',
title: 'text-base font-semibold tracking-[-0.01em]',
description: 'mt-1 text-sm text-pretty text-muted-foreground',
close: '-mt-1.5 -mr-2 shrink-0',
body: 'px-5 py-4 text-sm',
footer: 'flex flex-wrap items-center justify-end gap-2 border-t bg-muted/50 px-5 py-3',
};
export const titleId = (id: string) => `${id}-title`;
export const descriptionId = (id: string) => `${id}-description`;
export const CLOSE_PATH = 'M4 4l8 8M12 4l-8 8';---
import { cn } from '@/lib/utils';
import { CLOSE_PATH, descriptionId, dialog, titleId } from './dialog.variants';
interface Props {
/** Unique on the page. A trigger opens it with command="show-modal" commandfor={id}. */
id: string;
title: string;
description?: string;
class?: string;
}
const { id, title, description, class: className } = Astro.props;
const hasFooter = Astro.slots.has('footer');
---
<dialog id={id} data-dialog aria-labelledby={titleId(id)} aria-describedby={description ? descriptionId(id) : undefined} class={cn(dialog.root, className)}>
<div class={dialog.header}>
<div class="min-w-0">
<h2 id={titleId(id)} class={dialog.title}>{title}</h2>
{description && <p id={descriptionId(id)} class={dialog.description}>{description}</p>}
</div>
<form method="dialog" class={dialog.close}>
<button type="submit" aria-label="Close" class="inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="size-4"><path d={CLOSE_PATH} /></svg>
</button>
</form>
</div>
<div data-dialog-body class={dialog.body}><slot /></div>
{hasFooter && <form method="dialog" data-dialog-footer class={dialog.footer}><slot name="footer" /></form>}
</dialog>
<script>
import './dialog.client';
</script>// Dialog behaviour, shared by the Astro, React and HTML versions. A trigger is a button with command="show-modal"
// and commandfor="<dialog id>" (the browser's own Invoker Commands; this fills them in where they are missing). On
// open, focus goes to [autofocus], else the first field, else the first footer button: never the close button. On
// close, focus returns to the trigger. A click on the backdrop closes the dialog.
declare global {
interface Window {
__caseroomDialog?: boolean;
}
}
const SELECTOR = 'dialog[data-dialog]';
const openers = new WeakMap<HTMLDialogElement, HTMLElement>();
function focusInitial(d: HTMLDialogElement) {
const target =
d.querySelector<HTMLElement>('[autofocus]') ??
d.querySelector<HTMLElement>('[data-dialog-body] :is(input:not([type=hidden]), select, textarea):not(:disabled)') ??
d.querySelector<HTMLElement>('[data-dialog-footer] :is(button, a[href]):not(:disabled)');
target?.focus();
}
export function openDialog(d: HTMLDialogElement, opener?: HTMLElement | null) {
if (opener) openers.set(d, opener);
if (!d.open) d.showModal();
focusInitial(d);
}
if (typeof window !== 'undefined' && !window.__caseroomDialog) {
window.__caseroomDialog = true;
const native = 'commandForElement' in HTMLButtonElement.prototype;
document.addEventListener(
'click',
(e) => {
const t = (e.target as Element).closest<HTMLButtonElement>('button[commandfor]');
if (t && !t.disabled) {
const d = document.getElementById(t.getAttribute('commandfor') ?? '');
if (d instanceof HTMLDialogElement && d.matches(SELECTOR)) {
const command = t.getAttribute('command');
if (command === 'show-modal') {
// Native support opens it itself; either way we remember the opener and set the first focus.
if (!native) e.preventDefault();
queueMicrotask(() => openDialog(d, t));
} else if (!native && (command === 'close' || command === 'request-close')) d.close();
}
return;
}
// A click on the backdrop lands on the dialog element itself, outside its box.
const d = e.target;
if (d instanceof HTMLDialogElement && d.matches(SELECTOR) && d.open) {
const r = d.getBoundingClientRect();
const ev = e as MouseEvent;
if (ev.clientX < r.left || ev.clientX > r.right || ev.clientY < r.top || ev.clientY > r.bottom) d.close();
}
},
true,
);
document.addEventListener(
'close',
(e) => {
const d = e.target;
if (!(d instanceof HTMLDialogElement) || !d.matches(SELECTOR)) return;
const opener = openers.get(d);
if (opener?.isConnected) opener.focus();
},
true,
);
}
export {};/** A modal on the native <dialog>: the browser traps focus, Escape closes, the page behind is inert. It fades and
* rises in (opacity and a 4px move; with reduced motion it only appears). */
export const dialog = {
root: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-popover p-0 text-popover-foreground shadow-2xl dark:shadow-[0_24px_64px_rgb(0_0_0/0.7)] backdrop:bg-black/50 backdrop:backdrop-blur-[2px] opacity-0 translate-y-1 transition-[opacity,translate,display,overlay] transition-discrete duration-150 ease-out open:opacity-100 open:translate-y-0 starting:open:opacity-0 starting:open:translate-y-1',
header: 'flex items-start justify-between gap-4 px-5 pt-5',
title: 'text-base font-semibold tracking-[-0.01em]',
description: 'mt-1 text-sm text-pretty text-muted-foreground',
close: '-mt-1.5 -mr-2 shrink-0',
body: 'px-5 py-4 text-sm',
footer: 'flex flex-wrap items-center justify-end gap-2 border-t bg-muted/50 px-5 py-3',
};
export const titleId = (id: string) => `${id}-title`;
export const descriptionId = (id: string) => `${id}-description`;
export const CLOSE_PATH = 'M4 4l8 8M12 4l-8 8';<div class="flex flex-wrap items-center justify-center gap-3">
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4" command="show-modal" commandfor="ex-rename">
Rename project
</button>
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-destructive text-destructive-foreground hover:bg-[color-mix(in_oklab,var(--destructive)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--destructive)_var(--press-amount),var(--press-mix-destructive,black))] h-9 px-4" command="show-modal" commandfor="ex-delete">
Delete project
</button>
</div>
<dialog id="ex-rename" data-dialog aria-labelledby="ex-rename-title" aria-describedby="ex-rename-description" class="m-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-popover p-0 text-popover-foreground shadow-2xl dark:shadow-[0_24px_64px_rgb(0_0_0/0.7)] backdrop:bg-black/50 backdrop:backdrop-blur-[2px] opacity-0 translate-y-1 transition-[opacity,translate,display,overlay] transition-discrete duration-150 ease-out open:opacity-100 open:translate-y-0 starting:open:opacity-0 starting:open:translate-y-1">
<div class="flex items-start justify-between gap-4 px-5 pt-5">
<div class="min-w-0">
<h2 id="ex-rename-title" class="text-base font-semibold tracking-[-0.01em]">Rename project</h2>
<p id="ex-rename-description" class="mt-1 text-sm text-pretty text-muted-foreground">The new name shows on the dashboard and in invites.</p>
</div>
<form method="dialog" class="-mt-1.5 -mr-2 shrink-0">
<button type="submit" aria-label="Close" class="inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="size-4"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
</button>
</form>
</div>
<div data-dialog-body class="px-5 py-4 text-sm">
<div class="grid gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium has-[input]:items-start has-[input]:leading-snug has-[input]:[&>*:first-child]:mt-px select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50" for="ex-rename-name">
Name
</label>
<input type="text" class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 py-1 file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium" id="ex-rename-name" name="name" value="Northwind site">
</div>
</div>
<form method="dialog" data-dialog-footer class="flex flex-wrap items-center justify-end gap-2 border-t bg-muted/50 px-5 py-3">
<button type="submit" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4" value="cancel">
Cancel
</button>
<button type="submit" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] h-9 px-4" value="save">
Save
</button>
</form>
</dialog>
<dialog id="ex-delete" data-dialog aria-labelledby="ex-delete-title" aria-describedby="ex-delete-description" class="m-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-popover p-0 text-popover-foreground shadow-2xl dark:shadow-[0_24px_64px_rgb(0_0_0/0.7)] backdrop:bg-black/50 backdrop:backdrop-blur-[2px] opacity-0 translate-y-1 transition-[opacity,translate,display,overlay] transition-discrete duration-150 ease-out open:opacity-100 open:translate-y-0 starting:open:opacity-0 starting:open:translate-y-1">
<div class="flex items-start justify-between gap-4 px-5 pt-5">
<div class="min-w-0">
<h2 id="ex-delete-title" class="text-base font-semibold tracking-[-0.01em]">Delete Northwind site?</h2>
<p id="ex-delete-description" class="mt-1 text-sm text-pretty text-muted-foreground">
The project, its deploys and its domain settings are removed. This can't be undone.
</p>
</div>
<form method="dialog" class="-mt-1.5 -mr-2 shrink-0">
<button type="submit" aria-label="Close" class="inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="size-4"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
</button>
</form>
</div>
<div data-dialog-body class="px-5 py-4 text-sm"></div>
<form method="dialog" data-dialog-footer class="flex flex-wrap items-center justify-end gap-2 border-t bg-muted/50 px-5 py-3">
<button type="submit" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4" value="cancel">
Keep it
</button>
<button type="submit" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-destructive text-destructive-foreground hover:bg-[color-mix(in_oklab,var(--destructive)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--destructive)_var(--press-amount),var(--press-mix-destructive,black))] h-9 px-4" value="delete">
Delete project
</button>
</form>
</dialog><script type="module" src="/caseroom/r/html/dialog.js"></script>