FSAI Design System
Components

Modal

Dialog overlay for focused tasks, confirmations, and forms.

Overview

The Modal component provides an overlay dialog with header, body, and footer sections. It supports custom sizing, fullscreen mode, actions (submit/button), and compound composition via primitives.

Basic Usage

import { Modal } from '@fsai/shared-ui';

<Modal
  isOpen={isOpen}
  handleClose={() => setIsOpen(false)}
  title="Edit Profile"
  action={{
    type: 'button',
    label: 'Save',
    onClick: handleSave,
  }}
>
  <p>Modal body content goes here.</p>
</Modal>

With Form Submit

Tie the action to a form using type: 'submit':

<Modal
  isOpen={isOpen}
  handleClose={() => setIsOpen(false)}
  title="Create Item"
  action={{
    type: 'submit',
    formId: 'create-item-form',
    label: 'Create',
  }}
>
  <form id="create-item-form" onSubmit={handleSubmit}>
    <Input label="Name" name="name" />
  </form>
</Modal>

Every form modal should also pass hasUnsavedChanges — see Unsaved Changes Guard.

Loading Action

<Modal
  isOpen={isOpen}
  handleClose={() => setIsOpen(false)}
  title="Saving..."
  action={{
    type: 'button',
    label: 'Save',
    onClick: handleSave,
    isLoading: isSaving,
  }}
>
  {children}
</Modal>

Compound Components (Primitives)

For advanced layouts, use the modal primitives directly:

import { Modal } from '@fsai/shared-ui';

<Modal.Root isOpen={isOpen} onClose={() => setIsOpen(false)}>
  <Modal.Backdrop />
  <Modal.Panel>
    <Modal.Header>
      <Modal.Title>Custom Layout</Modal.Title>
      <Modal.CloseButton />
    </Modal.Header>
    <Modal.Body>Content</Modal.Body>
    <Modal.Footer>
      <Modal.CancelButton />
      <Button role="button" variant="primary" onClick={handleSave}>Save</Button>
    </Modal.Footer>
  </Modal.Panel>
</Modal.Root>
PartPurpose
Modal.RootOpen state, focus trap, dismissal, and the unsaved-changes guard
Modal.BackdropDimmed backdrop behind the panel
Modal.PanelThe dialog surface; hosts the unsaved-changes prompt
Modal.Header, Modal.Title, Modal.SubTitle, Modal.IconHeader layout. Title and SubTitle also label the dialog for assistive technology
Modal.BodyScrollable content area
Modal.FooterRight-aligned action row
Modal.CloseButtonHeader close button. Closes through the guard
Modal.CancelButtonFooter Cancel. Closes through the guard
Modal.UnsavedChangesOverlayThe discard prompt. Required inside Modal.Panel when a composed modal uses the guard
Modal.useUnsavedChanges(isDirty)Declares unsaved changes from a component rendered inside the modal
Modal.useContext()Returns { onClose }, the guarded close

Unsaved Changes Guard

Forms in modals guard against accidental closes. Pass hasUnsavedChanges — usually react-hook-form's isDirty — and while it is true, every way of closing the modal (a backdrop click, Escape, the close button, and the footer's Cancel) shows an inline Discard unsaved changes? prompt over the panel instead of closing. A clean form closes immediately, exactly as it would without the guard.

const {
  register,
  handleSubmit,
  formState: { errors, isDirty },
} = useForm<ContactFormValues>();

<Modal
  isOpen={isOpen}
  handleClose={handleClose}
  title="Add Contact"
  hasUnsavedChanges={isDirty}
  action={{ type: 'submit', formId: 'add-contact-form', label: 'Add' }}
>
  <form id="add-contact-form" onSubmit={handleSubmit(onSubmit)}>
    <Input
      label="Name*"
      {...register('name', { ...formValidations.required })}
      error={errors.name?.message}
    />
  </form>
</Modal>

The prompt offers Keep editing and Discard changes. Discarding performs the close the user asked for; keeping returns them to the form where they left off.

What counts as unsaved, and how to keep a form clean between opens, is covered in the Forms pattern.

Custom Copy

The default copy fits most forms. Override it with unsavedChangesOverlay when naming the object makes the consequence clearer, or when part of the work is already saved and the prompt has to say what will be lost:

<Modal
  isOpen={isOpen}
  handleClose={handleClose}
  title="Create Tracking Link"
  hasUnsavedChanges={hasUnsavedChanges}
  unsavedChangesOverlay={{
    title: 'Discard this tracking link?',
    description:
      "The link hasn't been created yet. If you close now, the details you entered are lost.",
    discardLabel: 'Discard link',
  }}
  action={{ type: 'submit', formId: 'create-link-form', label: 'Create' }}
>
  {children}
</Modal>

Cancel, Back, And handleSecondaryAction

The footer's built-in Cancel closes through the guard. handleSecondaryAction doesn't: it replaces that close and runs exactly as given. Use it only for navigation, and leave it unset on any step where the secondary button means Cancel:

<Modal
  isOpen={isOpen}
  handleClose={handleClose}
  title="Create Location"
  hasUnsavedChanges={isDirty}
  secondaryLabel={step === 0 ? 'Cancel' : 'Back'}
  handleSecondaryAction={step === 0 ? undefined : handleBack}
  action={{ type: 'button', label: 'Continue', onClick: handleContinue }}
>
  {children}
</Modal>

Back only changes steps; it never closes the modal.

With Primitives

Modal.Root takes the same hasUnsavedChanges prop. A composed modal also renders the two guard parts itself:

  • Modal.UnsavedChangesOverlay anywhere inside Modal.Panel. The prompt covers the whole panel, header and footer included.
  • Modal.CancelButton in the footer, so Cancel shares the guard with the close button, Escape, and the backdrop.
<Modal.Root isOpen={isOpen} onClose={handleClose} hasUnsavedChanges={isDirty}>
  <Modal.Backdrop />
  <Modal.Panel>
    <Modal.Header>
      <Modal.Title>Edit Contact</Modal.Title>
      <Modal.CloseButton />
    </Modal.Header>
    <Modal.Body>{fields}</Modal.Body>
    <Modal.Footer>
      <Modal.CancelButton />
      <Button role="button" variant="primary" onClick={handleSave}>
        Save
      </Button>
    </Modal.Footer>
    <Modal.UnsavedChangesOverlay title="Discard changes to this contact?" />
  </Modal.Panel>
</Modal.Root>

If the guard is armed but no Modal.UnsavedChangesOverlay is rendered, the modal logs a warning and closes without asking, rather than leaving the user behind a prompt that can't appear. The high-level Modal always renders the overlay.

Forms Inside The Modal

When the form lives in a component rendered inside the modal — a step, or a form body split into its own component — the component that renders the modal can't see its dirty state. Declare it from inside with Modal.useUnsavedChanges:

const InviteStep = () => {
  const {
    register,
    formState: { isDirty },
  } = useForm<InviteValues>();
  Modal.useUnsavedChanges(isDirty);

  return <Input label="Email*" {...register('email')} />;
};

The hook works under both Modal and Modal.Root. Call it only in components rendered inside the modal: above the root there is no modal to register with, and it throws. The guard is armed while the root's hasUnsavedChanges, or any component that registered, reports changes.

Custom Close Controls

Close pathGuarded
Backdrop click and Escape (when closeOnOutsideClick is on)Yes
Modal.CloseButton, including one given its own onCloseYes
Modal.CancelButton, and the built-in Cancel of ModalYes
Modal.useContext().onCloseYes
Guide and product-tour dismissalsYes
handleSecondaryActionNo — runs as given
A plain Button that calls your close handlerNo
The parent setting isOpen to falseNo

For a custom control that closes on some steps, take the guarded close from context in a component rendered inside the modal:

const { onClose: requestClose } = Modal.useContext();

<Button
  role="button"
  variant="secondary"
  onClick={stepIndex === 0 ? requestClose : handleBack}
>
  {stepIndex === 0 ? 'Cancel' : 'Back'}
</Button>

The parent's own close is deliberately unguarded: that is how a modal closes itself after a successful save.

Prompt Behavior

  • The prompt renders inside the current modal, over the panel — never as a second modal — so it follows the Overlay Hierarchy Rule.
  • Keep editing has focus when the prompt opens, so pressing Enter never discards work. Discard changes uses the destructive button style.
  • The form under the prompt is inert: nothing in it can be clicked or tabbed to until the user chooses.
  • A second close gesture while the prompt is up — Escape again, another backdrop click — means Keep editing, never Discard.
  • Keep editing returns focus to where the user was.
  • If the form becomes clean while the prompt is showing, the prompt goes away.

Modal.UnsavedChangesOverlay Props

Modal's unsavedChangesOverlay prop takes the same shape.

PropTypeDefault
titleReactNode'Discard unsaved changes?'
descriptionReactNode"Your changes haven't been saved. If you close now, they'll be lost."
discardLabelReactNode'Discard changes'
keepEditingLabelReactNode'Keep editing'
classNamestring—

className applies to the prompt card.

Modal.CancelButton Props

PropTypeDefaultDescription
onClose() => voidthe root's onCloseA close to run instead of the root's. Still guarded
childrenReactNode'Cancel'Button label
variantButtonVariant'secondary'Button variant
sizeButtonSize—Button size
disabledboolean—Disables the button
classNamestring—Additional classes

Modal.CancelButton renders nothing when there's no close to run: no onClose on the root and none passed.

Overlay Hierarchy Rule

Modal is the highest interruption surface in the platform.

The allowed overlay order is:

  1. page
  2. Panel
  3. Modal

That means:

  • a Modal may open on top of a Panel
  • a Panel must never open on top of a Modal
  • a Modal must never open on top of another Modal

This rule applies to major overlay surfaces.

It does not forbid local floating UI inside a modal. Components such as Popover, Menu, Picker, Tooltip, and similar anchored primitives are expected to open within the current modal when they belong to a control inside that modal.

If a modal workflow needs another step, do not stack a second overlay above it.

Prefer one of these patterns instead:

  • expand the existing modal
  • swap the modal body to the next step
  • use an inline stay-in-context pattern inside the modal
  • close the modal and open a Panel
  • escalate to a page if the task is large enough to deserve its own workspace

If a feature seems to require Modal -> Panel or Modal -> Modal, that is usually a sign the first surface was the wrong abstraction.

Confirming a discard follows the same rule: the unsaved-changes prompt renders inside the current modal instead of opening a second one.

Props

PropTypeDefaultDescription
isOpenboolean—Required. Controls visibility
titleReactNode—Required. Modal heading
childrenReactNode—Required. Modal body content
handleClose() => void—Called when modal should close
hasUnsavedChangesbooleanfalseAsks before any user close discards the form. See Unsaved Changes Guard
unsavedChangesOverlayModalUnsavedChangesOverlayProps—Copy overrides for the discard prompt
actionModalAction—Primary action button config
handleSecondaryAction() => void—Replaces the footer Cancel's close. Runs as given, not through the guard — use it for Back only
subTitleReactNode—Subtitle below the title
IconComponentType—Icon in the header
widthstring | number—Custom width
maxWidthstring | number—Maximum width
heightstring | number—Custom height
maxHeightstring | number—Maximum height
fullscreenbooleanfalseExpands to fill the viewport
closeOnOutsideClickbooleantrueLets a backdrop click or Escape close the modal (through the guard when hasUnsavedChanges is set)
withPaddingbooleantrueApplies body padding
hideHeaderbooleanfalseHides the header section
secondaryLabelstring'Cancel'Secondary button label
footerReactNode—Custom footer content
zIndexnumber80CSS z-index

ModalAction

PropTypeDescription
type'button' | 'submit'Required. Action type
labelstringButton label
onClick() => voidClick handler (button type)
formIdstringForm ID to submit (submit type)
variantButtonVariantButton variant
isLoadingbooleanShows loading spinner
disabledbooleanDisables the action
iconIconNameAction button icon

Modal.Root

PropTypeDefaultDescription
isOpenboolean—Required. Controls visibility
onClose() => void—The close the guard runs once the user confirms. Without it, the user can't dismiss the modal and CloseButton / CancelButton render nothing
closeOnOutsideClickbooleantrueLets a backdrop click or Escape close the modal
hasUnsavedChangesbooleanfalseArms the unsaved-changes guard
zIndexnumber80CSS z-index

Guidelines

  • Do always provide a clear title that describes the task
  • Do use closeOnOutsideClick for non-destructive modals
  • Do pass hasUnsavedChanges on every form modal so an accidental close asks before discarding input
  • Do use Modal.CancelButton for Cancel in composed footers, and render Modal.UnsavedChangesOverlay inside Modal.Panel
  • Do treat Modal as the top overlay layer above Panel
  • Don't pass a close handler as handleSecondaryAction — it skips the guard
  • Don't turn off closeOnOutsideClick to protect form input — it removes Escape and the backdrop as exits but leaves the close button and Cancel unprotected. Use hasUnsavedChanges
  • Don't confirm a discard with ConfirmActionModal or any second modal — the guard's prompt renders inside the current one
  • Don't nest modals inside modals
  • Don't open a Panel on top of a Modal
  • Don't use modals for simple confirmations — use ConfirmActionModal and follow the Confirmation Modals pattern instead
  • Don't use fullscreen mode on desktop unless the content genuinely requires it

On this page