Modal

Focus a task in a dialog over the page, with an alert dialog for destructive confirmations.

Installation

You can add the modal using our CLI or manually:

Dark mode ready - this component follows BoardUI semantic tokens automatically. Set up theme switching.

The CLI copies the component source (with its internal dependencies) into your project and installs any required npm packages.

npx boardui@latest add modal

Using a shared UI package or custom import aliases? Configure your installation paths before running the CLI. Copy-paste examples use the default imports.

Anatomy

ModalTrigger takes the trigger, any BoardUI button, followed by the Modal. Inside, ModalHeader names the dialog and describes it to assistive technology, ModalBody holds the content, and ModalFooter lines up the actions, stacked full width on phones.

Pass a function as the children to receive close. The panel fades and sharpens in from a soft blur, and plays the same transition in reverse before it leaves the page.

Alert dialog

AlertDialog asks for an answer before a destructive action. A press on the backdrop does not close it, focus starts on Cancel, and screen readers announce it as an alert. Return a promise from onConfirm to keep it open, with both actions disabled and pendingLabel on the button, until the work is done.

import { RiDeleteBinLine } from "@remixicon/react";import { Button } from "@/components/base/buttons/button";import { AlertDialog, ModalTrigger } from "@/components/base/modal/modal"; <ModalTrigger> <Button variant="danger" leadingIcon={RiDeleteBinLine}>Delete project</Button> <AlertDialog title="Delete project?" description="Acme Dashboard and its 24 files will be deleted for everyone. You can't undo this." confirmLabel="Delete project" pendingLabel="Deleting..." onConfirm={() => deleteProject(projectId)} // return the promise to keep it open until done /></ModalTrigger>

Confirm by typing

Children render between the description and the actions. Pair an input with isConfirmDisabled for actions that deserve a second look.

const [phrase, setPhrase] = useState(""); <ModalTrigger onOpenChange={(open) => !open && setPhrase("")}> <Button variant="secondary">Transfer ownership</Button> <AlertDialog title="Transfer workspace ownership?" description="Maya Chen becomes the owner and you become an admin. Type acme below to confirm." confirmLabel="Transfer ownership" isConfirmDisabled={phrase !== "acme"} > <Input aria-label="Workspace name" placeholder="acme" value={phrase} onChange={setPhrase} autoComplete="off" /> </AlertDialog></ModalTrigger>

Long content

When the content is taller than the screen, only ModalBody scrolls. The header and actions stay in place, and a progressive blur softens the top edge once the content moves under it.

<ModalTrigger> <Button variant="secondary" leadingIcon={RiFileTextLine}>Review terms</Button> <Modal size="lg"> {({ close }) => ( <> <ModalHeader title="Terms of service" description="Last updated 1 October 2026" /> <ModalBody>{/* any amount of content */}</ModalBody> <ModalFooter> <Button variant="secondary" onClick={close}>Decline</Button> <Button onClick={close}>Accept</Button> </ModalFooter> </> )} </Modal></ModalTrigger>

Controlled

Leave out ModalTrigger and drive the dialog with isOpen and onOpenChange, for example from a menu item or after a request finishes.

Project: Acme Dashboard

const [isOpen, setIsOpen] = useState(false); <Button variant="secondary" onClick={() => setIsOpen(true)}>Rename project</Button> <Modal isOpen={isOpen} onOpenChange={setIsOpen} size="sm"> <ModalHeader title="Rename project" /> <ModalBody> <form id="rename-form" onSubmit={(event) => { event.preventDefault(); save(); setIsOpen(false); }}> <Input aria-label="Project name" value={draft} onChange={setDraft} autoFocus /> </form> </ModalBody> <ModalFooter> <Button variant="secondary" onClick={() => setIsOpen(false)}>Cancel</Button> <Button type="submit" form="rename-form">Save</Button> </ModalFooter></Modal>

Sizes

Four widths, all capped by the screen. Without a header title, give the dialog an aria-label.

<Modal size="sm">…</Modal> // 400px, the AlertDialog default<Modal size="md">…</Modal> // 480px, the default<Modal size="lg">…</Modal> // 640px<Modal size="xl">…</Modal> // 800px // Without a ModalHeader, name the dialog yourself<Modal aria-label="Keyboard shortcuts">…</Modal>

Right-to-left

Wrap the region in DirectionProvider. The dialog renders in a portal and carries the direction with it, so the close button, actions, and text alignment mirror.

import { DirectionProvider } from "@/components/foundations/direction/direction"; <DirectionProvider locale="ar"> <ModalTrigger> <Button variant="secondary">دعوة أعضاء الفريق</Button> <Modal> {({ close }) => ( <> <ModalHeader title="دعوة أعضاء الفريق" description="سيصلهم بريد إلكتروني برابط للانضمام إلى مساحة العمل." closeLabel="إغلاق" /> <ModalBody> <Input label="البريد الإلكتروني" type="email" placeholder="name@company.com" /> </ModalBody> <ModalFooter> <Button variant="secondary" onClick={close}>إلغاء</Button> <Button onClick={close}>إرسال الدعوة</Button> </ModalFooter> </> )} </Modal> </ModalTrigger></DirectionProvider>

Keyboard and accessibility

Opening the dialog moves focus inside it, Tab and Shift+Tab cycle through its controls, and Escape closes it. Focus then returns to the trigger. The page behind stops scrolling and is hidden from screen readers while the dialog is open.

The header title becomes the dialog's accessible name and the description its accessible description. Reduced motion keeps the fade and drops the scale and blur.