‮Skip to content‬
uiify

Modal

‮Modal dialog built on the native dialog element.‬

‮Browsers‬
Chrome 123+Firefox 121+Safari 17.5+
‮Tier‬
0
‮Bundle compressed‬
944 B
‮Forced layout budget‬
0 ms
‮Axe budget‬
0
‮SSR compatible‬
‮Yes‬
‮Built on‬
elements/dialog
‮Published contract‬
‮Test harness‬

‮Playground‬

Modal

Closed

Controlled

Parent state owns this modal after hydration.

Changes: 0, state: closed

‮Examples‬

Sizes

size writes data-size: sm 24rem, md (default) 28rem, lg 40rem, full fills the viewport. On a phone every size except full is a bottom sheet.

Small

24rem wide.

Large

40rem wide.

import { Modal } from "@gunkin/uiify";

export default function Example() {
  return (
    <>
      <Modal id="size-sm" size="sm" title="Small" trigger="Small">
        <p>24rem wide.</p>
      </Modal>
      <Modal id="size-lg" size="lg" title="Large" trigger="Large">
        <p>40rem wide.</p>
      </Modal>
    </>
  );
}

Long content

The title, content and footer share one scroll area. Compose the parts inside ModalBody for a document-style dialog.

Terms of service

The whole document scrolls together.

Clause 1: text that makes the document taller than the screen.

Clause 2: text that makes the document taller than the screen.

Clause 3: text that makes the document taller than the screen.

Clause 4: text that makes the document taller than the screen.

Clause 5: text that makes the document taller than the screen.

Clause 6: text that makes the document taller than the screen.

Clause 7: text that makes the document taller than the screen.

Clause 8: text that makes the document taller than the screen.

Clause 9: text that makes the document taller than the screen.

Clause 10: text that makes the document taller than the screen.

Clause 11: text that makes the document taller than the screen.

Clause 12: text that makes the document taller than the screen.

Clause 13: text that makes the document taller than the screen.

Clause 14: text that makes the document taller than the screen.

Clause 15: text that makes the document taller than the screen.

Clause 16: text that makes the document taller than the screen.

Clause 17: text that makes the document taller than the screen.

Clause 18: text that makes the document taller than the screen.

Clause 19: text that makes the document taller than the screen.

Clause 20: text that makes the document taller than the screen.

Clause 21: text that makes the document taller than the screen.

Clause 22: text that makes the document taller than the screen.

Clause 23: text that makes the document taller than the screen.

Clause 24: text that makes the document taller than the screen.

Clause 25: text that makes the document taller than the screen.

Clause 26: text that makes the document taller than the screen.

Clause 27: text that makes the document taller than the screen.

Clause 28: text that makes the document taller than the screen.

Clause 29: text that makes the document taller than the screen.

Clause 30: text that makes the document taller than the screen.

import { ModalTrigger, ModalContent, ModalBody, ModalHeader, ModalTitle, ModalClose, ModalFooter } from "@gunkin/uiify";
export default function Example() {
  return <>
    <ModalTrigger target="terms">Read terms</ModalTrigger>
    <ModalContent id="terms" aria-labelledby="terms-title">
      <ModalBody>
        <ModalHeader><ModalTitle id="terms-title">Terms of service</ModalTitle><ModalClose aria-label="Close" target="terms">×</ModalClose></ModalHeader>
        {Array.from({ length: 30 }, (_, index) => <p key={index}>Clause {index + 1}: text that makes the document taller than the screen.</p>)}
        <ModalFooter><ModalClose target="terms">Done</ModalClose></ModalFooter>
      </ModalBody>
    </ModalContent>
  </>;
}

Opening a modal from a modal

Render the child Modal inside the parent. It opens on top, Escape closes only the child, focus returns to the button that opened it, and the page is dimmed once.

Edit profile

Unsaved changes are lost when you close.

Discard changes?

This cannot be undone.

import { Modal, ModalClose } from "@gunkin/uiify";

export default function Example() {
  return (
    <Modal
      id="editor"
      trigger="Edit profile"
      title="Edit profile"
      description="Unsaved changes are lost when you close."
    >
      <Modal
        id="discard"
        trigger="Discard changes"
        title="Discard changes?"
        description="This cannot be undone."
        footer={<ModalClose target="discard">Keep editing</ModalClose>}
      />
    </Modal>
  );
}

‮API‬

Modal

ModalTrigger

‮Prop‬‮Type‬‮Default‬‮Description‬
target*string—‮Id of the ModalContent this button opens; the browser resolves it at click time, so no JavaScript of ours runs.‬
childrenReactNode—‮Content of the trigger button.‬
asTAs & NativeButtonAs<TAs>"button"‮A component that forwards props and ref to a native button; other tags are rejected, because invoker commands need a real button.‬

ModalContent

‮Prop‬‮Type‬‮Default‬‮Description‬
id*string—‮Unique id supplied by the consumer; referenced by the trigger and close target.‬
size"sm" | "md" | "lg" | "full""md"‮Width step (24, 28, 40rem). full fills the viewport. Written to data-size; override --uiify-modal-inline-size for a custom width.‬
align"center" | "start""center"‮Vertical position on tablet and desktop. Phones get a bottom sheet unless size is full.‬
dismiss"any" | "closerequest" | "none""any"‮closedby: any adds backdrop taps, closerequest allows only Escape, none only your controls. Safari lacks closedby, so Escape always closes; UIEnhance handles backdrop taps (any) and sheet swipe (not none).‬
backdrop"opaque" | "blur" | "none""opaque"‮Backdrop treatment. Written to data-backdrop.‬
childrenReactNode—‮Content of the dialog.‬

ModalClose

‮Prop‬‮Type‬‮Default‬‮Description‬
target*string—‮Id of the ModalContent this button closes.‬
childrenReactNode—‮Content of the close button.‬
asTAs & NativeButtonAs<TAs>"button"‮A component that forwards props and ref to a native button; other tags are rejected, because invoker commands need a real button.‬

ModalHeader

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮Pinned top row holding the title, description and close control.‬

ModalTitle

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮The dialog's accessible name; reference its id from aria-labelledby.‬

ModalDescription

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮Supporting text; reference its id from aria-describedby.‬

ModalBody

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮The scrolling region between header and footer.‬

ModalFooter

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮Actions row pinned under the body.‬

ModalGrabber

‮Prop‬‮Type‬‮Default‬‮Description‬
childrenReactNode—‮Decorative drag handle on the mobile sheet; the swipe starts here.‬

ModalClientContent (@gunkin/uiify/components/modal/client)

‮Prop‬‮Type‬‮Default‬‮Description‬
id*string—‮Unique id supplied by the consumer; referenced by the trigger and close.‬
openboolean—‮Controlled open state, authoritative after hydration.‬
defaultOpenboolean—‮Uncontrolled initial state, applied with showModal() after hydration; the server markup stays closed.‬
onOpenChange(open: boolean) => void—‮Fires once per native or programmatic change with the new state, including once at mount if the browser opened it before hydration.‬
onOpenChangeComplete(open: boolean) => void—‮Fires once per open or close transition after its CSS transitions finish, or immediately without any, including the defaultOpen transition after hydration; never for an unchanged dialog.‬
size"sm" | "md" | "lg" | "full""md"‮Width step (24, 28, 40rem). full fills the viewport. Written to data-size; override --uiify-modal-inline-size for a custom width.‬
align"center" | "start""center"‮Vertical position on tablet and desktop. Phones get a bottom sheet unless size is full.‬
dismiss"any" | "closerequest" | "none""any"‮closedby: any adds backdrop taps, closerequest allows only Escape, none only your controls. Safari lacks closedby, so Escape always closes; UIEnhance handles backdrop taps (any) and sheet swipe (not none).‬
backdrop"opaque" | "blur" | "none""opaque"‮Backdrop treatment. Written to data-backdrop.‬
childrenReactNode—‮Content of the dialog.‬

‮Keyboard‬

‮Key‬‮Action‬
Escape‮Close (only the topmost modal when nested)‬
Tab‮Cycle focus inside modal‬
Shift+Tab‮Reverse tab order‬

‮Accessibility‬

‮Native <dialog> opened by a command="show-modal" invoker before any JavaScript runs. The browser owns modality, focus containment, Escape and focus return; closing uses request-close so onCancel can veto. The header close icon is always present. On phones the modal is a bottom sheet; swiping it down and tapping the backdrop are shortcuts, never the only way out.‬

‮Styling hooks‬

data-uiify-modaldata-part="trigger|content|header|title|description|body|footer|grabber|close"data-size="sm|lg|full"data-align="start"data-backdrop="blur|none"--uiify-trigger-open (inside [data-uiify-modal][data-part="root"])--uiify-modal-inline-size (dialog width)