Skip to content
design
Components

Task dialog

A dialog with a title, a body and optional actions, shown over the page.

On this page

No close request yet.

Configure the size, placement and dismissal behavior, then open the dialog. Close requests report their reason.
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.

01Fit the taskUse a small dialog for a short decision, a wider one for a bounded review, and a page for an extended workflow.
02Keep an exit clearProvide a named close or cancel action. Disable dismissal only for a specific, explained operation, and restore it when that operation completes.
03Preserve contextMove focus deliberately, return it when the task closes, and keep validation and unsaved changes inside the same task.

Sizes and placement

OptionUse
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.

TokenValueremPreview
--bk-modal-width-sm24em——
--bk-modal-width-md31.25em——
--bk-modal-width-lg48em——
--bk-modal-width-xl64em——
--bk-modal-viewport-gapvar(--space-lg)——

Dismissal policy

PropDefaultEffect
closeLabelOmittedSupplying translated text enables and names the header close button
closeOnEscapetrueEscape requests dismissal
closeOnOutsideClicktrueA pointer press on the backdrop requests dismissal
closeDisabledfalseBlocks 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.

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.
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.

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.
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.

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.
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.

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.
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

A confirmation before a destructive action. Cancel is the quiet button and the destructive one is a danger button.
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

A form in a dialog. Save is a submit button outside the form, tied to it by the form attribute, so Enter also saves.
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

An informational dialog with one action. Close, Escape and a press outside all dismiss it.
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.

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.
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

PropTypeDefaultDescription
openboolean | undefined–Whether the dialog is showing; the caller owns its open state.
defaultOpenboolean | undefined–Initial visibility when uncontrolled.
onOpenChangeBaseDialog.Root.Props["onOpenChange"]–Requests dismissal with its cause. Keep open true to retain a draft or ask for confirmation.
titlerequiredReactNode–The visible heading, which also names the dialog.
descriptionReactNode | undefined–A short introduction below the title, associated as the dialog's accessible description.
childrenrequiredReactNode–The dialog's content.
actionsReactNode | undefined–The buttons at the end of the footer; the caller controls submit, cancel and pending behavior.
footerReactNode | undefined–Supporting footer content before the actions, such as a saving state or a help link.
sizeTaskDialogSize | 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.
closeLabelstring | undefined–Enables the header close button and supplies its accessible name and tooltip, translated by the caller.
closeOnEscapeboolean | undefinedtrueAllow Escape to request dismissal; true by default.
closeOnOutsideClickboolean | undefinedtrueAllow a pointer press on the backdrop to request dismissal; true by default.
closeDisabledboolean | undefinedfalseTemporarily blocks Escape, backdrop and header-close requests. Footer actions remain caller-owned.
initialFocusBaseDialog.Popup.Props["initialFocus"]–Focus this element on opening; it must be focusable and inside the dialog. Otherwise the first focusable element is chosen.
finalFocusBaseDialog.Popup.Props["finalFocus"]–Focus this element on closing. Defaults to the element focused before opening, when it remains connected.
classNamestring | undefined–Classes added to the dialog's own.
testIdstring | undefined–data-testid on the dialog; parts use --overlay, --body and --close.
refRef<HTMLDivElement> | undefined–The portaled dialog element.
motionboolean | undefinedtrueOptional entrance/exit motion. Reduced motion uses fading only; false disables transitions.
Do
Name each action by what it does, so the crew can answer from the buttons alone.
Don't
Don't answer a question with Yes and No. A crew member who reads only the buttons cannot tell which one resigns.

Implementation reference: Base UI Dialog. Usage reference: Atlassian modal dialog.