Modal
- Stable
- WCAG 2.2 evidence
- RTL
A modal window for a task that needs the user's full attention, such as a short form. Built on React
Aria ModalOverlay + Modal + Dialog: a role="dialog" named by its title and described by its subtitle; focus
moves inside on open, Tab stays inside (the rest of the page is inert and its scrolling is locked), Escape or the
close button closes it, and focus returns to the trigger (WCAG 2.1.1, 2.1.2, 2.4.3, 4.1.2). The body scrolls between
a fixed header and footer, and becomes focusable when it overflows; dividers appear while it is scrolled. Below 768 px it docks to the bottom of the screen (1.4.10). Motion only runs when allowed (2.3.3). Consumer duties: a title that states the task and actions that name the outcome; error handling inside the form
(3.3.1, 3.3.4). For a quick confirmation use Dialog; for content people compare with the page use Drawer.
import { Modal } from "@nexera-ui/react";- 9
- examples
- 18
- props
- 5
- live controls
- 1
- platform
- 11
- WCAG criteria
- 14
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { Modal } from "@nexera-ui/react";
<Modal />Examples 8
The same examples as Storybook, rendered live. Open Code to copy one.
Medium
Figma Size=md (560 px).
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
<Modal
title="Add employee"
subtitle="They will get an invite to set up their account."
trigger={<Button>Add employee</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button size="md" slot="close">
Send invite
</Button>
}
defaultOpen
size="md"
>
{<InviteForm />}
</Modal>;
Large
Figma Size=lg (720 px).
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
<Modal
title="Add employee"
subtitle="They will get an invite to set up their account."
trigger={<Button>Add employee</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button size="md" slot="close">
Send invite
</Button>
}
defaultOpen
size="lg"
>
{<InviteForm />}
</Modal>;
Fullscreen
Figma Size=Fullscreen: fills the viewport; the body scrolls between the header and the footer.
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
<Modal
title="Add employee"
subtitle="They will get an invite to set up their account."
trigger={<Button>Add employee</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button size="md" slot="close">
Send invite
</Button>
}
defaultOpen
size="fullscreen"
>
{<InviteForm />}
</Modal>;
With footer note
Figma Show footer note: a step indicator at the start of the footer.
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
<Modal
title="Add employee"
subtitle="They will get an invite to set up their account."
trigger={<Button>Add employee</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={<Button size="md">Next</Button>}
defaultOpen
footerNote="Step 1 of 3"
>
{<InviteForm />}
</Modal>;
Long content
Figma Scroll=Scrolled: the body is longer than the viewport allows, so it scrolls (and joins the tab order) while the header and the actions stay in place; dividers appear while there is content above or below.
import { Button, Modal } from "@nexera-ui/react";
<Modal
title="Leave policy for the payroll and finance teams in the last quarter of the year"
subtitle="Read the policy before approving requests. Everyone on the team gets the same rules, and exceptions need the approval of a director."
trigger={<Button>Add employee</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button size="md" slot="close">
I have read it
</Button>
}
defaultOpen
footerNote="Updated 2 October"
>
{
<div className="flex flex-col gap-3">
{Array.from({ length: 14 }, (_, index) => (
<p key={index} className="m-0">
{index + 1}. Requests for the last two weeks of December must be sent before 15 November.
Overlapping requests in the same team are approved in the order they were sent, and the
payroll run on the 28th needs two people available.
</p>
))}
</div>
}
</Modal>;
Controlled
Opened from the page's own state instead of a trigger (isOpen + onOpenChange), for example after a table action.
import { useState } from "react";
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
function ControlledExample() {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="secondary"
onPress={() => {
setOpen(true);
}}
>
Edit role
</Button>
<Modal
title="Edit role"
isOpen={open}
onOpenChange={setOpen}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button size="md" slot="close">
Save role
</Button>
}
>
<InviteForm />
</Modal>
</>
);
}
export function Controlled() {
return <ControlledExample />;
}
Right to left
Right-to-left: the close button moves to the left, actions to the left of the footer.
import { Button, Modal } from "@nexera-ui/react";
function InviteForm() {
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<label className="flex flex-col gap-1 text-component-label text-primary">
Work email
<input
type="email"
autoComplete="email"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
<label className="flex flex-col gap-1 text-component-label text-primary">
Role
<input
type="text"
className="box-border min-h-control-md rounded-md border border-input-border bg-input px-3 text-body-default text-primary"
/>
</label>
</form>
);
}
<Modal
title="إضافة موظف"
subtitle="سيتلقى دعوة لإعداد حسابه."
trigger={<Button>إضافة موظف</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
إلغاء
</Button>
}
primaryAction={
<Button size="md" slot="close">
إرسال الدعوة
</Button>
}
defaultOpen
closeLabel="إغلاق"
footerNote="الخطوة 1 من 3"
>
{<InviteForm />}
</Modal>;
Multi step invite
import { useState } from "react";
import { Button, Input, Modal, Select, SelectOption } from "@nexera-ui/react";
function MultiStepInviteDemo() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(1);
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
return (
<div className="w-80 max-w-full flex flex-col gap-4">
<p className="m-0 text-body-default">
Invite a teammate, review their access and confirm. No email is sent in this demo.
</p>
<Button
onPress={() => {
setStep(1);
setError("");
setOpen(true);
}}
>
Invite a teammate
</Button>
{sent ? (
<p role="status" className="m-0 text-body-default">
Demo invitation prepared for {email}.
</p>
) : null}
<Modal
isOpen={open}
onOpenChange={setOpen}
title={step === 1 ? "Add your teammate" : "Review invitation"}
subtitle="Grant the right access from day one."
footerNote={`Step ${step} of 2`}
secondaryAction={
<Button
variant="secondary"
onPress={() => {
if (step === 2) setStep(1);
else setOpen(false);
}}
>
{step === 2 ? "Back" : "Cancel"}
</Button>
}
primaryAction={
<Button
onPress={() => {
if (step === 1) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
setError("Enter a valid email address.");
else setStep(2);
} else {
setSent(true);
setOpen(false);
}
}}
>
{step === 1 ? "Review invitation" : "Confirm invitation"}
</Button>
}
>
{step === 1 ? (
<div className="flex flex-col gap-4">
<Input
label="Teammate email"
type="email"
value={email}
onChange={(value) => {
setEmail(value);
setError("");
}}
isInvalid={Boolean(error)}
errorMessage={error}
autoComplete="email"
/>
<Select
label="Access level"
value={role}
onChange={(key) => {
if (key !== null) setRole(String(key));
}}
>
<SelectOption id="member" label="Member" description="Create and edit projects" />
<SelectOption id="viewer" label="Viewer" description="View shared projects" />
<SelectOption
id="admin"
label="Administrator"
description="Manage workspace settings"
/>
</Select>
</div>
) : (
<dl className="m-0 grid gap-3 text-body-default">
<div>
<dt>Email</dt>
<dd className="m-0 font-semibold">{email}</dd>
</div>
<div>
<dt>Access level</dt>
<dd className="m-0 font-semibold">{role}</dd>
</div>
</dl>
)}
</Modal>
</div>
);
}
export function MultiStepInvite() {
return <MultiStepInviteDemo />;
}
Props 18
Press "Try it" on a card to load that prop into the playground.
18 props shown
title*NexeraReactNodeTitle: a level 2 heading that names the dialog (aria-labelledby). State the task ("Add
employee"); it wraps instead of truncating.
subtitleNexeraReactNodeSupporting line under the title. Also the dialog's description.
childrenNexeraReactNodeThe body: a form, a list, text. It scrolls between the header and the footer when it is taller than the viewport allows.
footerNoteNexeraReactNodeShort note at the start of the footer, such as "Step 1 of 3".
primaryActionNexeraReactNodeMain action at the end of the footer: <Button size="md">. Give it slot="close" to close
the modal when pressed, or close it yourself through isOpen when the task succeeds.
secondaryActionNexeraReactNodeAction before it: usually <Button variant="secondary" size="md"
slot="close">Cancel</Button>.
sizeNexera"sm" | "md" | "lg" | "fullscreen"Width: 400 / 560 / 720 px, or the whole viewport. Below 768 px every size except fullscreen becomes a
full-width bottom sheet.
closeLabelNexerastringAccessible name of the close button. Translate it for your locale.
isDismissableNexerabooleanWhether a click on the scrim closes the modal. Off by default: a modal holds a task, and a stray click must not
throw away what was typed. Escape and the close button always close it unless isKeyboardDismissDisabled.
idNexerastringId of the surface element.
classNameNexerastringExtra classes for the surface, merged last so they win over the defaults.
styleNexeraCSSPropertiesInline style for the surface.
triggerNexeraReactElement<unknown, string | JSXElementConstructor<any>>The element that opens the overlay, usually a Nexera Button. It toggles the overlay when pressed and gets focus
back when the overlay closes. Leave it out to open the overlay from your own state (isOpen + onOpenChange).
isOpenNexerabooleanWhether the overlay is open (controlled). Use with onOpenChange.
defaultOpenNexerabooleanWhether the overlay is open on first render (uncontrolled).
onOpenChangeNexera(isOpen: boolean) => voidCalled when the overlay opens or closes (Escape, the close button, a slot="close" action, a click outside).
shouldCloseOnInteractOutsideReact Aria(element: Element) => booleanWhen user interacts with the argument element outside of the overlay ref, return true if onClose should be called. This gives you a chance to filter out interaction with elements that should not dismiss the overlay. By default, onClose will always be called on interaction outside the overlay ref.
isKeyboardDismissDisabledReact AriabooleanWhether pressing the escape key to close the modal should be disabled.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
WCAG 2.2 evidence
6 direct · 5 supporting- 1.3.1Info and RelationshipsLevel A · tested directly
- 1.3.2Meaningful SequenceLevel A · tested directly
- 1.4.4Resize TextLevel AA · supporting test
- 1.4.10ReflowLevel AA · supporting test
- 1.4.12Text SpacingLevel AA · supporting test
- 2.1.1KeyboardLevel A · tested directly
- 2.1.2No Keyboard TrapLevel A · tested directly
- 2.3.3Animation from InteractionsLevel AAA · supporting test
- 2.4.3Focus OrderLevel A · tested directly
- 2.4.7Focus VisibleLevel AA · supporting test
- 4.1.2Name, Role, ValueLevel A · tested directly
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
data-dividerdata-hovereddata-pressed
<Modal className="data-divider:opacity-90 shadow-sm" />Used in blocks
- Session expiredSign in and account
- Product tourOnboarding
- Onboarding stepsOnboarding
- Invite your teamOnboarding
- Workspace switcherApp shells and navigation
- Keyboard shortcutsLists, tables and records
- Add a personForms and data entry
- Terms and consentForms and data entry
- Week calendarScheduling and calendar
- Saved payment methodsCommerce and payments
- Product galleryCommerce and payments
- Photo galleryMedia and content
- Document previewMedia and content
- Success confirmationStates and system messages
Related components
- ActionSheetAn iOS-style sheet of actions that slides up from the bottom, with a separate Cancel row.
- ActionSheetItemOne action in an `ActionSheet`.
- BottomSheetA mobile sheet that slides up from the bottom edge for a short task or a set of options.
- CommandPaletteA searchable list of pages, actions and people in a modal.
- DialogA short modal question that needs a decision before work continues, such as "Submit October payroll?".
- DrawerA side panel over the page for details, filters or a secondary task.
- PopoverA short, non-modal panel anchored to a trigger.
- TooltipA short description of a control, shown on hover and keyboard focus.