Skip to content
Twilight React Playground
ثيم رائدaren

Modal

componentBeginnerserverbrowserlive demo

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

A Modal with a form inside. The page behind it is this playground: it dims and stops scrolling.Try this: turn "Keep Enter and Space" off, open the modal and type a space in the field: the modal closes and the space is never typed.
Storefront canvas · ar · RTL
onClose called 0 times
Controls
closeOnBackdropClickAlso decides whether Enter and Space inside the modal close it.
closeOnEscape
preventBodyScroll
Header close button
Keep Enter and SpaceStops those keys at the content so the modal does not close on them.
What a theme writes
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

app/components/account/RemoveAddressButton.tsx
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 into document.body.

  • While open it remembers the focused element and focuses the panel, adds menu-opened to <body> (with preventBodyScroll) and listens for Escape on document. 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-full panel: max-w-sm (24rem) to max-w-xl (36rem), and full is max-w-full mx-4. position="top" is items-start pt-10. The wrapper is fixed inset-0 z-50.

  • role="dialog" and aria-modal are on the full-screen wrapper and nothing sets aria-labelledby. Modal.Header renders 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 TwilightProvider is needed. The engine uses it through ImageModal (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 only closeOnBackdropClick. Fix: wrap the content in a <div> whose onKeyDown calls event.stopPropagation() for Enter and Space (as in the example), or pass closeOnBackdropClick={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-opened from <body> (unlocking scroll that an open Drawer set) and moves focus back to whatever opened the modal last. The same useCallback fixes it.

  • Scroll locking is only the class name. The engine ships no CSS for it; the reference theme has body.menu-opened { overflow: hidden } in app/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.Header itself gets onClose. The parts share nothing with Modal, so showCloseButton alone shows nothing.

  • A Modal opened from inside an open Drawer appears behind it: the Modal wrapper is z-50, the Drawer wrapper z-[200], and neither can be changed by a prop. Close the drawer before opening the modal.

Related

Source and docs