stream/ui
Browse the library

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.

Delete this project?

This removes all deployments and can't be undone.

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

Invite a teammate

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/react
usage.tsx
import { Button, Dialog } from "@streamuilib/react";

<Dialog title="Delete this project?" trigger={<Button>Delete</Button>}>
  This can't be undone.
</Dialog>

API

PropTypeDefaultDescription
titleReactNode—Heading; also the dialog's accessible name. Required.
descriptionReactNode—Supporting line; becomes the accessible description.
childrenReactNode—Body content.
footerReactNode—Actions along the bottom.
triggerReactElement—Element that opens the dialog; gets aria-haspopup and aria-expanded.
openboolean—Controlled open state.
defaultOpenbooleanfalseInitial state when uncontrolled.
onOpenChange(open: boolean) => void—Called on every open and close, including Esc.
dismissiblebooleantrueClose when the backdrop is clicked.
classNamestring—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>
    </>
  );
}