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
| Component | Wraps | Class | Notes |
|---|---|---|---|
Modal | Dialog.Root | — | Root state container. Props: open, onOpenChange, defaultOpen, modal. |
ModalTrigger | Dialog.Trigger | — | Pass-through, no asChild. Style directly, e.g. className="ods-btn -danger". Don't nest another interactive component inside it. |
ModalContent | Dialog.Portal + Dialog.Overlay + Dialog.Content | .ods-overlay (overlay) + .ods-modal (content) | Renders ModalHeader/ModalTitle/ModalBody/ModalFooter as children. |
ModalHeader | <div> | .ods-modal-header | — |
ModalTitle | Dialog.Title | .ods-modal-title | Radix auto-wires this to ModalContent via aria-labelledby. Always render one inside ModalHeader. |
ModalBody | <div> | .ods-modal-body | — |
ModalFooter | <div> | .ods-modal-footer | Flex row, right-aligned — typically Cancel + a confirm Button. |
ModalClose | Dialog.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>. Escapecloses the dialog; clicking the.ods-overlaybackdrop closes it too — both are Radix defaults, not something this wrapper adds.ModalTitlerenders Radix'sDialog.Title, which Radix automatically links to the dialog surface viaaria-labelledby— always render exactly one insideModalHeaderso the dialog has an accessible name.ModalTrigger/ModalCloserender their own real<button>(noasChild); nesting another interactive element inside either produces invalid nested-button markup — useopen/onOpenChangewith plainButton onClickhandlers instead when you need styled action buttons in the footer (see Usage above).- Reserve
Modalfor 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-contextthere instead.