Dialog
A modal built on the native dialog element: focus is trapped, Esc closes it, and focus returns to the trigger.
betaDialog.tsx
Examples
Confirm
Controlled with open and onOpenChange so the footer buttons can close it.
dialog/basic.tsx
"use client";
import { useState } from "react";
import { Trash } from "lucide-react";
import { Button, Dialog } from "@streamuilib/react";
export default function DialogBasic() {
const [open, setOpen] = useState(false);
return (
<Dialog
open={open}
onOpenChange={setOpen}
title="Delete this project?"
description="This removes all deployments and can't be undone."
trigger={
<Button variant="pink">
<Trash aria-hidden /> Delete project
</Button>
}
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="pink" onClick={() => setOpen(false)}>
Delete
</Button>
</>
}
/>
);
}With a form
dialog/form.tsx
"use client";
import { useState } from "react";
import { UserPlus } from "lucide-react";
import { Button, Dialog, Input } from "@streamuilib/react";
export default function DialogForm() {
const [open, setOpen] = useState(false);
return (
<Dialog
open={open}
onOpenChange={setOpen}
title="Invite a teammate"
trigger={
<Button>
<UserPlus aria-hidden /> Invite
</Button>
}
>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
}}
>
<div>
<label htmlFor="dialog-invite-email" className="mb-2 block font-bold">
Email
</label>
<Input id="dialog-invite-email" type="email" required placeholder="name@company.com" />
</div>
<Button type="submit" className="self-end">
Send invite
</Button>
</form>
</Dialog>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { Button, Dialog } from "@streamuilib/react";
<Dialog title="Delete this project?" trigger={<Button>Delete</Button>}>
This can't be undone.
</Dialog>API
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Heading; also the dialog's accessible name. Required. |
| description | ReactNode | — | Supporting line; becomes the accessible description. |
| children | ReactNode | — | Body content. |
| footer | ReactNode | — | Actions along the bottom. |
| trigger | ReactElement | — | Element that opens the dialog; gets aria-haspopup and aria-expanded. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called on every open and close, including Esc. |
| dismissible | boolean | true | Close when the backdrop is clicked. |
| className | string | — | Classes for the panel. |
Accessibility
- Uses <dialog>.showModal(): focus moves into the dialog, Tab stays inside, and the rest of the page is inert.
- Esc closes it and focus returns to the element that opened it.
- Named by its title (aria-labelledby) and described by its description (aria-describedby). The close button has a label.
Source
packages/react/src/Dialog.tsx
"use client";
import {
cloneElement,
isValidElement,
useEffect,
useId,
useRef,
useState,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { X } from "lucide-react";
import { cn } from "./cn";
export type DialogProps = {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
/** Buttons along the bottom, e.g. Cancel / Confirm. */
footer?: ReactNode;
/** An element that opens the dialog when clicked (uncontrolled use). */
trigger?: ReactElement<{ onClick?: (e: MouseEvent) => void }>;
/** Controlled open state. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Close when the backdrop is clicked. */
dismissible?: boolean;
className?: string;
};
/**
* A modal built on native <dialog>.showModal(): focus moves in and is
* trapped, Esc closes, focus returns to the trigger, and the rest of the
* page is inert, all from the browser.
*/
export function Dialog({
title,
description,
children,
footer,
trigger,
open,
defaultOpen = false,
onOpenChange,
dismissible = true,
className,
}: DialogProps) {
const [internal, setInternal] = useState(defaultOpen);
const isOpen = open ?? internal;
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
const setOpen = (next: boolean) => {
setInternal(next);
onOpenChange?.(next);
};
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (isOpen && !dialog.open) dialog.showModal();
if (!isOpen && dialog.open) dialog.close();
}, [isOpen]);
return (
<>
{isValidElement(trigger) &&
cloneElement(trigger, {
"aria-haspopup": "dialog",
"aria-expanded": isOpen,
onClick: (e: MouseEvent) => {
trigger.props.onClick?.(e);
setOpen(true);
},
} as Record<string, unknown>)}
<dialog
ref={ref}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
// Esc (cancel) and form[method=dialog] both end in "close".
onClose={() => isOpen && setOpen(false)}
onClick={(e) => {
// A click on the <dialog> itself, not its panel, is the backdrop.
if (dismissible && e.target === e.currentTarget) setOpen(false);
}}
className={cn(
// The <dialog> is a transparent full-screen layer; the panel sits inside it,
// so a click on the padding around the panel is a backdrop click.
"fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none place-items-center bg-transparent p-4 open:grid",
"backdrop:bg-ink/40",
"transition-[opacity,display,overlay] transition-discrete duration-200 ease-out starting:open:opacity-0 motion-reduce:transition-none",
)}
>
<div
className={cn(
"flex w-full max-w-lg flex-col border-2 border-ink bg-paper-raised text-ink shadow-brutal-lg",
className,
)}
>
<div className="flex items-start justify-between gap-4 border-b-2 border-ink p-5">
<div className="flex flex-col gap-1.5">
<h2 id={titleId} className="text-lg leading-tight font-bold tracking-tight">
{title}
</h2>
{description && (
<p id={descriptionId} className="text-sm leading-relaxed text-ink-muted">
{description}
</p>
)}
</div>
<button
type="button"
aria-label="Close"
onClick={() => setOpen(false)}
className="grid size-9 shrink-0 cursor-pointer place-items-center border-2 border-ink bg-paper-raised transition-colors duration-150 hover:bg-pink"
>
<X aria-hidden className="size-4.5" strokeWidth={2.5} />
</button>
</div>
{children && <div className="p-5 leading-relaxed">{children}</div>}
{footer && (
<div className="flex flex-wrap justify-end gap-3 border-t-2 border-ink bg-paper p-5">{footer}</div>
)}
</div>
</dialog>
</>
);
}