Modal
Modal opens a dialog for a focused task or confirmation. Use it when people must finish or dismiss something before going on.
Beta until design confirms the two layouts, the 36px radius token and the overlay (C38), and the close button's focus indicator meets 3:1 (C5). Everything else on this page is implemented and tested.
Preview
Change the controls, then open the modal. The preview renders the real component and the code updates with it.
import {
Button, Modal, ModalBody, ModalClose, ModalContent, ModalDescription, ModalFooter, ModalHeader, ModalTitle, ModalTrigger, Select,
} from '@nuton/ui'
<Modal>
<ModalTrigger asChild>
<Button appearance="stroke">Move lesson</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Move to a space</ModalTitle>
<ModalDescription>Choose where “Photosynthesis basics” should live.</ModalDescription>
</ModalHeader>
<ModalBody>
<Select aria-label="Space" value={space} onChange={(e) => setSpace(e.target.value)}>
<option>Biology 101</option>
<option>Chemistry revision</option>
<option>Exam prep</option>
</Select>
</ModalBody>
<ModalFooter>
<ModalClose asChild><Button appearance="stroke">Cancel</Button></ModalClose>
<ModalClose asChild><Button onClick={moveLesson}>Move lesson</Button></ModalClose>
</ModalFooter>
</ModalContent>
</Modal>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactPeer dependencies: react and react-dom 18 or later. Modal is built on @radix-ui/react-dialog, which @nuton/ui installs for you (ADR 0005). The close button uses a Remix icon (D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)3. Render it from a client component
Modal holds open state and renders into a portal, so it is a client component ('use client'). In the Next.js App Router, put the modal and its handlers in a client file and import that into your server page.
Usage
'use client'
import {
Button, Modal, ModalClose, ModalContent, ModalDescription, ModalFooter, ModalHeader, ModalTitle, ModalTrigger,
} from '@nuton/ui'
export function ArchiveLesson({ onArchive }: { onArchive: () => void }) {
return (
<Modal>
<ModalTrigger asChild>
<Button appearance="stroke">Archive lesson</Button>
</ModalTrigger>
<ModalContent layout="alert">
<ModalHeader>
<ModalTitle>Archive “Photosynthesis basics”?</ModalTitle>
<ModalDescription>Students won’t see it in Biology 101. You can restore it from the archive.</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild><Button appearance="stroke">Cancel</Button></ModalClose>
<ModalClose asChild><Button onClick={onArchive}>Archive lesson</Button></ModalClose>
</ModalFooter>
</ModalContent>
</Modal>
)
}- Compose the parts:
Modalholds the state,ModalTriggeropens it,ModalContentis the dialog, andModalHeader,ModalBodyandModalFooterlay it out. - Every modal needs a
ModalTitle; it names the dialog. Add aModalDescription, or passaria-describedby={undefined}toModalContentwhen there isn't one. - Use
asChildonModalTriggerandModalClosewith a<Button>, so focus returns to the trigger and Cancel closes the modal. - Use the
alertlayout withrole="alertdialog"for destructive confirmations, andsectioned(the default) for tasks with fields. - Control it with
openandonOpenChangewhen it must stay open, for example while a form is invalid.
Variants and sizes
Figma draws six example frames but no component set. They fall into two layouts, which become the layout prop. Header icon, footer divider and close button are independent options.
Layout
<Modal>
<ModalTrigger asChild><Button appearance="stroke">Sectioned</Button></ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Lesson details</ModalTitle>
<ModalDescription>Photosynthesis basics · Biology 101</ModalDescription>
</ModalHeader>
<ModalBody>
<Field label="Lesson name"><Input defaultValue="Photosynthesis basics" /></Field>
</ModalBody>
<ModalFooter>
<ModalClose asChild><Button appearance="stroke">Cancel</Button></ModalClose>
<ModalClose asChild><Button>Save</Button></ModalClose>
</ModalFooter>
</ModalContent>
</Modal>
<Modal>
<ModalTrigger asChild><Button appearance="stroke">Alert</Button></ModalTrigger>
<ModalContent layout="alert" role="alertdialog">
<ModalHeader>
<ModalTitle>Leave this space?</ModalTitle>
<ModalDescription>You’ll lose access to its lessons and chats until someone invites you again.</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild><Button appearance="stroke">Stay</Button></ModalClose>
<ModalClose asChild><Button intent="error">Leave space</Button></ModalClose>
</ModalFooter>
</ModalContent>
</Modal>| Layout | Use it for | Structure | Figma frames |
|---|---|---|---|
sectioned | Tasks with fields or choices: rename, move, create, invite. | Header bar with a bottom divider and the close button beside the title; body; optional footer bar with a top divider. | Create a Personal Space, Move to a space, Rename Lesson |
alert | Confirmations, usually destructive: delete, leave, discard. | A 1px border, the close button in the top corner, title and description stacked, actions underneath. No dividers. | Delete Lesson, Delete Account |
Footer divider
<ModalFooter> {/* default: a bar with a top divider */}
<ModalFooter divider={false}> {/* actions sit 12px under the body */}divider applies to sectioned only. The alert layout never shows a divider.
Header icon and close button
ModalHeader takes an optional decorative icon before the title (24px, Icon/Sub-600, hidden from screen readers). Figma doesn't draw it; it was added for the brief using existing tokens (C38). showClose={false}hides the close button. Do that only when the footer has its own Cancel, because the close button is the visible way out for pointer users. Try both in the playground or in Invite students.
Dimensions
| Part | Sectioned | Alert |
|---|---|---|
| Width | 664, capped at the viewport minus 16 on each side | |
| Max height | Viewport height minus 16 on each side; content scrolls inside | |
| Corner radius | 36 (--nu-modal-radius; no radius token exists, C38); 24 on screens up to 480px wide | |
| Padding | Header and footer bars 20 above and below, 24 at the sides; body 24 all round | 32 on every side (including the 1px border) |
| Gap between parts | Set by the section padding (footer without a bar: 24 under the body) | 24 |
| Header | 20 padding above and below, 1px divider inside; title stops 12 before the close button | Title and description 4 apart; a long title stays clear of the close button |
| Close button | 36 × 36, centred on the title line, 20 from the end | 36 × 36, centred on the title line, 20 from the end |
| Body | Items 12 apart | |
| Footer | Actions 16 apart, end-aligned. Bar: 20 above and below the actions with a 1px divider | Actions 16 apart, end-aligned |
| Type | Title Label/X Large (24/34) Text/Strong-950; description Paragraph/Medium (16/28) Text/Sub-600 | |
Values in px, from the component CSS. Spacing balance (P3, owner request): Figma uses 12 above and below the header and footer bars, 60 / 24 / 24 / 24 in the alert layout and a 52px close button. Inside a 36px radius that read as cramped at the top and heavy at the corners. Code evens it out as shown, with a 36px close button (Button sm) in proportion to the 24px title. Colours, type, width and radius follow Figma.
States and interactions
- Biology 101
role="alertdialog", a click outside doesn't close it, so a stray click can't skip the decision. Escape and the close button still do.prefers-reduced-motion, the open animation is removed. Motion uses --nu-duration-fast and --nu-ease-standard (C34).--nu-overlay-gray. Design to confirm (C38).Figma has no loading state: keep the modal open and set loading on the footer's main Button.
Component properties
Set on .nu-modal. Override them on a wrapper class only for a documented exception.
| Property | Value | Controls |
|---|---|---|
--nu-modal-width | 664px | Width before the viewport cap |
--nu-modal-radius | 36px (raw; radius-36 proposed, C38) | Corner radius |
--nu-modal-close-size | 36px | Close button size and the space kept for it (P3) |
--nu-modal-bar-pad | --nu-space-5 | Padding above and below the header and footer bars; close-button inset (P3) |
--nu-modal-alert-pad | --nu-space-8 | Alert layout padding (P3) |
--nu-modal-gutter | --nu-space-4 | Minimum distance from the viewport edge |
--nu-modal-pad-inline | --nu-space-6 | Side padding and body padding |
Colours: surface --nu-bg-white, border and dividers --nu-stroke-soft-200, overlay --nu-overlay-gray. Layers: overlay --nu-z-overlay, modal --nu-z-modal.
Composition examples
Rename a lesson
Figma “Rename Lesson”: a sectioned modal with one field and no footer bar. The form lives in the body, and the Save button submits it with the form attribute, so Enter in the field also saves. The modal stays open while the name is empty.
Delete a space
Figma “Delete Lesson”: an alert dialog. The title asks the question, the description names what is lost, and the destructive button repeats the verb. The trigger disappears after deletion, so onCloseAutoFocus moves focus to the next sensible control instead of losing it.
Initial focus lands on Cancel, the first action, because the close button comes last in the DOM. That is the safe default for a destructive confirmation.
Invite students
The “Create a Personal Space” structure: a header icon, a short form in the body and a footer bar. The email is validated on submit and the error is linked to the field.
Modals and navigation
A modal is for a task in the current view. If people need to link to it, bookmark it or use the browser's back button to close it, make it a page instead. Never open a modal from inside another modal; finish or close the first one.
API reference
Props
Modal is a set of parts. Modal, ModalTrigger, ModalClose, ModalTitle and ModalDescription are Radix Dialog parts with Nuton styles; their props are Radix's.
Modal (root, renders no element)
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Use with onOpenChange. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Called when the modal opens or closes, from the trigger, Escape, the overlay, the close button or ModalClose. |
modal | boolean | true | Traps focus and makes the page behind inert. Keep it true; a modal that isn't modal is a different pattern. |
children | ReactNode | — | The trigger and content. |
ModalTrigger and ModalClose
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merges behaviour into the single child, usually a <Button>, instead of rendering a plain <button>. |
ref | Ref<HTMLButtonElement> | — | Forwarded to the button. |
…rest | ButtonHTMLAttributes | — | Any native button attribute (onClick, aria-*…). type defaults to "button". |
ModalContent (the dialog, rendered in a portal over the overlay)
| Prop | Type | Default | Description |
|---|---|---|---|
layout | 'sectioned' | 'alert' | 'sectioned' | Figma frame family: header bar and dividers, or a bordered confirmation. |
role | 'dialog' | 'alertdialog' | 'dialog' | alertdialog for destructive confirmations: announced as an alert, and a click outside no longer closes it. |
showClose | boolean | true | Shows the 36px icon-only close button (Button lighter, sm; P3). |
closeLabel | string | 'Close' | Accessible name of the close button. Pass a translated string. |
container | Element | DocumentFragment | null | document.body | Where the portal renders. |
onOpenAutoFocus | (event: Event) => void | — | Runs when focus moves in on open. Call preventDefault() and focus an element yourself to change the target. |
onCloseAutoFocus | (event: Event) => void | — | Runs when focus returns on close. Call preventDefault() to send focus elsewhere, for example when the trigger was removed. |
onEscapeKeyDown | (event: KeyboardEvent) => void | — | Escape was pressed. preventDefault() keeps the modal open. |
onPointerDownOutside | (event: PointerDownOutsideEvent) => void | — | A pointer went down outside. Always prevented for alertdialog. |
onFocusOutside | (event: FocusOutsideEvent) => void | — | Focus moved outside the modal. |
onInteractOutside | (event: PointerDownOutsideEvent | FocusOutsideEvent) => void | — | Either of the two above. preventDefault() keeps the modal open. |
forceMount | true | — | Passed to the Radix content. The portal and overlay aren’t force-mounted, so it has no effect while the modal is closed. |
asChild | boolean | false | Radix slot on the content element. Not needed for normal use. |
ref | Ref<HTMLDivElement> | — | Forwarded to the dialog element. |
…rest | HTMLAttributes<HTMLDivElement> | — | Any other attribute, for example className, or aria-describedby={undefined} when there's no description. |
ModalHeader, ModalBody and ModalFooter (layout parts, all <div>)
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | ModalHeader only. Decorative icon before the title, 24px, Icon/Sub-600, rendered aria-hidden. Not in Figma (C38). |
divider | boolean | true | ModalFooter only. In sectioned, draws the footer as a bar with a top divider; false puts the actions 12px under the body. Ignored by alert. |
ref | Ref<HTMLDivElement> | — | Forwarded to the div. |
…rest | HTMLAttributes<HTMLDivElement> | — | Any native div attribute. className is merged. |
ModalTitle (<h2>) and ModalDescription (<p>)
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render your own element instead, for example an <h3> when the heading level must differ. It still names or describes the dialog. |
ref | Ref<HTMLHeadingElement> | Ref<HTMLParagraphElement> | — | Forwarded to the element. |
…rest | HTMLAttributes | — | Any native attribute. className is merged. The id is generated and wired to aria-labelledby / aria-describedby. |
Types exported: ModalProps, ModalTriggerProps, ModalCloseProps, ModalContentProps, ModalLayout, ModalHeaderProps and ModalFooterProps.
Styling hooks
The parts render .nu-modal__overlay, .nu-modal (with data-layout and data-close), .nu-modal__header (data-icon), .nu-modal__icon, .nu-modal__title, .nu-modal__description, .nu-modal__body, .nu-modal__footer (data-divider) and .nu-modal__close. Use props rather than styling these. The --nu-modal-* properties (see Component properties) are the supported override points. Keep ModalHeader, ModalBody and ModalFooter as direct children of ModalContent; the layout styles depend on it.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Enter / Space | On the trigger: opens the modal and moves focus inside. |
| Tab / Shift + Tab | Moves between controls in the modal. Focus wraps and never reaches the page behind. |
| Esc | Closes the modal and returns focus to the trigger, for both roles. |
Screen readers
Role dialog or alertdialog with aria-modal. Named by ModalTitle (an h2) and described by ModalDescription. The page behind is hidden. The header icon is hidden; the close button is named by closeLabel.
Focus
On open, focus goes to the first field or action (the close button is last in the DOM). On close it returns to the trigger. The close button uses Button's focus ring, below 3:1 today (C5).
Contrast and colour
Title (Strong-950) and description (Sub-600, 6.69:1) pass AA on white. Dividers and the alert border are Stroke/Soft-200, decorative. In forced colours the modal gets a visible border.
Touch and pointer
The close button is 36 × 36, above the 24 × 24 minimum (WCAG 2.5.8). On small screens the modal keeps a 16px gutter and scrolls inside, and the page behind doesn't scroll.
You must: include a ModalTitle; pass aria-describedby={undefined} when there's no description; use asChild on the trigger so focus can return; move focus yourself with onCloseAutoFocus when the trigger is removed; pass a translated closeLabel.
Figma mapping
| Figma | Values | Code |
|---|---|---|
| Frames (no component set) | Create a Personal Space · Move to a space · Rename Lesson (two frames) | layout="sectioned" |
| Frames (no component set) | Delete Lesson · Delete Account | layout="alert", usually with role="alertdialog" |
| Footer bar | Present (Create a Personal Space) · absent (Rename Lesson) | ModalFooter divider |
| Close button | Buttons, Lighter, icon only, labelled “Medium (40)”, measures 52 | showClose; Button sm, 36px (P3) |
| Title / description | Label/X Large · Paragraph/Medium | ModalTitle, ModalDescription |
| Radius 36 (unbound) | No token | --nu-modal-radius (C38) |
| Not in Figma | Overlay, header icon | --nu-overlay-gray, ModalHeader icon (C38) |
Open: design to confirm (C38)
The two layouts, a radius-36 token, the overlay colour, and the header icon's size and colour are interpretations of Figma frames, not a designed component. The role switch uses Radix Dialog with an alertdialog role rather than a separate alert-dialog package, because only @radix-ui/react-dialog is installed (ADR 0005); whether to adopt @radix-ui/react-alert-dialog is pending a decision.
Guidelines and related components
Use Modal for
- Short tasks that need full attention: rename a lesson, move it to a space, invite a student.
- Confirming destructive or irreversible actions: delete a space, leave a space.
- Decisions people must make before going on.
Don't use Modal for
- Long or multi-step flows, like building a course. Use a page.
- Hints or extra detail on hover. Use a Tooltip.
- Choosing from a short list of actions. Use a Dropdown menu.
- Confirming safe, undoable actions. Just do them, and offer undo.
Do and don't
Common mistakes
- Leaving out
ModalTitle, so the dialog has no name (Radix warns in development). - Opening the modal from a plain
onClickwithoutModalTrigger, so focus doesn't return on close. - Wrapping
ModalBodyandModalFooterin a<form>, which breaks the layout. Put the form in the body and use theformattribute on the submit button. - Hiding the close button without a Cancel in the footer, leaving Escape as the only way out.
- Stacking a second modal on top of the first.