OperantAI Workflow Design System
ComfortableCompact

Modal (.ods-modal) is the answer to AGENTS.md's decision-table row "Blocking confirm (irreversible)" — reserve it for destructive confirmations and short, focused tasks. For non-blocking detail, use .ods-drawer/.ods-context instead; a modal that isn't blocking an irreversible action is usually the wrong component. It's built on @radix-ui/react-dialog, so focus-trap, Escape-to-close, and focus-restore-on-close (AGENTS.md #8) come from Radix rather than being hand-rolled.

Installation

HTML — already included if you loaded the 3 stylesheets. See the Installation guide. Note: the behavior that makes a modal a modal — focus trapping, Escape-to-close, focus restoration, scroll locking, the overlay click-outside-to-close — is genuinely JS (Radix Dialog in the React layer). The markup below shows the DOM shape and classes for visual reference; wire up equivalent JS (or use the React component) for real behavior.

React

npm install @operant/ui

@operant/ui is currently a monorepo workspace package, not yet published to the public npm registry.

Usage

HTML (static markup shape)

<div class="ods-overlay">
  <div class="ods-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
    <div class="ods-modal-header">
      <h2 class="ods-modal-title" id="modal-title">Delete this run?</h2>
      <button type="button" class="ods-iconbtn" aria-label="Close">×</button>
    </div>
    <div class="ods-modal-body">
      This permanently deletes the run, its trace, and every approval attached to it. This action
      cannot be undone.
    </div>
    <div class="ods-modal-footer">
      <button type="button" class="ods-btn -secondary">Cancel</button>
      <button type="button" class="ods-btn -danger">Delete permanently</button>
    </div>
  </div>
</div>

React

ModalTrigger/ModalClose don't support asChild, so they render their own <button> and can't safely wrap another interactive component like this package's Button (that would nest a <button> inside a <button>). To use real Buttons for Cancel/Confirm, run the dialog controlled and drive it from plain Button onClick handlers instead of ModalClose:

import { useState } from "react";
import { X } from "react-bootstrap-icons";
import {
  Button,
  Modal,
  ModalBody,
  ModalClose,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
} from "@operant/ui";

export default function Example() {
  const [open, setOpen] = useState(false);
  return (
    <Modal open={open} onOpenChange={setOpen}>
      <ModalTrigger className="ods-btn -danger">Delete run</ModalTrigger>
      <ModalContent>
        <ModalHeader className="between">
          <ModalTitle>Delete this run?</ModalTitle>
          <ModalClose className="ods-iconbtn" aria-label="Close">
            <X />
          </ModalClose>
        </ModalHeader>
        <ModalBody>
          This permanently deletes the run, its trace, and every approval attached to it. This
          action cannot be undone.
        </ModalBody>
        <ModalFooter>
          <Button variant="secondary" onClick={() => setOpen(false)}>
            Cancel
          </Button>
          <Button variant="danger" onClick={() => setOpen(false)}>
            Delete permanently
          </Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
}

Examples

Destructive confirm

The primary use case: a single irreversible action, gated behind an explicit confirm.

<div class="ods-overlay">
  <div class="ods-modal" role="dialog" aria-modal="true" aria-labelledby="delete-run-title">
    <div class="ods-modal-header">
      <h2 class="ods-modal-title" id="delete-run-title">Delete this run?</h2>
      <button type="button" class="ods-iconbtn" aria-label="Close">×</button>
    </div>
    <div class="ods-modal-body">
      This permanently deletes the run, its trace, and every approval attached to it. This action
      cannot be undone.
    </div>
    <div class="ods-modal-footer">
      <button type="button" class="ods-btn -secondary">Cancel</button>
      <button type="button" class="ods-btn -danger">Delete permanently</button>
    </div>
  </div>
</div>
<Modal open={open} onOpenChange={setOpen}>
  <ModalTrigger className="ods-btn -danger">Delete run</ModalTrigger>
  <ModalContent>
    <ModalHeader className="between">
      <ModalTitle>Delete this run?</ModalTitle>
      <ModalClose className="ods-iconbtn" aria-label="Close">
        <X />
      </ModalClose>
    </ModalHeader>
    <ModalBody>
      This permanently deletes the run, its trace, and every approval attached to it. This action
      cannot be undone.
    </ModalBody>
    <ModalFooter>
      <Button variant="secondary" onClick={() => setOpen(false)}>
        Cancel
      </Button>
      <Button variant="danger" onClick={() => setOpen(false)}>
        Delete permanently
      </Button>
    </ModalFooter>
  </ModalContent>
</Modal>;

Header-only close affordance

The header's close control is a non-interactive icon (<X />) inside ModalClose's own <button> — .ods-iconbtn already sizes/colors a descendant svg, so no extra styling is needed, and nesting the icon (not another interactive component) inside ModalClose stays valid HTML.

<div class="ods-modal-header">
  <h2 class="ods-modal-title">Delete this run?</h2>
  <button type="button" class="ods-iconbtn" aria-label="Close">
    <svg aria-hidden="true" viewBox="0 0 16 16"><!-- x icon --></svg>
  </button>
</div>
<ModalHeader className="between">
  <ModalTitle>Delete this run?</ModalTitle>
  <ModalClose className="ods-iconbtn" aria-label="Close">
    <X />
  </ModalClose>
</ModalHeader>

API Reference

Components

ComponentWrapsClassNotes
ModalDialog.Root—Root state container. Props: open, onOpenChange, defaultOpen, modal.
ModalTriggerDialog.Trigger—Pass-through, no asChild. Style directly, e.g. className="ods-btn -danger". Don't nest another interactive component inside it.
ModalContentDialog.Portal + Dialog.Overlay + Dialog.Content.ods-overlay (overlay) + .ods-modal (content)Renders ModalHeader/ModalTitle/ModalBody/ModalFooter as children.
ModalHeader<div>.ods-modal-header—
ModalTitleDialog.Title.ods-modal-titleRadix auto-wires this to ModalContent via aria-labelledby. Always render one inside ModalHeader.
ModalBody<div>.ods-modal-body—
ModalFooter<div>.ods-modal-footerFlex row, right-aligned — typically Cancel + a confirm Button.
ModalCloseDialog.Close—Pass-through, no asChild, same nesting caveat as ModalTrigger.

All props on every component above are forwarded straight through to the corresponding Radix Dialog primitive.

HTML-side modifiers: .ods-overlay (fixed backdrop) + .ods-modal (the panel: max-width: 520px, --shadow-lg), .ods-modal-header/.ods-modal-body/.ods-modal-footer. No variant modifiers — one visual treatment for all modals.

Accessibility

  • Radix Dialog traps focus inside the modal while open and restores focus to the trigger on close (AGENTS.md #8: "trap focus in modals and restore on close") — this is the primary reason the component is built on Radix rather than a plain <div>.
  • Escape closes the dialog; clicking the .ods-overlay backdrop closes it too — both are Radix defaults, not something this wrapper adds.
  • ModalTitle renders Radix's Dialog.Title, which Radix automatically links to the dialog surface via aria-labelledby — always render exactly one inside ModalHeader so the dialog has an accessible name.
  • ModalTrigger/ModalClose render their own real <button> (no asChild); nesting another interactive element inside either produces invalid nested-button markup — use open/onOpenChange with plain Button onClick handlers instead when you need styled action buttons in the footer (see Usage above).
  • Reserve Modal for blocking, irreversible-confirm scenarios per the AGENTS.md decision table — using it for non-blocking detail view is an anti-pattern; use .ods-drawer/.ods-context there instead.