Skip to content
Beta · ExperimentalReact 19No peer dependencies

Modal

Forms, details and drawers above the page, built on the native dialog element with focus and dismiss handled for you.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block@latest -a Modal -beta

The block copies the modal folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely. There are no peer dependencies other than React.

Requirements

  • React 19 and @types/react 19
  • TypeScript target ES2022 or newer, with "jsx": "react-jsx"
  • Browsers from 2024 or newer (the styles use light-dark(), :has() and @starting-style)

Import the stylesheet once, for example in your global CSS:

css
@import "../components/modal/styles.css";

or in your root layout / entry file:

ts
import "@/components/modal/styles.css";

The Modal shows content above the page: forms, details, terms, or anything that needs the user's full attention. It is built on the browser's own <dialog> element. The browser keeps it above every other element, makes the page behind it unreachable, keeps keyboard focus inside and returns focus to where it was on close. The component adds controlled state, dismiss rules, a guard for unsaved changes, sizes, side and bottom drawers, and animation.

For short questions — "Delete this?", "Rename to…" — use the Dialog instead.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

Default

Live preview

Quick Start

tsx
"use client";
 
import { useState } from "react";
import { Modal } from "@/components/modal";
 
export default function EditProfile() {
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <button onClick={() => setOpen(true)}>Edit profile</button>
 
      <Modal
        open={open}
        onOpenChange={setOpen}
        title="Edit profile"
        description="Changes are saved when you press Save."
        footer={({ close }) => (
          <>
            <button onClick={close}>Cancel</button>
            <button type="submit" form="profile-form">Save</button>
          </>
        )}
      >
        <form id="profile-form" onSubmit={(event) => { event.preventDefault(); save(); setOpen(false); }}>
          <input name="name" data-autofocus />
        </form>
      </Modal>
    </>
  );
}

The Modal is controlled with open + onOpenChange, or uncontrolled with defaultOpen and the ref's open() / close().

A submit button in the footer can belong to a form in the body through the form attribute (form='profile-form'), so Enter in any field and the Save button submit the same form.

Props Table

PropTypeDefaultDescription
openbooleanOpen state (controlled).
defaultOpenbooleanfalseStarting state (uncontrolled).
onOpenChange(open: boolean, reason?: CloseReason) => voidFires on open and close. reason says what closed it: escape, backdrop, close-button or api.
titleReactNodeHeading; also names the modal for screen readers.
descriptionReactNodeText under the title; also describes the modal.
aria-labelstringAccessible name when there is no title.
childrenReactNode | ({ close }) => ReactNodeBody. Scrolls on its own when it's taller than the screen.
footerReactNode | ({ close }) => ReactNodeFooter, usually buttons. Stays visible while the body scrolls.
size"sm" | "md" | "lg" | "xl" | "full""md"Width: 400, 520, 720 or 960 px, or the whole screen. For drawers, the drawer's width (or height at the bottom).
placement"center" | "top" | "left" | "right" | "bottom""center"top sits near the top of the screen; left, right and bottom slide in as drawers.
closeButtonbooleantrueShow the × button.
closeOnEscapebooleantrueClose on Escape.
closeOnBackdropbooleantrueClose on a click outside the modal.
onBeforeClose(reason: CloseReason) => boolean | Promise<boolean>Return false to keep the modal open. See Closing.
initialFocusRefObject<HTMLElement | null>Element to focus on open. See Focus.
keepMountedbooleanfalseKeep the content mounted while closed, so its state survives.
idstringgeneratedId of the <dialog>; also the base for the title and description ids.
classNamestringClass for the <dialog>.
classNamesPartial<Record<ModalSlot, string>>Classes per slot: root, header, title, description, close, body, footer.
styleCSSPropertiesInline style for the <dialog> (e.g. CSS variables).
localeTextPartial<ModalLocaleText>EnglishOverrides UI text. See Locale Text.
refRef<ModalHandle>Imperative API. See Imperative API.

Closing

A modal closes through Escape, a backdrop click, the × button, or your own code: close() from the children / footer render props, ref.close(), or setting open to false.

SourcereasonTurned off by
EscapeescapecloseOnEscape={false}
Click outside the modalbackdropcloseOnBackdrop={false}
× buttonclose-buttoncloseButton={false}
close(), ref.close()api

Every one of these, except setting open yourself, goes through onBeforeClose. That makes it the place to protect unsaved work:

tsx
<Modal
  open={open}
  onOpenChange={setOpen}
  closeOnBackdrop={false}
  onBeforeClose={() => !isDirty || dialog.confirm({ title: "Discard your changes?", intent: "warning" })}
>
  • Returning false, or a promise that resolves false, keeps the modal open. If the guard throws, the modal also stays open.
  • A drag that starts inside the modal and ends on the backdrop, for example while selecting text, doesn't close it.

Sizes and Drawers

tsx
<Modal size="lg" />                       {/* a wider dialog */}
<Modal size="full" />                     {/* covers the screen */}
<Modal placement="top" />                 {/* near the top, for command palettes and search */}
<Modal placement="right" size="sm" />     {/* a 400 px drawer from the right */}
<Modal placement="bottom" />              {/* a bottom sheet, handy on phones */}

Drawers fill the height of the screen (or the width, at the bottom) and slide in from their side. left and right refer to the physical screen edges.

Focus

When the modal opens, focus moves to, in order of preference:

  1. the element in initialFocus,
  2. the first element with data-autofocus,
  3. the first focusable element (the browser's default, often the × button).

Focus stays inside while the modal is open, and returns to the element that had it before, usually the button that opened it.

tsx
<input name="email" data-autofocus />

Imperative API (ref)

tsx
const modal = useRef<ModalHandle>(null);
 
<Modal ref={modal} defaultOpen={false} title="Shortcuts">…</Modal>;
 
modal.current?.open();
MethodSignatureDescription
open() => voidOpen the modal.
close() => voidAsk to close, with reason api; onBeforeClose still applies.
getElement() => HTMLDialogElement | nullThe underlying <dialog>.

Keyboard

KeysAction
Tab / Shift + TabMove between focusable elements inside the modal only.
EscapeClose, unless closeOnEscape={false} or onBeforeClose says no.

Accessibility: the modal is a native modal <dialog>, so the page behind it is inert. Screen readers can't wander out of it, and clicks and focus can't reach it. The title labels it (aria-labelledby) and the description describes it (aria-describedby). The × button is labelled with localeText.close.

Styling and Theming

All rules are in the CSS components layer, so utility classes passed through className / classNames override them.

tsx
<Modal className="max-w-2xl" classNames={{ body: "space-y-4", footer: "justify-between" }} />

CSS variables

Override them on .md-root, on :root, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.

VariableUsed for
--md-widthWidth (set by size).
--md-gutterSpace kept between the modal and the screen edges (16px).
--md-pxHorizontal padding of the header, body and footer.
--md-bg, --md-fgBackground and text color.
--md-mutedDescription and the × button.
--md-borderBorders.
--md-footer-bgFooter background.
--md-backdropThe dimmed page behind the modal.
--md-shadowShadow.
--md-radiusCorner radius.
--md-durationEnter and exit animation length (200ms).

Dark mode

Colors follow the page's color-scheme. To force a scheme, put class="dark" or data-theme="dark" (or "light") on an ancestor such as <html>.

Data attributes

ElementAttributes
Root (dialog.md-root)data-size, data-placement, data-state (open / closed), open

Enter and exit use @starting-style and transition-behavior: allow-discrete. Browsers without them open and close instantly. With prefers-reduced-motion the modal fades without moving.

Locale Text

tsx
<Modal localeText={{ close: "Schließen" }} />
KeyDefault
close"Close"

Headless Use

The rules the Modal follows are exported from the framework-free @/components/modal/core:

ExportDescription
canDismiss(reason, { closeOnEscape, closeOnBackdrop })Whether a close request is allowed before the guard runs.
isOutside(rect, x, y)Whether a click on the <dialog> landed on its backdrop.
confirmClose(guard, reason)Runs an onBeforeClose guard; resolves false to stay open.

Next.js

The component is a client component, and "use client" is already at the top of its file. The <dialog> element renders closed on the server and opens after hydration, so there is no hydration mismatch. Opening a modal from a Server Component means rendering it inside a small client component that holds the open state.

Migrating from the Previous Modal

PreviousNew
isOpenopen
setIsOpenonOpenChange — pass your setter: onOpenChange={setOpen}
onButtonClick("Action_1" | "Action_2") with built-in Yes/No buttonsYour own buttons in footer, or use the Dialog: await dialog.confirm(...)
confirmBtn / deletBtnButton text in your footer, or confirmLabel / cancelLabel on the Dialog
titleSame name
childrenSame; can also be a function receiving { close }
gbs-modal-containerclassName or classNames.root
gbs-modal-titleclassNames.title
gbs-modal-contentclassNames.body
gbs-modal-buttons, -action1, -action2classNames.footer, and classes on your own buttons
No Escape, focus trap or focus returnAll built in through the native <dialog>
New: description, size, placement (drawers), onBeforeClose, closeOnEscape, closeOnBackdrop, initialFocus, keepMounted

Notes

  • The page behind is locked with :root:has(.md-root[open]) { overflow: hidden }. On pages with a visible scrollbar, content can move sideways by the scrollbar's width while a modal is open.
  • Nested modals work: each new one stacks above the last, and Escape closes the top one.
  • closeOnBackdrop needs a visible gap between the modal and the screen edge. A full-size modal has none, so use its × button or your own.