Modal

A portal-based dialog that floats above all content. Supports keyboard dismissal, click-outside, four sizes, and composable header, body, and footer regions.

Preview

Form modal

Any content works inside the body. Inputs, lists, code blocks — the modal adapts to its content height up to 90vh, after which the body scrolls internally.

Implementation

React: rendered via createPortal into document.body — never trapped inside overflow-hidden ancestors. HTML: uses the native <dialog> element with ::backdrop.

Modal.tsx
tsx
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { Button } from "@/components/ui/Button";

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

  return (
    <>
      <Button variant="danger" onClick={() => setOpen(true)}>
        Delete project
      </Button>

      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Confirm deletion"
        description="This action cannot be undone."
        footer={
          <>
            <Button variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button variant="danger" onClick={() => setOpen(false)}>
              Delete
            </Button>
          </>
        }
      >
        <p>All files and collaborators will be permanently removed.</p>
      </Modal>
    </>
  );
}

Props

PropTypeDefaultDescription
openbooleanControls visibility. When false the modal unmounts completely.
onClose() => voidCalled on Escape key, backdrop click, or close button press.
titlestringRenders an <h2> in the header and wires aria-labelledby.
descriptionstringSubtitle below the title. Wires aria-describedby.
size"sm" | "md" | "lg" | "xl""md"Sets max-width: sm=384px, md=448px, lg=512px, xl=672px.
footerReactNodeAction row rendered at the bottom with a top border.
childrenReactNodeBody content rendered between the header and footer.

Motion

Entry and exit should feel fast and purposeful, not decorative.

PhasePropertyValueEasing
Enteropacity0 → 1200ms ease-out
Enterscale0.96 → 1200ms ease-out
Exitopacity1 → 0150ms ease-in
Exitscale1 → 0.96150ms ease-in
Backdropopacity0 → 0.5200ms ease-out

Mobile

On mobile, modals compete with the virtual keyboard and limited screen real estate. The preferred mobile pattern is a bottom sheet, not a centered dialog.

9:41

Settings

Notifications
Privacy
Appearance
Account
ScenarioGuidance
Layout on mobileUse w-full max-w-none on small screens so the modal fills the viewport width. Set max-height: 90dvh with overflow-y: auto to handle tall content.
Bottom sheetOn mobile, slide the panel up from the bottom edge (translate-y transition) instead of scaling from center. Add a 4px drag handle at the top for discoverability.
Virtual keyboardWhen the modal contains an input, the virtual keyboard pushes content up. Use position: fixed and env(safe-area-inset-bottom) to prevent the close button from being hidden.
Swipe to dismissAllow a downward swipe gesture to dismiss. Track touchstart/touchmove delta; dismiss when drag exceeds 120px or velocity exceeds 0.5px/ms.
Safe area insetsAdd padding-bottom: env(safe-area-inset-bottom) to the modal footer to clear the iPhone home indicator and Android gesture bar.

Accessibility

  • role="dialog" and aria-modal="true" are applied automatically — screen readers announce the overlay context.
  • aria-labelledby and aria-describedby are wired to the title and description when provided.
  • Escape key always dismisses. Body scroll is locked while open.
  • Focus should be moved into the modal on open and restored to the trigger on close (implement with useEffect + ref).
  • Backdrop click dismisses via pointer event on the overlay, not the panel, so accidental interior clicks are safe.