Gecko UIGecko UI

Dialog

Modal component for centered overlay content, usable declaratively or imperatively

Dialog

A modal component that renders content in a centered overlay. It serves as the foundational layer for more complex dialog patterns and handles escape key dismissal and backdrop click behavior.

There are two ways to use it:

  • Declarative — <Dialog open onClose={...}>, when the dialog belongs to a component's state. Same shape as Drawer.
  • Imperative — Dialog.show(), for one-off dialogs triggered from anywhere. Multiple dialogs stack, and each show() returns an id for targeted dismissal.

Dialogs always render above drawers.

Setup

Dialog.show() requires <GeckoUIProvider> to wrap your app. The declarative <Dialog> renders in place and works without it.

import { Dialog } from '@geckoui/geckoui';

Declarative Usage

import { Dialog, Button } from '@geckoui/geckoui';

function Example() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setOpen(true)}>Open Dialog</Button>
      <Dialog open={open} onClose={() => setOpen(false)}>
        <h2 className="text-xl font-semibold mb-4">Controlled Dialog</h2>
        <Button onClick={() => setOpen(false)}>Close</Button>
      </Dialog>
    </>
  );
}

Props

PropTypeDefaultDescription
openbooleanfalseWhether the dialog is visible
onClose() => void—Called when the dialog asks to close (Esc or backdrop click)
childrenReactNode—Dialog content
classNamestring—CSS class for the dialog container
dismissOnEscapebooleantrueDismiss dialog on Escape key press
dismissOnOutsideClickbooleantrueDismiss dialog on backdrop click
data-*string—Data attributes for the dialog element

Imperative Usage

import { Dialog, Button } from '@geckoui/geckoui';

function Example() {
  const openDialog = () => {
    Dialog.show({
      content: ({ dismiss }) => (
        <div>
          <h2 className="text-xl font-semibold mb-4">Welcome!</h2>
          <p className="text-gray-600 mb-6">
            This is a simple dialog with custom content.
          </p>
          <Button onClick={dismiss}>Close</Button>
        </div>
      )
    });
  };

  return <Button onClick={openDialog}>Open Dialog</Button>;
}

API

Dialog.show(options)

Opens a dialog and returns an id string you can pass to dismiss(id).

OptionTypeDefaultDescription
contentReactNode | FC<{ dismiss: () => void }>—Content to display in the dialog
classNamestring—CSS class for the dialog container
dismissOnEscapebooleantrueDismiss dialog on Escape key press
dismissOnOutsideClickbooleantrueDismiss dialog on backdrop click
data-*string—Data attributes for the dialog element

Returns: string — the overlay id.

Dialog.dismiss(id?)

Closes a dialog. Pass the id returned by show() to close a specific dialog, or call with no argument to close the topmost dialog. It never closes a drawer — use Drawer.dismiss() for those.

const id = Dialog.show({ content: () => <p>Hello</p> });

// Close this specific dialog:
Dialog.dismiss(id);

// Close whatever dialog is on top:
Dialog.dismiss();

Note: clicks inside the dialog never dismiss it. That includes content rendered in a portal, such as a Select or Menu popup — only a click on the backdrop itself closes the dialog.

Examples

Stacking Dialogs

Dialog.show({
  content: ({ dismiss }) => (
    <div>
      <p>First dialog</p>
      <Button onClick={() =>
        Dialog.show({ content: () => <p>Second dialog (stacked on top)</p> })
      }>
        Open another
      </Button>
      <Button onClick={dismiss}>Close this</Button>
    </div>
  )
});

Context Consumption

Overlay content can read any React context provided above GeckoUIProvider:

// Inside a component where AuthContext is available:
Dialog.show({
  content: () => {
    const { user } = useContext(AuthContext);
    return <p>Logged in as {user.name}</p>;
  }
});

Image Lightbox

const openImageViewer = () => {
  Dialog.show({
    content: ({ dismiss }) => (
      <div className="relative">
        <img src="/large-image.jpg" alt="Preview" className="max-w-4xl rounded-lg" />
        <button onClick={dismiss} className="absolute top-4 right-4 size-8 flex items-center justify-center bg-black/50 text-white rounded-full">
          ✕
        </button>
      </div>
    ),
    className: "bg-transparent shadow-none",
    dismissOnEscape: true,
    dismissOnOutsideClick: true
  });
};

Form Modal

import { Input, Label } from '@geckoui/geckoui';

const showUserForm = () => {
  Dialog.show({
    content: ({ dismiss }) => (
      <div>
        <h2 className="text-xl font-semibold mb-4">Edit Profile</h2>
        <div className="space-y-4">
          <div className="space-y-1">
            <Label htmlFor="name">Name</Label>
            <Input id="name" type="text" defaultValue="John Doe" />
          </div>
          <div className="flex gap-2 justify-end">
            <Button variant="ghost" onClick={dismiss}>Cancel</Button>
            <Button onClick={dismiss}>Save Changes</Button>
          </div>
        </div>
      </div>
    ),
    className: "max-w-2xl",
    dismissOnEscape: false,
    dismissOnOutsideClick: false
  });
};

Non-Dismissible Dialog

Dialog.show({
  content: ({ dismiss }) => (
    <div>
      <h2 className="text-xl font-semibold mb-4">Important Notice</h2>
      <p className="text-gray-600 mb-6">This dialog can only be closed by clicking the button.</p>
      <Button onClick={dismiss}>I Understand</Button>
    </div>
  ),
  dismissOnEscape: false,
  dismissOnOutsideClick: false
});

Loading State

Dialog.show({
  content: () => (
    <div className="flex flex-col items-center gap-4">
      <Spinner />
      <p>Processing payment...</p>
    </div>
  ),
  dismissOnEscape: false,
  dismissOnOutsideClick: false
});

await processPayment();
Dialog.dismiss();

Behavior

Stacking

Each Dialog.show() pushes onto a shared overlay stack. The topmost dialog captures Esc and backdrop clicks; lower dialogs ignore them until they become topmost.

Dialogs sit above drawers, so an open dialog is topmost even when a drawer opened after it. A dialog gives up topmost as soon as it starts closing, so the overlay underneath responds to Esc right away instead of waiting for the 300ms exit animation.

Focus Management

Blurs the active element when a dialog opens.

Dismissal

  • Escape Key: Closes the topmost dialog by default (disable with dismissOnEscape: false)
  • Backdrop Click: Closes the topmost dialog by default (disable with dismissOnOutsideClick: false). Both the press and the release must land on the backdrop, so dragging to select text past the dialog edge does not close it.
  • Programmatic: Dialog.dismiss(id?) from anywhere

Clicks inside the dialog never dismiss it. That includes content rendered in a portal, such as a Select or Menu popup, which lives outside the dialog element in the DOM.

Styling

  • GeckoUIDialog — Main container (data-state="entering" | "open" | "closing"), z-index: 2000
  • GeckoUIDialog__backdrop — Background overlay
  • GeckoUIDialog__dialog — Dialog content wrapper

Stacked dialogs get an explicit z-index of 2000 + position, so they never depend on DOM order. Drawers start at 1000, which is why dialogs always win.

Accessibility

  • role="dialog" and aria-modal="true" on the dialog panel
  • Escape key support
  • Backdrop click detection
  • A menu, popover or picker open inside it closes first: an outside click or Escape reaches only the topmost overlay, so the next one closes the dialog

Page scroll is locked while a dialog is open, with the scrollbar width paid back as padding so nothing shifts sideways. Stacked overlays share one reference counted lock.

Not handled yet: there is no focus trap, and focus is not restored to the trigger on close. iOS Safari ignores overflow: hidden on the body, so the page behind can still be dragged there.