On this page
A modal interrupts the user with a focused overlay that requires them to act or dismiss before continuing.
- Overview
- Rules and guidance
- Properties
- Styles
- Accessibility
When to use
- To ask the user to confirm or cancel a consequential action, such as deleting a record
- To collect a small amount of information before the user can continue, where navigating to a new page would break context
- To notify the user of something urgent that requires an immediate response
When not to use
- For complex or multi-page forms - navigate to a dedicated page instead.
- For non-urgent information - use a Notification instead. Modals block the page and should only be used when the interruption is justified.
- Nested inside another modal - a modal must not open another modal. If the user journey requires it, redesign the flow.
Anatomy
- Overlay (the semi-transparent backdrop that obscures and blocks interaction with the page behind)
- Dialog (the card container holding all modal content)
- Title (a required heading that names the action or topic)
- Content (the body of the modal; can contain any content including forms and notifications)
- Dismiss button (an icon button that closes the modal; present when isDismissable is true)
Keyboard support
Keyboard functions
- Tab: moves focus forward through interactive elements within the modal; focus is trapped inside the dialog
- Shift + Tab: moves focus backward through interactive elements within the modal
- Escape: closes the modal (when isDismissable is true)
Basic usage
The isOpen prop controls visibility. The title prop is required. Provide an onClose handler to manage state.
Edit live code
() => { // ignore - for live code example only. const Example = () => { const [isOpen, setIsOpen] = useState(false); return ( <> <PrimaryCTAButton onClick={() => setIsOpen(true)}>Open modal</PrimaryCTAButton> <Modal title="Offshore Wind" isOpen={isOpen} onClose={() => setIsOpen(false)} > <P> Offshore wind power or offshore wind energy is the use of wind farms constructed in bodies of water, usually in the ocean, to harvest wind energy to generate electricity. </P> </Modal> </> ); } return <Example />; }
Variants and features
Non-dismissable
Set isDismissable={false} to remove the dismiss button and prevent closing by clicking the overlay or pressing Escape. Use this only when the user must make an explicit choice to continue, for example in a critical confirmation step where accidental dismissal could cause problems.
Always provide an explicit way to close the modal when using this variant, such as a Cancel button.
Edit live code
() => { // ignore - for live code example only. const Example = () => { const [isOpen, setIsOpen] = useState(false); return ( <> <PrimaryCTAButton onClick={() => setIsOpen(true)}>Open non-dismissable modal</PrimaryCTAButton> <Modal title="Offshore Wind" isOpen={isOpen} onClose={() => setIsOpen(false)} isDismissable={false} > <P> Offshore wind is a great addition to the renewable energy mix. Please confirm to continue. </P> <PrimaryCTAButton onClick={() => setIsOpen(false)}> I agree </PrimaryCTAButton> </Modal> </> ); } return <Example />; }
Custom heading level
By default, the modal title renders as an h2. Use the headingComponent prop to override this when the semantic heading level needs to change to fit the page structure.
Edit live code
() => { // ignore - for live code example only. const Example = () => { const [isOpen, setIsOpen] = useState(false); return ( <> <PrimaryCTAButton onClick={() => setIsOpen(true)}>Open h4 modal</PrimaryCTAButton> <Modal title="Offshore Wind" isOpen={isOpen} onClose={() => setIsOpen(false)} headingComponent={Heading4} > <P> Offshore wind power or offshore wind energy is the use of wind farms constructed in bodies of water, usually in the ocean, to harvest wind energy to generate electricity. </P> </Modal> </> ); } return <Example />; }
Auto focus
By default, focus moves to the dialog container when the modal opens. Set autoFocus to move focus to the first focusable element inside the modal instead. Use this when the modal opens directly into a form and the user should begin typing immediately.
Do not use autoFocus when important content or instructions appear before the first input, as the user will miss them.
Edit live code
() => { // ignore - for live code example only. const Example = () => { const [isOpen, setIsOpen] = useState(false); return ( <> <PrimaryCTAButton onClick={() => setIsOpen(true)}>Open autofocus modal</PrimaryCTAButton> <Modal title="Add a meter reading" isOpen={isOpen} onClose={() => setIsOpen(false)} autoFocus > <FormFields> <NumberField id="modal-reading" label="Meter reading" characters={10} /> </FormFields> </Modal> </> ); } return <Example />; }
Scrollable content
The modal does not scroll internally. If the content is taller than the viewport, the page scrolls to reveal the full modal. Keep modal content concise to avoid this. If content regularly overflows the viewport, it is a signal the content should move to a dedicated page.
usage
- Modal must not open another ModalNested modals disorient users and create inaccessible focus management.Copy rule ID for modal.no-nesting
- Modal should not be used unless the interruption is clearly justified by the urgency or consequences of the actionModals block the page and demand immediate attention. Overuse leads to users dismissing them without reading.Copy rule ID for modal.requires-justification
- Modal should not contain complex or multi-step formsModals are for focused, temporary tasks. Complex content belongs on a dedicated page where the user has full context.Copy rule ID for modal.no-complex-content
content
- Modal must always have a title propThe title is the accessible name of the dialog. Without it, screen reader users cannot understand the purpose of the overlay.Copy rule ID for modal.title-required
- Modal title must describe the action or purpose of the modal, not the system stateThe title orients the user immediately. "Confirm payment" is more useful than "Payment".Copy rule ID for modal.title-describes-action
- Modal title should not end with a full stopException: the title is a question, in which case a question mark is acceptableCopy rule ID for modal.title-no-terminal-punctuation
- Modal title should use a verb-noun format for task-focused modals'Delete record' or 'Edit contact' sets clearer expectations than a noun alone.Copy rule ID for modal.title-verb-noun
interaction
- Modal will trap keyboard focus within the dialog while open. Tab and Shift+Tab cycle through focusable elements inside the modal onlyWCAG 2.2 SC 2.4.3. Prevents users from reaching obscured background content by keyboard.Exception: focus needs to be placed on a higher-priority overlay, such as the contact panelCopy rule ID for modal.focus-trap
- Modal will move focus into the dialog container when it opensWCAG 2.2 SC 2.4.3. Keyboard and screen reader users are placed in the modal context immediately.Copy rule ID for modal.focus-on-open
- Modal will return focus to the element that triggered it when it closesWCAG 2.2 SC 2.4.3. Keyboard users are returned to their position in the page after the modal closes.Copy rule ID for modal.focus-restore-on-close
- Modal overlay will mark background content as aria-hidden and prevent pointer interaction while the modal is openBackground content is hidden from both assistive technologies and pointer users. Requires OverlayProvider. See modal.requires-overlay-provider.Copy rule ID for modal.background-inert
- Modal will prevent the page behind from scrolling while openCopy rule ID for modal.prevent-scroll
- If isDismissable is trueModal will close when the user presses the Escape keyWCAG 2.2 SC 2.1.1. Escape is the standard keyboard interaction for dismissing overlays.Copy rule ID for modal.escape-closes
- If isDismissable is trueModal will close when the user clicks the overlay backdropCopy rule ID for modal.overlay-click-closes
- Modal should not be made dismissible when it contains user input that would be lost on closeisDismissable controls both overlay click and Escape key together. There is no way to allow Escape while blocking overlay click. Set isDismissable=false for transactional modals and provide an explicit cancel action instead.Copy rule ID for modal.no-dismissible-with-user-input
- Modal will not render to the DOM when isOpen is falseUnlike Notification, Modal is fully unmounted when closed. Any state inside the modal is lost on close unless managed externally.Copy rule ID for modal.not-in-dom-when-closed
- If isDismissable is falseModal must provide an explicit in-content action to close when isDismissable is falseWhen isDismissable is false, Escape and overlay click are disabled. Without a close action such as a Cancel button, there is no exit.Copy rule ID for modal.non-dismissable-requires-close
- Modal autoFocus prop should not be set to true when the modal contains instructions or information that precede the first interactive elementautoFocus skips to the first input, so any content before it will not be read by screen readers on open. Only use it when the modal opens directly into a form with no preamble.Copy rule ID for modal.autofocus-form-only
composition
- Modal must only be used inside a page wrapped with OverlayProvider from React AriaOverlayProvider enables aria-hidden on background content. Without it, screen reader users can reach obscured content, which is a WCAG 2.2 failure.Copy rule ID for modal.requires-overlay-provider
- Modal headingComponent prop must produce a heading element at the semantically appropriate level for the pageThe modal title should fit the document outline. Defaulting to h2 is correct for most full-page layouts; change only when the structure requires it.Copy rule ID for modal.heading-component-semantic
Modal
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
isDismissable | boolean | true | No | Whether to close the overlay when the user interacts outside it. |
title | ReactNode | — | Yes | Heading shown at the top of the modal. It also labels the dialog for assistive technology. |
headingComponent | string | ComponentType<any> | Heading3 | No | Element or component used to render the title. |
autoFocus | boolean | — | No | Moves focus into the modal when it opens. |
containFocus | boolean | true | No | Keeps keyboard focus inside the modal while it is open. |
isOpen | boolean | — | No | Whether the modal is shown. When false, nothing is rendered. |
onClose | () => void | — | No | Called when the user asks to close the modal: pressing Escape, interacting outside it (if `isDismissable`) or using the dismiss button. The consumer must set `isOpen` to false in response. |
Styles
overlay
position: fixed;
z-index: 100;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
overflow-y: auto;
modal
position: relative;
max-width: 100%;
width: ${breakpoints.medium};
min-height: calc(100vh - ${space[12]});
margin: 0 auto;
margin-top: ${space[12]};
${mediaQueries.smallAndUp} {
min-height: 0;
}
&:focus-visible {
outline: none;
}
modal-wrapper
${mediaQueries.smallAndUp} {
padding-left: ${space[8]};
padding-right: ${space[8]};
}
heading
padding-right: ${space[6]};
dismiss-button
background: none;
border: none;
padding: 0;
width: ${space[10]};
height: ${space[10]};
position: absolute;
color: ${colors.primary};
top: ${distance[0]};
right: ${distance[0]};
${mediaQueries.smallAndUp} {
top: ${distance[1]};
right: ${distance[1]};
}
&:focus-visible {
${focusRing}
}
&:hover {
color: ${colors.primaryAlt};
}
In brief
Modal is a dialog named by its title. A screen reader announces the dialog and its title when it opens, and the page behind it is hidden while it is open.
What Nebula provides
- The modal has the role dialog and is named by its title. Focus moves into it when it opens, and returns to where it was when it closes.
- Focus stays inside while the modal is open, unless you set containFocus to false. The page behind cannot scroll.
- Pressing Esc calls onClose, as does the dismiss button and, when isDismissable is on, interacting outside the modal.
- The dismiss button is a native button named "Dismiss", with a visible focus ring.
Keyboard behaviour
Focus stays inside the modal while it is open, and the dismiss button is a native button reached with Tab. Esc calls onClose.
Focus order
- The controls in the modal content, in the order they are written
- The dismiss button, when the modal is dismissable
Interactions
- Tab - Moves focus to the next control in the modal
Accessible design
Write a title that says what the modal is for
The title is the name of the dialog, announced when it opens. Write "Confirm cancellation" and not "Are you sure?".
WCAG: 2.4.6 Headings and Labels
If the modal is not dismissable, give it a visible way to finish
A non-dismissable modal has no dismiss button, so the only visible way out is a control you add, such as "I accept". Esc still calls onClose.
WCAG: 2.1.2 No Keyboard Trap
Accessible development
Always close the modal in onClose
The modal renders nothing when isOpen is false and never closes itself. If onClose does not set isOpen to false, Esc and the dismiss button do nothing and the user is stuck.
WCAG: 2.1.2 No Keyboard Trap
Always wrap the app in OverlayProvider
The provider is what hides the page behind the modal from screen readers. Without it a screen reader user can still read the page the modal sits on.
If the modal holds a form, set autoFocus
Focus moves to the dialog itself on open, and only moves to the first input when autoFocus is set. It is off by default, so set it where the first input is the next thing the user does.
WCAG: 2.4.3 Focus Order
Avoid setting containFocus to false
With it off, Tab leaves the modal for the page behind. Use it only for a third-party embed that must stay interactive.
WCAG: 2.4.3 Focus Order
Accessibility testing status
Reviewed on 9 October 2026 against v17.0.1.
| Test | Status | Tested with |
|---|---|---|
| Default state | Partially tested | axe-core via the Storybook test runner (CI) |
| Advanced states | Partially tested | axe-core via the Storybook test runner (CI) |
| Keyboard navigation | Partially tested | keyboard (reported by the owner) |
| Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) |
What these tests and statuses mean
The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used.
The tests
- Default state: The component as it first appears, before anything is clicked or changed.
- Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering.
- Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring.
- Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state.
The statuses
- Tested: Every check in this row passed in an automated run that repeats on every pull request.
- Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change.
- Partially tested: Some checks in this row were run once and passed; the rest are still to do.
- Issue found: A check in this row failed. The ticket is shown beside the status.
- Not tested: No check in this row has been run yet.
- Not applicable: This row does not apply to the component, for the reason given beside it.