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>| Part | Purpose |
|---|---|
Modal.Root | Open state, focus trap, dismissal, and the unsaved-changes guard |
Modal.Backdrop | Dimmed backdrop behind the panel |
Modal.Panel | The dialog surface; hosts the unsaved-changes prompt |
Modal.Header, Modal.Title, Modal.SubTitle, Modal.Icon | Header layout. Title and SubTitle also label the dialog for assistive technology |
Modal.Body | Scrollable content area |
Modal.Footer | Right-aligned action row |
Modal.CloseButton | Header close button. Closes through the guard |
Modal.CancelButton | Footer Cancel. Closes through the guard |
Modal.UnsavedChangesOverlay | The 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.UnsavedChangesOverlayanywhere insideModal.Panel. The prompt covers the whole panel, header and footer included.Modal.CancelButtonin 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 path | Guarded |
|---|---|
Backdrop click and Escape (when closeOnOutsideClick is on) | Yes |
Modal.CloseButton, including one given its own onClose | Yes |
Modal.CancelButton, and the built-in Cancel of Modal | Yes |
Modal.useContext().onClose | Yes |
| Guide and product-tour dismissals | Yes |
handleSecondaryAction | No — runs as given |
A plain Button that calls your close handler | No |
The parent setting isOpen to false | No |
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.
| Prop | Type | Default |
|---|---|---|
title | ReactNode | 'Discard unsaved changes?' |
description | ReactNode | "Your changes haven't been saved. If you close now, they'll be lost." |
discardLabel | ReactNode | 'Discard changes' |
keepEditingLabel | ReactNode | 'Keep editing' |
className | string | — |
className applies to the prompt card.
Modal.CancelButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
onClose | () => void | the root's onClose | A close to run instead of the root's. Still guarded |
children | ReactNode | 'Cancel' | Button label |
variant | ButtonVariant | 'secondary' | Button variant |
size | ButtonSize | — | Button size |
disabled | boolean | — | Disables the button |
className | string | — | 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:
- page
PanelModal
That means:
- a
Modalmay open on top of aPanel - a
Panelmust never open on top of aModal - a
Modalmust never open on top of anotherModal
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
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | — | Required. Controls visibility |
title | ReactNode | — | Required. Modal heading |
children | ReactNode | — | Required. Modal body content |
handleClose | () => void | — | Called when modal should close |
hasUnsavedChanges | boolean | false | Asks before any user close discards the form. See Unsaved Changes Guard |
unsavedChangesOverlay | ModalUnsavedChangesOverlayProps | — | Copy overrides for the discard prompt |
action | ModalAction | — | Primary action button config |
handleSecondaryAction | () => void | — | Replaces the footer Cancel's close. Runs as given, not through the guard — use it for Back only |
subTitle | ReactNode | — | Subtitle below the title |
Icon | ComponentType | — | Icon in the header |
width | string | number | — | Custom width |
maxWidth | string | number | — | Maximum width |
height | string | number | — | Custom height |
maxHeight | string | number | — | Maximum height |
fullscreen | boolean | false | Expands to fill the viewport |
closeOnOutsideClick | boolean | true | Lets a backdrop click or Escape close the modal (through the guard when hasUnsavedChanges is set) |
withPadding | boolean | true | Applies body padding |
hideHeader | boolean | false | Hides the header section |
secondaryLabel | string | 'Cancel' | Secondary button label |
footer | ReactNode | — | Custom footer content |
zIndex | number | 80 | CSS z-index |
ModalAction
| Prop | Type | Description |
|---|---|---|
type | 'button' | 'submit' | Required. Action type |
label | string | Button label |
onClick | () => void | Click handler (button type) |
formId | string | Form ID to submit (submit type) |
variant | ButtonVariant | Button variant |
isLoading | boolean | Shows loading spinner |
disabled | boolean | Disables the action |
icon | IconName | Action button icon |
Modal.Root
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | — | 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 |
closeOnOutsideClick | boolean | true | Lets a backdrop click or Escape close the modal |
hasUnsavedChanges | boolean | false | Arms the unsaved-changes guard |
zIndex | number | 80 | CSS z-index |
Guidelines
- Do always provide a clear title that describes the task
- Do use
closeOnOutsideClickfor non-destructive modals - Do pass
hasUnsavedChangeson every form modal so an accidental close asks before discarding input - Do use
Modal.CancelButtonfor Cancel in composed footers, and renderModal.UnsavedChangesOverlayinsideModal.Panel - Do treat
Modalas the top overlay layer abovePanel - Don't pass a close handler as
handleSecondaryAction— it skips the guard - Don't turn off
closeOnOutsideClickto protect form input — it removes Escape and the backdrop as exits but leaves the close button and Cancel unprotected. UsehasUnsavedChanges - Don't confirm a discard with
ConfirmActionModalor any second modal — the guard's prompt renders inside the current one - Don't nest modals inside modals
- Don't open a
Panelon top of aModal - Don't use modals for simple confirmations — use
ConfirmActionModaland follow theConfirmation Modalspattern instead - Don't use fullscreen mode on desktop unless the content genuinely requires it