# Modal
A modal interrupts the user with a focused overlay that requires them to act or dismiss before continuing.
Section: components
Docs: https://nebula.kaluza.com/components/modal
---
## 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](/components/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
1. **Overlay** (the semi-transparent backdrop that obscures and blocks interaction with the page behind)
2. **Dialog** (the card container holding all modal content)
3. **Title** (a required heading that names the action or topic)
4. **Content** (the body of the modal; can contain any content including forms and notifications)
5. **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.
```tsx
const Example = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
setIsOpen(true)}>Open modal
setIsOpen(false)}
>
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.
>
);
}
```
## 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.
```tsx
const Example = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
setIsOpen(true)}>Open non-dismissable modal
setIsOpen(false)}
isDismissable={false}
>
Offshore wind is a great addition to the renewable energy mix. Please confirm to continue.
setIsOpen(false)}>
I agree
>
);
}
```
### 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.
```tsx
const Example = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
setIsOpen(true)}>Open h4 modal
setIsOpen(false)}
headingComponent={Heading4}
>
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.
>
);
}
```
### 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.
```tsx
const Example = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
setIsOpen(true)}>Open autofocus modal
setIsOpen(false)}
autoFocus
>
>
);
}
```
### 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.
## Rules and guidance
### Usage
- **Modal** MUST NOT open another Modal. Rationale: Nested modals disorient users and create inaccessible focus management. [id: modal.no-nesting; testability: auto]
- **Modal** SHOULD NOT be used unless the interruption is clearly justified by the urgency or consequences of the action. Rationale: Modals block the page and demand immediate attention. Overuse leads to users dismissing them without reading. [id: modal.requires-justification; testability: manual]
- **Modal** SHOULD NOT contain complex or multi-step forms. Rationale: Modals are for focused, temporary tasks. Complex content belongs on a dedicated page where the user has full context. [id: modal.no-complex-content; testability: manual]
### Content
- **Modal** MUST always have a title prop. Rationale: The title is the accessible name of the dialog. Without it, screen reader users cannot understand the purpose of the overlay. [id: modal.title-required; testability: auto]
- **Modal title** MUST describe the action or purpose of the modal, not the system state. Rationale: The title orients the user immediately. "Confirm payment" is more useful than "Payment". [id: modal.title-describes-action; testability: manual]
- **Modal title** SHOULD NOT end with a full stop. Exception: the title is a question, in which case a question mark is acceptable. [id: modal.title-no-terminal-punctuation; testability: auto]
- **Modal title** SHOULD use a verb-noun format for task-focused modals. Rationale: 'Delete record' or 'Edit contact' sets clearer expectations than a noun alone. [id: modal.title-verb-noun; testability: manual]
### Interaction
- **Modal** WILL trap keyboard focus within the dialog while open. Tab and Shift+Tab cycle through focusable elements inside the modal only. Rationale: WCAG 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 panel. [id: modal.focus-trap; testability: auto]
- **Modal** WILL move focus into the dialog container when it opens. Rationale: WCAG 2.2 SC 2.4.3. Keyboard and screen reader users are placed in the modal context immediately. [id: modal.focus-on-open; testability: auto]
- **Modal** WILL return focus to the element that triggered it when it closes. Rationale: WCAG 2.2 SC 2.4.3. Keyboard users are returned to their position in the page after the modal closes. [id: modal.focus-restore-on-close; testability: manual]
- **Modal overlay** WILL mark background content as aria-hidden and prevent pointer interaction while the modal is open. Rationale: Background content is hidden from both assistive technologies and pointer users. Requires OverlayProvider. See modal.requires-overlay-provider. [id: modal.background-inert; testability: manual]
- **Modal** WILL prevent the page behind from scrolling while open. [id: modal.prevent-scroll; testability: auto]
- If isDismissable is true: **Modal** WILL close when the user presses the Escape key. Rationale: WCAG 2.2 SC 2.1.1. Escape is the standard keyboard interaction for dismissing overlays. [id: modal.escape-closes; testability: auto]
- If isDismissable is true: **Modal** WILL close when the user clicks the overlay backdrop. [id: modal.overlay-click-closes; testability: auto]
- **Modal** SHOULD NOT be made dismissible when it contains user input that would be lost on close. Rationale: isDismissable 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. [id: modal.no-dismissible-with-user-input; testability: manual]
- **Modal** WILL not render to the DOM when isOpen is false. Rationale: Unlike Notification, Modal is fully unmounted when closed. Any state inside the modal is lost on close unless managed externally. [id: modal.not-in-dom-when-closed; testability: auto]
- If isDismissable is false: **Modal** MUST provide an explicit in-content action to close when isDismissable is false. Rationale: When isDismissable is false, Escape and overlay click are disabled. Without a close action such as a Cancel button, there is no exit. [id: modal.non-dismissable-requires-close; testability: manual]
- **Modal autoFocus prop** SHOULD NOT be set to true when the modal contains instructions or information that precede the first interactive element. Rationale: autoFocus 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. [id: modal.autofocus-form-only; testability: context]
### Composition
- **Modal** MUST only be used inside a page wrapped with OverlayProvider from React Aria. Rationale: OverlayProvider enables aria-hidden on background content. Without it, screen reader users can reach obscured content, which is a WCAG 2.2 failure. [id: modal.requires-overlay-provider; testability: manual]
- **Modal headingComponent prop** MUST produce a heading element at the semantically appropriate level for the page. Rationale: The modal title should fit the document outline. Defaulting to h2 is correct for most full-page layouts; change only when the structure requires it. [id: modal.heading-component-semantic; testability: auto]
## Accessibility
### 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
1. The controls in the modal content, in the order they are written
2. 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](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html)
### 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](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html)
### 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 each test covers:
- 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.
What these statuses mean:
- 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.
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.
## Properties
### Modal
Also accepts all props from `HTMLAttributes`.
- `isDismissable` (type `boolean`, optional, default `true`). Whether to close the overlay when the user interacts outside it.
- `title` (type `ReactNode`, required). Heading shown at the top of the modal. It also labels the dialog for assistive technology.
- `headingComponent` (type `string | ComponentType`, optional, default `Heading3`). Element or component used to render the title.
- `autoFocus` (type `boolean`, optional). Moves focus into the modal when it opens.
- `containFocus` (type `boolean`, optional, default `true`). Keeps keyboard focus inside the modal while it is open.
- `isOpen` (type `boolean`, optional). Whether the modal is shown. When false, nothing is rendered.
- `onClose` (type `() => void`, optional). 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
Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS.
### overlay
```css
position: fixed;
z-index: 100;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
overflow-y: auto;
```
### modal
```css
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
```css
${mediaQueries.smallAndUp} {
padding-left: ${space[8]};
padding-right: ${space[8]};
}
```
### heading
```css
padding-right: ${space[6]};
```
### dismiss-button
```css
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};
}
```