@mshafiqyajid/react-modal
Headless modal hook and styled dialog/drawer for React. Focus trap, scroll lock, Escape to close, four drawer positions, four sizes, smooth enter/exit animations, confirm variant, mobile sheet mode, swipe-to-dismiss, modal stacking with depth-aware z-index, async preventClose guard, ARIA-compliant.
Playground #
import { ModalStyled } from "@mshafiqyajid/react-modal/styled";
import "@mshafiqyajid/react-modal/styles.css";
<ModalStyled
isOpen={open}
onClose={() => setOpen(false)}
title="Example modal"
/>Install #
npm install @mshafiqyajid/react-modal Quick start #
import { ModalStyled } from "@mshafiqyajid/react-modal/styled";
import "@mshafiqyajid/react-modal/styles.css";
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Open</button>
<ModalStyled
open={open}
onClose={() => setOpen(false)}
title="Confirm action"
footer={
<>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={() => setOpen(false)}>Confirm</button>
</>
}
>
<p>Are you sure you want to proceed?</p>
</ModalStyled> Drawer variants #
<ModalStyled variant="drawer-right" title="Settings" open={open} onClose={close}>
<p>Drawer content</p>
</ModalStyled>
<ModalStyled variant="drawer-bottom" title="Options" open={open} onClose={close}>
<p>Bottom sheet (swipe-to-dismiss enabled automatically)</p>
</ModalStyled> Confirm variant #
Set confirmVariant="confirm" for a pre-wired confirm/cancel footer. No need to pass footer.
<ModalStyled
open={open}
onClose={close}
title="Delete item"
confirmVariant="confirm"
confirmLabel="Delete"
confirmTone="danger"
onConfirm={() => { deleteItem(); close(); }}
onCancel={close}
>
<p>This action cannot be undone.</p>
</ModalStyled> Mobile sheet #
Set mobileVariant="sheet" to render as a bottom sheet on viewports β€ 640 px. Drag-to-dismiss is enabled automatically. Desktop layout is unchanged.
<ModalStyled open={open} onClose={close} title="Options" mobileVariant="sheet">
<p>Drag down to dismiss on mobile.</p>
</ModalStyled> Transition #
Choose the panel entrance animation. Only applies to variant="dialog"; drawers always slide. Default: "fade".
<ModalStyled open={open} onClose={close} transition="zoom">...</ModalStyled>
<ModalStyled open={open} onClose={close} transition="slide-up">...</ModalStyled> Async preventClose guard #
preventClose receives the close reason and can return/resolve true to block the close.
<ModalStyled
open={open}
onClose={close}
preventClose={async (reason) => {
if (reason === "overlay") return true; // block overlay click
const dirty = await checkUnsavedChanges();
return dirty; // true = cancel the close
}}
>
<p>Form with unsaved changes</p>
</ModalStyled> Headless #
import { useModal } from "@mshafiqyajid/react-modal";
const { isOpen, open, close, modalProps, overlayProps } = useModal();
<button onClick={open}>Open</button>
{isOpen && (
<div {...overlayProps}>
<div {...modalProps} role="dialog" aria-modal="true">
<button onClick={close}>Close</button>
Content
</div>
</div>
)} API #
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | β | Controls visibility. Alias: isOpen (both accepted) |
| onClose | () => void | β | Called when modal requests close |
| title | ReactNode | β | Header title β omit to hide header entirely |
| description | ReactNode | β | Accessible description rendered below the title; linked via aria-describedby |
| footer | ReactNode | β | Footer content β omit to hide footer. Ignored when confirmVariant="confirm" |
| size | "sm" | "md" | "lg" | "full" | "md" | Dialog width / drawer width |
| variant | "dialog" | "drawer-left" | "drawer-right" | "drawer-bottom" | "dialog" | Layout variant |
| mobileVariant | "default" | "sheet" | "default" | On viewports β€ 640 px, render as a bottom sheet with drag-to-dismiss |
| transition | "fade" | "zoom" | "slide-up" | "slide-down" | "fade" | Panel entrance animation (dialog variant only; drawers always slide) |
| blur | "none" | "sm" | "md" | "lg" | "md" | Backdrop blur intensity |
| padding | "none" | "sm" | "md" | "lg" | "md" | Body padding |
| scrollable | boolean | true | Allow body to scroll when content overflows |
| overlayColor | string | β | Custom backdrop color e.g. "rgba(0,0,0,0.6)" |
| closeOnOverlayClick | boolean | true | Close when clicking the backdrop |
| closeOnEsc | boolean | true | Close on Escape key |
| showCloseButton | boolean | true | Show the Γ button in the header |
| lockBodyScroll | boolean | true | Lock body scroll while open |
| swipeToDismiss | boolean | falseβ | Allow swipe-down-to-dismiss on touch devices. Defaults to true for drawer-bottom |
| closeOnSubmit | boolean | false | Auto-close when a contained <form> submits (and default is not prevented) |
| preventClose | (reason: CloseReason) => boolean | Promise<boolean> | β | Return/resolve true to cancel the close |
| onAfterOpen | () => void | β | Fires after the open transition completes |
| onAfterClose | () => void | β | Fires after the close transition completes |
| initialFocusRef | RefObject<HTMLElement> | β | Element to focus when modal opens |
| finalFocusRef | RefObject<HTMLElement> | β | Element to focus when modal closes |
| container | HTMLElement | null | document.body | Override the portal container |
| confirmVariant | "default" | "confirm" | "default" | Set to "confirm" to render a pre-wired confirm/cancel footer |
| confirmLabel | ReactNode | "Confirm" | Confirm button label |
| cancelLabel | ReactNode | "Cancel" | Cancel button label |
| confirmTone | "neutral" | "danger" | "neutral" | Confirm button tone |
| onConfirm | () => void | β | Called when confirm button is clicked |
| onCancel | () => void | β | Called when cancel button is clicked |
| style | CSSProperties | β | Inline style override on the panel |
CloseReason values: "esc" | "overlay" | "close-button" | "programmatic" | "swipe" | "submit".