Task dialog
A dialog with a title, a body and optional actions, shown over the page.
On this page
No close request yet.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { TaskDialog, type TaskDialogChangeEventDetails, type TaskDialogSize } from "@bosunkit/components/task-dialog";
import { Select } from "@bosunkit/components/select";
import { Switch } from "@bosunkit/components/switch";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Choose the size, placement and dismissal policy, then open the dialog. Closing reports its reason; the settings remain available for the next preview.";
const sizes: readonly TaskDialogSize[] = ["sm", "md", "lg", "xl", "full"];
const sizeNames: Record<TaskDialogSize, string> = {
sm: "Small",
md: "Medium",
lg: "Large",
xl: "Extra large",
full: "Full available area",
};
export default function Options(): ReactElement {
const sizeId: string = useId();
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const [size, setSize]: [TaskDialogSize, Dispatch<SetStateAction<TaskDialogSize>>] = useState<TaskDialogSize>("md");
const [top, setTop]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const [outside, setOutside]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(true);
const [escape, setEscape]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(true);
const [reason, setReason]: [string, Dispatch<SetStateAction<string>>] = useState("No close request yet.");
function close(reason: TaskDialogChangeEventDetails["reason"]): void {
setReason(`Close request: ${reason}.`);
setOpen(false);
}
return (
<div style={{ display: "grid", gap: "var(--space-lg)", width: "100%" }}>
<FormField label="Dialog size" htmlFor={sizeId}>
<Select.Root
value={size}
onValueChange={(next: string | null): void => { ((value: string): void => {
if (sizes.includes(value as TaskDialogSize)) setSize(value as TaskDialogSize);
})(next ?? ""); }}
>
<Select.Trigger id={sizeId}>
<Select.Value />
</Select.Trigger>
<Select.Portal><Select.Positioner alignItemWithTrigger={true} ><Select.Popup ><Select.List>
{sizes.map((value: TaskDialogSize): ReactElement => (
<Select.Item key={value} value={value}>
{sizeNames[value]}
</Select.Item>
))}
</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</FormField>
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)" }}>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch checked={top} onCheckedChange={setTop} />
Align near top
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch checked={outside} onCheckedChange={setOutside} />
Close on backdrop
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch checked={escape} onCheckedChange={setEscape} />
Close on Escape
</label>
</div>
<div>
<Button
onClick={(): void => {
setOpen(true);
}}
>
Open configured modal
</Button>
</div>
<p role="status">{reason}</p>
<TaskDialog
open={open}
onOpenChange={(open, details): void => { if (!open) close(details.reason); }}
title="Review this report"
description="Choose a dialog shape that suits one focused task."
size={size}
placement={top ? "top" : "center"}
closeLabel="Close report preview"
closeOnEscape={escape}
closeOnOutsideClick={outside}
footer="Illustrative report preview"
actions={
<Button
onClick={(): void => {
setOpen(false);
}}
>
Done
</Button>
}
>
<p>The header explains the task, the body contains its content, and the footer keeps the next action clear.</p>
<p>
Try the backdrop, Escape or the close button to see which close request is reported. Done is an action
controlled by this example.
</p>
</TaskDialog>
</div>
);
}
A modal contains one focused task above the current page. It has a visible title, an optional description and
close button, a body, and optional footer content and actions. The caller controls open and handles onOpenChange.
A close request does not discard a draft or confirm a save by itself.
Sizes and placement
| Option | Use |
|---|---|
size="sm" | A short confirmation or notice |
size="md" | A compact form; the default width |
size="lg" | A review with several related fields or a modest comparison |
size="xl" | A bounded figure or wider review that needs more room |
size="full" | The available viewport area below the app header, with an outer inset |
placement="top" | Anchor near the top when changing body length should not shift the title |
All sizes stay within the viewport. Full size is a dialog layout, not the browser's fullscreen mode; it respects the app-header offset. Width presets use em-based roles so they follow the presentation size class. Keep actions concise: the footer wraps at narrow widths rather than shrinking targets or forcing horizontal scrolling.
| Token | Value | rem | Preview |
|---|---|---|---|
--bk-modal-width-sm | 24em | — | — |
--bk-modal-width-md | 31.25em | — | — |
--bk-modal-width-lg | 48em | — | — |
--bk-modal-width-xl | 64em | — | — |
--bk-modal-viewport-gap | var(--space-lg) | — | — |
Dismissal policy
| Prop | Default | Effect |
|---|---|---|
closeLabel | Omitted | Supplying translated text enables and names the header close button |
closeOnEscape | true | Escape requests dismissal |
closeOnOutsideClick | true | A pointer press on the backdrop requests dismissal |
closeDisabled | false | Blocks those three dismissal paths during a pending operation |
onOpenChange(open, details) receives escape, outside or close-button. Existing callbacks with no argument remain
valid. Footer buttons belong to the caller: cancel and submit do not automatically call onOpenChange, and
closeDisabled does not disable them. Keep their pending state consistent with the rest of the task.
The default API remains controlled. Set open={false} only when the caller accepts dismissal. For a dirty form,
retain the dialog and offer Keep editing and Discard draft inside it. A press in the application's alert
zone does not dismiss the dialog; the shell owns keeping that zone reachable above the overlay.
Examples
Protected draft
Edit the title and request dismissal. The confirmation stays inside the current dialog and preserves the text.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { SectionMessage } from "@bosunkit/components/section-message";
import {
useRef,
useState,
type ChangeEvent,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"Edit the title, then try Escape, the backdrop or the close button. The caller retains the draft and asks for confirmation inside the same dialog.";
const original: string = "Weekly engineering update";
export default function ProtectedDraft(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState(original);
const [confirm, setConfirm]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const field: RefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null);
function requestClose(): void {
if (draft !== original) setConfirm(true);
else setOpen(false);
}
function keepEditing(): void {
setConfirm(false);
field.current?.focus();
}
return (
<>
<Button
onClick={(): void => {
setConfirm(false);
setOpen(true);
}}
>
Try a protected draft
</Button>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (requestClose)(); }}
title="Edit report title"
description="Changes in this example stay local."
closeLabel="Close title editor"
initialFocus={field}
actions={
confirm ? (
<>
<Button onClick={keepEditing}>Keep editing</Button>
<Button
variant="danger"
onClick={(): void => {
setDraft(original);
setConfirm(false);
setOpen(false);
}}
>
Discard draft
</Button>
</>
) : (
<Button onClick={requestClose}>Cancel</Button>
)
}
>
<div style={{ display: "grid", gap: "var(--space-lg)" }}>
<FormField label="Report title">
<Input
ref={field}
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.currentTarget.value);
setConfirm(false);
}}
/>
</FormField>
{confirm && (
<div role="status">
<SectionMessage kind="attention" title="Discard your changes?">
Your edited title has not been saved. Keep editing or discard this local draft.
</SectionMessage>
</div>
)}
</div>
</TaskDialog>
</>
);
}
Validation, saving and retry
Submit an empty title, try the simulated failure, then switch failure off and retry. Saving blocks automatic dismissal and footer actions. Failure keeps the draft; success closes the task and reports its result.
No draft saved in this example.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { SectionMessage } from "@bosunkit/components/section-message";
import { Switch } from "@bosunkit/components/switch";
import {
useActionState,
useId,
useRef,
useState,
type ChangeEvent,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"A simulated save with a required title, pending feedback and a retryable failure. Dismissal is blocked only while saving; a failure leaves the draft available.";
interface SaveResult {
error?: string;
fieldError?: string;
}
function Editor({ onClose, onSaved }: { onClose: () => void; onSaved: (title: string) => void }): ReactElement {
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState("");
const [fail, setFail]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(true);
const input: RefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null);
const formId: string = useId();
const [result, saveAction, pending]: [SaveResult, (data: FormData) => void, boolean] = useActionState<
SaveResult,
FormData
>(async (_previous: SaveResult, data: FormData): Promise<SaveResult> => {
const submitted: FormDataEntryValue | null = data.get("title");
const title: string = typeof submitted === "string" ? submitted.trim() : "";
if (title === "") {
input.current?.focus();
return { fieldError: "Enter a report title." };
}
await new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, 900);
});
if (fail)
return {
error: "The simulated save failed. Your title is kept here. Turn off the failure switch and try again.",
};
onSaved(title);
return {};
}, {});
return (
<TaskDialog
open
onOpenChange={(open): void => { if (!open) (onClose)(); }}
title="Save report draft"
description="A local simulation of a form action; no server request is made."
closeLabel="Close draft form"
closeOnOutsideClick={false}
closeDisabled={pending}
initialFocus={input}
footer={<span role="status">{pending ? "Saving the example…" : "Local example only"}</span>}
actions={
<>
<Button disabled={pending} onClick={onClose}>
Cancel
</Button>
<Button variant="primary" type="submit" form={formId} disabled={pending}>
{pending ? "Saving…" : "Save draft"}
</Button>
</>
}
>
<form id={formId} action={saveAction} aria-busy={pending} style={{ display: "grid", gap: "var(--space-lg)" }}>
<FormField label="Draft title" required error={result.fieldError}>
<Input
name="title"
ref={input}
value={draft}
disabled={pending}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.currentTarget.value);
}}
/>
</FormField>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch checked={fail} onCheckedChange={setFail} disabled={pending} />
Simulate a save failure
</label>
{result.error !== undefined && (
<div role="alert">
<SectionMessage kind="error" title="Draft was not saved">
{result.error}
</SectionMessage>
</div>
)}
</form>
</TaskDialog>
);
}
export default function SaveFeedback(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const [saved, setSaved]: [string, Dispatch<SetStateAction<string>>] = useState("");
return (
<div style={{ display: "grid", gap: "var(--space-md)" }}>
<div>
<Button
onClick={(): void => {
setOpen(true);
}}
>
Try save and retry
</Button>
</div>
<p role="status">{saved === "" ? "No draft saved in this example." : `Saved locally: ${saved}`}</p>
{open && (
<Editor
onClose={(): void => {
setOpen(false);
}}
onSaved={(title: string): void => {
setSaved(title);
setOpen(false);
}}
/>
)}
</div>
);
}
Long content and scroll behavior
scrollBehavior="body" keeps the header and footer visible while the body scrolls. "dialog" scrolls them with
the content. Prefer short tasks; a lengthy report belongs on its own page even when a review dialog links to it.
Code
import { Button } from "@bosunkit/components/button";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { Switch } from "@bosunkit/components/switch";
import { useRef, useState, type Dispatch, type ReactElement, type RefObject, type SetStateAction } from "react";
export const description: string =
"A long review with a persistent header and footer. Switch to whole-dialog scrolling to compare. Initial focus goes to the introduction, so opening does not jump to the bottom.";
const sections: readonly string[] = [
"Purpose",
"Audience",
"Scope",
"Sources",
"Method",
"Findings",
"Limitations",
"Decisions",
"Next steps",
"References",
];
export default function LongContent(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const [whole, setWhole]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
const intro: RefObject<HTMLParagraphElement | null> = useRef<HTMLParagraphElement | null>(null);
return (
<div style={{ display: "grid", gap: "var(--space-md)" }}>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch checked={whole} onCheckedChange={setWhole} />
Scroll the whole dialog
</label>
<div>
<Button
onClick={(): void => {
setOpen(true);
}}
>
Open long review
</Button>
</div>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) ((): void => {
setOpen(false);
})(); }}
title="Report review checklist"
description="Review each section before sharing your draft."
size="lg"
closeLabel="Close review checklist"
scrollBehavior={whole ? "dialog" : "body"}
initialFocus={intro}
footer="Checklist example · no report is published"
actions={
<Button
onClick={(): void => {
setOpen(false);
}}
>
Finish review
</Button>
}
>
<div style={{ display: "grid", gap: "var(--space-lg)" }}>
<p ref={intro} tabIndex={-1}>
This is a reading task. Focus starts here, while the complete document remains available in its own page.
</p>
{sections.map((section: string): ReactElement => (
<section key={section}>
<h3>{section}</h3>
<p>
Make this section understandable to its intended reader. Keep the supporting evidence close to the
conclusion, identify any uncertainty, and link to the source material.
</p>
</section>
))}
</div>
</TaskDialog>
</div>
);
}
Multiple actions
The caller can compose more than one outcome. Put the primary action last and describe each outcome explicitly.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { Reading } from "@bosunkit/components/reading";
import { CircleStop, Info, Navigation } from "lucide-react";
import {
useId,
useState,
type ChangeEvent,
type Dispatch,
type ReactElement,
type SetStateAction,
type SubmitEvent,
} from "react";
export const description: string =
"Two dialogs from their triggers: ending a passage early, with an icon in the title, the passage's figures, a " +
"required log note and three actions, and a notice with no actions that Escape or a press outside closes.";
export default function Showcase(): ReactElement {
const [ending, setEnding]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [explaining, setExplaining]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [note, setNote]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [missingNote, setMissingNote]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [ended, setEnded]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<
string | undefined
>(undefined);
const [paused, setPaused]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const formId: string = useId();
function closeEnding(): void {
setEnding(false);
setMissingNote(false);
}
function endPassage(event: SubmitEvent<HTMLFormElement>): void {
event.preventDefault();
if (note.trim() === "") {
setMissingNote(true);
return;
}
setEnded(note.trim());
setPaused(false);
closeEnding();
}
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)" }}>
<Button
variant="danger"
disabled={ended !== undefined}
onClick={(): void => {
setEnding(true);
}}
>
<CircleStop size={14} aria-hidden="true" />
End passage early
</Button>
<Button
variant="ghost"
onClick={(): void => {
setExplaining(true);
}}
>
<Info size={14} aria-hidden="true" />
What ending a passage keeps
</Button>
{paused && ended === undefined && <Badge variant="dim">Passage paused</Badge>}
{ended !== undefined && <Badge variant="amber">Ended off Ushant: {ended}</Badge>}
<TaskDialog
open={ending}
onOpenChange={(open): void => { if (!open) (closeEnding)(); }}
title={
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<Navigation size={18} aria-hidden="true" />
End the passage to A Coruña?
</span>
}
actions={
<>
<Button variant="ghost" onClick={closeEnding}>
Keep sailing
</Button>
<Button
onClick={(): void => {
setPaused(true);
closeEnding();
}}
>
Pause the passage
</Button>
<Button variant="danger" type="submit" form={formId}>
End passage
</Button>
</>
}
>
<form id={formId} onSubmit={endPassage} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.5 }}>
SV Althea is 98 nm into the 412 nm passage, off Ushant. Ending it stops the route guidance and the arrival
estimate; the track, the log and the watch rota stay in the logbook.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
<Reading value={98} unit="nm" label="Sailed" source="Log" />
<Reading value={314} unit="nm" label="Remaining" source="Route" />
<Reading value={14.4} unit="h" label="Underway" source="Log" fractionDigits={1} />
</div>
<FormField
label="Log note (required)"
error={
missingNote
? "The log needs a reason for ending the passage. Write one line, such as the forecast or a crew need."
: undefined
}
>
<Input
aria-label="Log note"
required
value={note}
placeholder="Why the passage ends here"
aria-invalid={missingNote}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setNote(event.target.value);
setMissingNote(false);
}}
/>
</FormField>
</form>
</TaskDialog>
<TaskDialog
open={explaining}
onOpenChange={(open): void => { if (!open) ((): void => {
setExplaining(false);
})(); }}
title="What ending a passage keeps"
>
<div style={{ display: "flex", flexDirection: "column", gap: 10, fontSize: 14, lineHeight: 1.5 }}>
<span>
The track, every log entry and the watch rota stay in the logbook, so the passage report covers the miles
sailed.
</span>
<span>
Route guidance, the arrival estimate and the tidal-gate reminders stop. Start a new passage from the present
position to get them back. Press Escape or anywhere outside this box to close it.
</span>
</div>
</TaskDialog>
</div>
);
}
Confirm
Code
import { Button } from "@bosunkit/components/button";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A confirmation before a destructive action. Cancel is the quiet button and the destructive one is a danger button.";
export default function Confirm(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function close(): void {
setOpen(false);
}
return (
<>
<Button
variant="primary"
onClick={(): void => {
setOpen(true);
}}
>
Clear alerts
</Button>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (close)(); }}
title="Clear all acknowledged alerts?"
actions={
<>
<Button onClick={close}>Cancel</Button>
<Button variant="danger" onClick={close}>
Clear alerts
</Button>
</>
}
>
The 12 acknowledged alerts leave the alert list. Alerts you have not acknowledged stay in the list.
</TaskDialog>
</>
);
}
Edit crew member
Code
import { Button } from "@bosunkit/components/button";
import { Input } from "@bosunkit/components/input";
import { Label } from "@bosunkit/components/label";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { useId, useState, type Dispatch, type SubmitEvent, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A form in a dialog. Save is a submit button outside the form, tied to it by the form attribute, so Enter also saves.";
export default function EditCrewMember(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const formId: string = useId();
const nameId: string = useId();
const roleId: string = useId();
const phoneId: string = useId();
function close(): void {
setOpen(false);
}
function save(event: SubmitEvent<HTMLFormElement>): void {
event.preventDefault();
close();
}
return (
<>
<Button
variant="primary"
onClick={(): void => {
setOpen(true);
}}
>
Edit crew member
</Button>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (close)(); }}
title="Edit Marta Lindqvist"
description="Update the contact details used in the crew directory."
closeLabel="Close crew editor"
closeOnOutsideClick={false}
actions={
<>
<Button onClick={close}>Cancel</Button>
<Button variant="primary" type="submit" form={formId}>
Save
</Button>
</>
}
>
<form id={formId} onSubmit={save} style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
<div>
<Label htmlFor={nameId}>Name</Label>
<Input id={nameId} defaultValue="Marta Lindqvist" autoComplete="off" />
</div>
<div>
<Label htmlFor={roleId}>Role</Label>
<Input id={roleId} defaultValue="First mate" autoComplete="off" />
</div>
<div>
<Label htmlFor={phoneId}>Mobile number</Label>
<Input id={phoneId} type="tel" defaultValue="+46 70 123 45 67" autoComplete="off" />
</div>
</form>
</TaskDialog>
</>
);
}
Notice
Code
import { Button } from "@bosunkit/components/button";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"An informational dialog with one action. Close, Escape and a press outside all dismiss it.";
export default function Notice(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function close(): void {
setOpen(false);
}
return (
<>
<Button
variant="primary"
onClick={(): void => {
setOpen(true);
}}
>
Forecast details
</Button>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (close)(); }}
title="The forecast is 3 hours old"
actions={
<Button variant="primary" onClick={close}>
Close
</Button>
}
>
The vessel has had no connection for 3 hours, so the forecast shows the last update. It refreshes when the
connection returns.
</TaskDialog>
</>
);
}
Motion
A modal moves by default: the overlay fades in and the dialog scales up from slightly smaller over the medium
duration, and both leave over the fast one. The title and the body are in the page, and read out, from the first
frame. Under reduced motion the dialog only fades. motion={false} opens and closes it at once. The durations and
curves are the motion tokens.
Code
import { Button } from "@bosunkit/components/button";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The same dialog with its motion and with `motion={false}`: the first scales in over a fading overlay and leaves " +
"faster than it came, the second opens and closes at once.";
function Opener({ motion, label }: { motion: boolean; label: string }): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function close(): void {
setOpen(false);
}
return (
<>
<Button
onClick={(): void => {
setOpen(true);
}}
>
{label}
</Button>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (close)(); }}
title="Engine hours logged"
motion={motion}
actions={
<Button variant="primary" onClick={close}>
Close
</Button>
}
>
1,842 engine hours are in the log. The next oil change is due at 1,900.
</TaskDialog>
</>
);
}
export default function Motion(): ReactElement {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
<Opener motion label="With motion" />
<Opener motion={false} label="Without motion" />
</div>
);
}
Focus, descriptions and forms
The title provides the dialog's accessible name. description supplies a short introduction and its accessible
description; keep long structured content in the body. closeLabel names the icon button and its tooltip.
Icon artwork can be included in title when it is decorative beside the heading text.
By default, Base UI selects an initial focus target. Use initialFocus for a field, a safe cancel action or a
focusable introduction (tabIndex={-1}) in a long reading task. The target must be inside the dialog. Tab and
Shift+Tab stay within the modal's focus loop. On close, focus returns to the element focused before opening;
finalFocus provides another destination if the original trigger disappears.
Put the body inputs in a form with a stable id. A footer submit button uses type="submit" and form={id}
to participate in that form. Enter, field validation and the React Action then share one path. The save example
uses useActionState; Modal itself performs no requests and owns no draft or persistence.
footer holds supporting information before the action group. Use it for a short save state or help link, not
for essential field errors. Keep error messages beside the affected field or inside the body. When there are no
actions, provide closeLabel so a touch user has a visible way out.
Themes and integration
The dialog and backdrop carry the trigger's presentation theme, size class and language through the portal.
Use shared surface, type, spacing, shadow and modal-width roles. The component stays below the application's
alert layer and never enters the browser's top layer. ref addresses the dialog element, and testId also names
its --overlay, --body and --close parts.
Check narrow and short windows, zoom, translated titles, the full keyboard loop and reduced motion. Save errors, close requests and source updates must not silently erase a user's draft. Prefer one modal task at a time.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | undefined | – | Whether the dialog is showing; the caller owns its open state. |
| defaultOpen | boolean | undefined | – | Initial visibility when uncontrolled. |
| onOpenChange | BaseDialog.Root.Props["onOpenChange"] | – | Requests dismissal with its cause. Keep open true to retain a draft or ask for confirmation. |
| titlerequired | ReactNode | – | The visible heading, which also names the dialog. |
| description | ReactNode | undefined | – | A short introduction below the title, associated as the dialog's accessible description. |
| childrenrequired | ReactNode | – | The dialog's content. |
| actions | ReactNode | undefined | – | The buttons at the end of the footer; the caller controls submit, cancel and pending behavior. |
| footer | ReactNode | undefined | – | Supporting footer content before the actions, such as a saving state or a help link. |
| size | TaskDialogSize | undefined | "md" | Width preset: md by default; full fills the available width and height below the app header. |
| placement | "center" | "top" | undefined | "center" | Centered by default, or aligned near the top below the app header. Full size always occupies that area. |
| scrollBehavior | "body" | "dialog" | undefined | "body" | body keeps the header/footer visible; dialog scrolls the entire dialog. Defaults to body. |
| closeLabel | string | undefined | – | Enables the header close button and supplies its accessible name and tooltip, translated by the caller. |
| closeOnEscape | boolean | undefined | true | Allow Escape to request dismissal; true by default. |
| closeOnOutsideClick | boolean | undefined | true | Allow a pointer press on the backdrop to request dismissal; true by default. |
| closeDisabled | boolean | undefined | false | Temporarily blocks Escape, backdrop and header-close requests. Footer actions remain caller-owned. |
| initialFocus | BaseDialog.Popup.Props["initialFocus"] | – | Focus this element on opening; it must be focusable and inside the dialog. Otherwise the first focusable element is chosen. |
| finalFocus | BaseDialog.Popup.Props["finalFocus"] | – | Focus this element on closing. Defaults to the element focused before opening, when it remains connected. |
| className | string | undefined | – | Classes added to the dialog's own. |
| testId | string | undefined | – | data-testid on the dialog; parts use --overlay, --body and --close. |
| ref | Ref<HTMLDivElement> | undefined | – | The portaled dialog element. |
| motion | boolean | undefined | true | Optional entrance/exit motion. Reduced motion uses fading only; false disables transitions. |
Related
- Elevation: the surface and shadow a dialog sits on.
- Alarms and severity: the alert zone a dialog leaves in reach.
- Button: the buttons an actions row holds.
- Form field: labels, validation and described fields.
Implementation reference: Base UI Dialog. Usage reference: Atlassian modal dialog.