Modal

Modal opens a dialog for a focused task or confirmation. Use it when people must finish or dismiss something before going on.

  • Beta
  • Accessibility
  • Figma Modals 4457:59536
  • packages/ui/src/components/Modal

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.

tsx
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

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer 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

tsx
// 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

tsx
'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: Modal holds the state, ModalTrigger opens it, ModalContent is the dialog, and ModalHeader, ModalBody and ModalFooter lay it out.
  • Every modal needs a ModalTitle; it names the dialog. Add a ModalDescription, or pass aria-describedby={undefined} to ModalContent when there isn't one.
  • Use asChild on ModalTrigger and ModalClose with a <Button>, so focus returns to the trigger and Cancel closes the modal.
  • Use the alert layout with role="alertdialog" for destructive confirmations, and sectioned (the default) for tasks with fields.
  • Control it with open and onOpenChange when 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

LayoutUse it forStructureFigma frames
sectionedTasks 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
alertConfirmations, 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

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

PartSectionedAlert
Width664, capped at the viewport minus 16 on each side
Max heightViewport height minus 16 on each side; content scrolls inside
Corner radius36 (--nu-modal-radius; no radius token exists, C38); 24 on screens up to 480px wide
PaddingHeader and footer bars 20 above and below, 24 at the sides; body 24 all round32 on every side (including the 1px border)
Gap between partsSet by the section padding (footer without a bar: 24 under the body)24
Header20 padding above and below, 1px divider inside; title stops 12 before the close buttonTitle and description 4 apart; a long title stays clear of the close button
Close button36 × 36, centred on the title line, 20 from the end36 × 36, centred on the title line, 20 from the end
BodyItems 12 apart
FooterActions 16 apart, end-aligned. Bar: 20 above and below the actions with a 1px dividerActions 16 apart, end-aligned
TypeTitle 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

Open and dismissOpens over a dimmed overlay with a short fade and scale. Escape, a click outside, the close button and Cancel all close it.
Try it in any example
Focus trappedFocus moves to the first field or action, Tab cycles inside the modal, and the page behind is inert. Focus returns to the trigger on close.
  • Biology 10114 lessons · 3 students
Alert dialogWith 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.
Long contentPast the viewport height the whole modal scrolls inside itself; scrolling doesn't leak to the page.
Opacity only
Reduced motionWith prefers-reduced-motion, the open animation is removed. Motion uses --nu-duration-fast and --nu-ease-standard (C34).
Not designed
OverlayFigma shows no overlay, so the modal reuses the Command menu scrim, --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.

PropertyValueControls
--nu-modal-width664pxWidth before the viewport cap
--nu-modal-radius36px (raw; radius-36 proposed, C38)Corner radius
--nu-modal-close-size36pxClose button size and the space kept for it (P3)
--nu-modal-bar-pad--nu-space-5Padding above and below the header and footer bars; close-button inset (P3)
--nu-modal-alert-pad--nu-space-8Alert layout padding (P3)
--nu-modal-gutter--nu-space-4Minimum distance from the viewport edge
--nu-modal-pad-inline--nu-space-6Side 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.

  • Photosynthesis basicsBiology 101 · 12 min

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.

  • Biology 10114 lessons · 3 students

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)

Modal props
PropTypeDefaultDescription
openboolean—Controlled open state. Use with onOpenChange.
defaultOpenbooleanfalseInitial 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.
modalbooleantrueTraps focus and makes the page behind inert. Keep it true; a modal that isn't modal is a different pattern.
childrenReactNode—The trigger and content.

ModalTrigger and ModalClose

ModalTrigger and ModalClose props
PropTypeDefaultDescription
asChildbooleanfalseMerges behaviour into the single child, usually a <Button>, instead of rendering a plain <button>.
refRef<HTMLButtonElement>—Forwarded to the button.
…restButtonHTMLAttributes—Any native button attribute (onClick, aria-*…). type defaults to "button".

ModalContent (the dialog, rendered in a portal over the overlay)

ModalContent props
PropTypeDefaultDescription
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.
showClosebooleantrueShows the 36px icon-only close button (Button lighter, sm; P3).
closeLabelstring'Close'Accessible name of the close button. Pass a translated string.
containerElement | DocumentFragment | nulldocument.bodyWhere 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.
forceMounttrue—Passed to the Radix content. The portal and overlay aren’t force-mounted, so it has no effect while the modal is closed.
asChildbooleanfalseRadix slot on the content element. Not needed for normal use.
refRef<HTMLDivElement>—Forwarded to the dialog element.
…restHTMLAttributes<HTMLDivElement>—Any other attribute, for example className, or aria-describedby={undefined} when there's no description.

ModalHeader, ModalBody and ModalFooter (layout parts, all <div>)

ModalHeader, ModalBody and ModalFooter props
PropTypeDefaultDescription
iconReactNode—ModalHeader only. Decorative icon before the title, 24px, Icon/Sub-600, rendered aria-hidden. Not in Figma (C38).
dividerbooleantrueModalFooter only. In sectioned, draws the footer as a bar with a top divider; false puts the actions 12px under the body. Ignored by alert.
refRef<HTMLDivElement>—Forwarded to the div.
…restHTMLAttributes<HTMLDivElement>—Any native div attribute. className is merged.

ModalTitle (<h2>) and ModalDescription (<p>)

ModalTitle and ModalDescription props
PropTypeDefaultDescription
asChildbooleanfalseRender your own element instead, for example an <h3> when the heading level must differ. It still names or describes the dialog.
refRef<HTMLHeadingElement> | Ref<HTMLParagraphElement>—Forwarded to the element.
…restHTMLAttributes—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

KeyBehaviour
Enter / SpaceOn the trigger: opens the modal and moves focus inside.
Tab / Shift + TabMoves between controls in the modal. Focus wraps and never reaches the page behind.
EscCloses 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

FigmaValuesCode
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 Accountlayout="alert", usually with role="alertdialog"
Footer barPresent (Create a Personal Space) · absent (Rename Lesson)ModalFooter divider
Close buttonButtons, Lighter, icon only, labelled “Medium (40)”, measures 52showClose; Button sm, 36px (P3)
Title / descriptionLabel/X Large · Paragraph/MediumModalTitle, ModalDescription
Radius 36 (unbound)No token--nu-modal-radius (C38)
Not in FigmaOverlay, 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

DoName the action on the button. “Delete space” confirms exactly what will happen.
Don'tDon't use Yes and No. People have to re-read the question to know which is which.
DoPut Cancel first and the main action last. The main action sits at the end, where the eye finishes, and Cancel gets initial focus.
Don'tDon't give both actions equal emphasis. Two filled buttons make people stop and compare.
DoUse the alert layout for destructive confirmations. With role alertdialog, a stray click outside can't dismiss the decision.
Don'tDon't confirm everyday, undoable actions. Frequent confirmations train people to click through without reading.

Common mistakes

  • Leaving out ModalTitle, so the dialog has no name (Radix warns in development).
  • Opening the modal from a plain onClick without ModalTrigger, so focus doesn't return on close.
  • Wrapping ModalBody and ModalFooter in a <form>, which breaks the layout. Put the form in the body and use the form attribute 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.