Modal
A dialog box over a dimmed page, opened and closed by your own state, with Header, Body and Footer parts.
import { Modal, ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from '@salla.sa/twilight-theme-engine/components/modal';In plain words
A modal is a box that opens on top of the page and dims everything behind it, for a question like "Remove this address?" or a short form.
The modal does not remember whether it is open. Your component keeps that in state (a value React remembers between renders, created with useState) and passes it in as the isOpen prop (props are the values you give a component, like attributes on an HTML tag). It also passes onClose: a function the modal calls when the shopper clicks the dark background or presses Escape. Your function sets the state back to false, and the modal disappears.
Modal.Header, Modal.Body and Modal.Footer are ready-styled sections to put inside it.
Signature
function Modal(props: ModalProps): React.ReactPortal | null
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
closeOnBackdropClick?: boolean; // default true (also: Enter/Space inside closes it)
closeOnEscape?: boolean; // default true
preventBodyScroll?: boolean; // default true: adds class menu-opened to <body>
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; // default 'md'
position?: 'center' | 'top'; // default 'center'
className?: string; // added to the white panel
}
Modal.Header(props: ModalHeaderProps) // <h2> title + close button
Modal.Body(props: ModalBodyProps)
Modal.Footer(props: ModalFooterProps) // buttons aligned to the end
interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
showCloseButton?: boolean; // default true, but the button needs onClose
onClose?: () => void;
}
interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }
interface ModalFooterProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }Try it live
import { useCallback, useState } from 'react';
import { Modal } from '@salla.sa/twilight-theme-engine/components/modal';
export function StockAlert() {
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => setIsOpen(false), []);
return (
<>
<button type="button" className="btn btn--primary" onClick={() => setIsOpen(true)}>
Notify me when available
</button>
<Modal isOpen={isOpen} onClose={close}>
{/* Enter or Space inside the modal would close it: keep them for the content. */}
<div
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
}}
>
<Modal.Header onClose={close}>Back-in-stock alert</Modal.Header>
<Modal.Body>…</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn--outline-primary" onClick={close}>
Cancel
</button>
</Modal.Footer>
</div>
</Modal>
</>
);
}
Example
import { useCallback, useState, type KeyboardEvent } from 'react';
import { Modal } from '@salla.sa/twilight-theme-engine/components/modal';
/** Modal closes on Enter or Space pressed anywhere inside it; keep those keys for the buttons. */
function keepEnterAndSpace(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
}
export function RemoveAddressButton({ onRemove }: { onRemove: () => void }) {
const [isOpen, setIsOpen] = useState(false);
// Stable: Modal re-runs its focus effect whenever onClose is a new function.
const close = useCallback(() => setIsOpen(false), []);
return (
<>
<button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(true)}>
Remove address
</button>
<Modal isOpen={isOpen} onClose={close} size="sm">
<div onKeyDown={keepEnterAndSpace}>
<Modal.Header onClose={close}>Remove this address?</Modal.Header>
<Modal.Body>You can add it again at any time.</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn--outline-primary" onClick={close}>
Cancel
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => {
onRemove();
close();
}}
>
Remove
</button>
</Modal.Footer>
</div>
</Modal>
</>
);
}
How it behaves
Fully controlled: there is no internal open state and no trigger part. It renders nothing on the server (it checks
typeof document) and nothing while closed; open, it portals a full-screen wrapper intodocument.body.While open it remembers the focused element and focuses the panel, adds
menu-openedto<body>(withpreventBodyScroll) and listens for Escape ondocument. Closing undoes all three and puts focus back. There is no focus trap: Tab can reach the page behind.A backdrop click closes it only when the click lands on the wrapper itself, so clicks inside the panel never do.
Sizes are Tailwind max widths on a
w-fullpanel:max-w-sm(24rem) tomax-w-xl(36rem), andfullismax-w-full mx-4.position="top"isitems-start pt-10. The wrapper isfixed inset-0 z-50.role="dialog"andaria-modalare on the full-screen wrapper and nothing setsaria-labelledby.Modal.Headerrenders its children inside an<h2>, so give it text or inline elements.There is no open or close animation: closed returns
null, so the scale and opacity classes are only ever in their open state.No
TwilightProvideris needed. The engine uses it throughImageModal(product gallery zoom, the footer tax certificate). The same export is also at@salla.sa/twilight-theme-engine/modal.
Gotchas
Enter or Space pressed anywhere inside the modal closes it and swallows the key: a space is never typed into an input, and Enter on a focused footer button closes the modal without running the button. The wrapper's
onKeyDown(Modal.tsx) receives keys bubbling up from the content and checks onlycloseOnBackdropClick. Fix: wrap the content in a<div>whoseonKeyDowncallsevent.stopPropagation()for Enter and Space (as in the example), or passcloseOnBackdropClick={false}.An inline
onClose={() => setIsOpen(false)}is a new function on every render, and the focus effect depends on it. Each re-render of the owner re-runs the effect, which moves focus back to the panel: typing in a controlled input inside the modal loses focus after every keystroke. Fix:const close = useCallback(() => setIsOpen(false), []).The same re-run happens while the modal is closed: every re-render of its owner removes
menu-openedfrom<body>(unlocking scroll that an open Drawer set) and moves focus back to whatever opened the modal last. The sameuseCallbackfixes it.Scroll locking is only the class name. The engine ships no CSS for it; the reference theme has
body.menu-opened { overflow: hidden }inapp/styles/app.css. Without that rule the page behind still scrolls.Content taller than the screen is cut off and cannot be scrolled: neither the wrapper nor the panel sets a maximum height or overflow. Pass
className="max-h-[90vh] overflow-y-auto".The header close button appears only when
Modal.Headeritself getsonClose. The parts share nothing withModal, soshowCloseButtonalone shows nothing.A Modal opened from inside an open Drawer appears behind it: the Modal wrapper is
z-50, the Drawer wrapperz-[200], and neither can be changed by a prop. Close the drawer before opening the modal.
Related
A full-height panel on the side where the page starts reading (right in Arabic), opened by your state, for menus and filters.
ImageModalA ready-made Modal showing one image at full size with a loading placeholder; lazily loaded, so it needs a Suspense boundary.
toastShows a short notification (success, error, warning, info, loading, or a promise's progress) that closes on its own.