Agent docs

Modal

The dialog shell for a detail that is the task; opens by id from anywhere, closes on Escape, backdrop or its own button, with ConfirmModal as the yes/no gate.

The dialog shell for a detail that IS the task. It obscures the page, opens from anywhere in the tree by id, and closes on ESC, backdrop click, or its own close button because it wraps a real <dialog> element.

When to reach for it#

Use Modal when the detail is the task and hiding the rest of the page is fine: an edit form, a confirm gate, a record preview opened from a table row. Speedway's parts list retired its row-click side panel entirely in favor of a modal opened from an Eye icon (speedway/app/routes/workspaces/jobs/parts.tsx:196, comment: "The Eye icon is the one way into a part's detail now... The row-click side panel is gone").

It does not serve two adjacent cases, and the canon's decision tree at method/design-language/10-overlays.md §A1 is the authority on the split.

  • Peek panel, not Modal, when the surrounding context must stay visible (a run's log beside a part detail). A peek is an in-flow column, not an overlay.
  • Full page, not Modal, only when the surface is a destination in its own right. Speedway retired its part page because "it duplicated the parts table's preview dialog."

A surface may deviate from this tree only when the deviation buys better and more consistent UX, and it records its own reasoning for doing so (owner ruling D1, 10-overlays.md §A1).

Contract#

Modal is the shell (modal.tsx). It renders children only when its elementId (and modalKey, for one of many instances like a per row edit dialog) matches what useModal() has open globally. Nothing else needs a portal, since native <dialog> gives backdrop, ESC, and top layer stacking for free.

  • elementId (ModalId = string) is required. The kit ships no ModalId enum. Each app owns its own id space so one app's modals never collide with another's namespace (modal.types.ts:6-11).
  • modalKey distinguishes instances sharing an elementId, for example one edit row modal rendered per table row. Two mounted instances with the same id and key trigger a dev only console warning (modal-store.ts:129-147).
  • size is "xs" | "sm" | "md" | "lg" | "xl", default "md". Fixed pixel presets (475, 700, 1000, 1400, full), every one capped at 90vw so nothing overflows a narrow viewport. Pick the smallest that fits the job.
  • onClose fires on any native close (ESC, backdrop, header close button), not on a programmatic closeModal() call site.
  • The modal-box publishes its 1.5rem side padding as --card-inset, the variable a Card body publishes, so a Table in a modal body bleeds to the dialog's edges with a hairline above it (pass bleed={false} to keep the inset).
  • className styles the inner modal-box. dialogClassName styles the <dialog> itself.
  • aria-label / aria-labelledby name the dialog. A string ModalTitle auto-labels it via a stable id (a ModalTitle context), so pass these only for a custom-node title or to override; explicit caller ARIA wins over the auto label (canon 15-accessibility.md).

useModal() is callable from anywhere in the tree, not just inside the modal, and hands back { modalId, modalKey, modalArgs, openModal, closeModal }. openModal(id, key?, args) is how a row passes typed data into the dialog that will render elsewhere.

ModalTitle and ModalFooter (modal-elements.tsx) are the standard chrome.

ModalTitle takes title, an optional Icon, and as of kit 0.0.23 a record header anatomy: identifier (renders mono, doubles as a copy button), subtitle (the human readable name under the title, subtitleTooltip? overriding its truncated tip text), and onIdentifierCopied(value) for the toast that fires after a successful copy. Passing none of the three leaves a plain single line title untouched. A string title or subtitle truncates behind a Tooltip carrying the full text (canon 12 §A11), since either can run longer than the header's width. The anatomy is canon (10-overlays.md §A9): "an identity glyph, then a column of a primary line above a secondary line." ModalTitleProps also carries toolbar (a right-edge slot beside the close button), hideClose, divider, iconSize, and the className/titleClassName slots (modal.types.ts:44-69).

Adoption, measured 2026-09-04: the structured identifier/subtitle props and onIdentifierCopied appear only in the kit's own gallery (apps/playground/src/app/components/modal/examples/record-anatomy.tsx:14-17). Walmart's record modal (PartPreviewModal.tsx:831-848) predates the anatomy and hand rolls the same pattern with a separate CopyButton. Read the canon's sweep as the target rather than the shipped state until that call site moves.

ModalFooter takes back/next (each a ModalFooterButton with content, Icon, onClick, loading, disabled, tooltip, color, variant, shade), spread to push the pair to opposite ends, and loading to disable both and drive the primary's spinner. FooterButton renders the kit Button, not a raw <button>; both buttons default to variant="ghost", "the primary action is text emphasis only" (10-overlays.md §A4), and next defaults to color="primary". A caller opts a button into a stronger tone with color/variant/shade per button, the same way ConfirmModal's delete preset now colours its own confirm action.

ModalTitle draws a divider under itself by default, so a scrolling body always has an edge to pass under; divider={false} removes it. ModalFooter draws none by default; divider opts it in. hideBorder is accepted for one release as divider's inverse on both. Add the footer divider when the body's last field or row does not already close the box visually. The dialog box carries no bottom padding of its own: a trailing spacer ends the scroll content, so overflow runs to the dialog's edge and reads as clipped by it, not as coming from behind a padded strip. With a ModalFooter present the footer owns the bottom space and the spacer is dropped.

ModalBody (modal-elements.tsx) is the standard body wrapper: padded (default true) supplies the py-4 and inter-child gap-4 a bare <div> between title and footer used to hand roll at every call site, and scroll (a CSS length, e.g. "320px") caps the body's own height and lets it scroll independently of a page that has more content above and below it. ModalSection divides a body into labelled parts: label renders through Kicker, count is an optional right-aligned hint ("2 vehicles"), and a border-t separates each section from the one before it. Reach for ModalSection over a hand rolled <div className="border-t pt-4">, and never nest a Card inside either (Banned combinations below).

Both header and footer stick to their edge while the body between them scrolls (modal-elements.tsx:33, modal-elements.tsx:136). The footer, not modal-box, owns the bottom inset: modal-box carries [&:has([data-modal-footer])]:pb-0 (modal.tsx:52) so its own bottom padding drops out the moment a ModalFooter is present (data-modal-footer at modal-elements.tsx:131), and the footer's own pb-4 (modal-elements.tsx:136) becomes the only bottom padding in the box, sitting flush against the dialog edge instead of stacking on top of a second one. Both behaviours were ruled together, and the measurement is the rule: modal-box pads 24px on both sides, and the footer's own pb-4 is the only bottom padding when a footer is present. The reasoning and the earlier asymmetry it corrected are recorded in the versable-builder repo at docs/plan/48-showcase-feedback.md, B13, which the published package does not carry.

Both are opaque by default, bg-base-100, unconditional (modal-elements.tsx:33, modal-elements.tsx:136). 10-overlays.md §A14 records a translucent variant as an opt-in preset for a sticky header or footer floating over a scrolling body, gated on the body's own scroll position rather than the page's: the same recipe as the navbar's translucent bar (packages/ui/docs/app-shell.md, 08-navigation-and-shell.md §B), restated for an overlay. Neither ModalTitle nor ModalFooter exposes it as a prop today; a caller who wants it composes the classes directly rather than reaching for a kit option that does not yet exist.

FormModal<Args> (form-modal.tsx) is the form counterpart of ConfirmModal: a title, your fields as children, and a footer of Cancel and one primary submit (success, text, shade, bottom right, the ruled form-submit look). checks takes the same priority-ordered [condition, reason] list useDisabledReason takes; the first failing check bricks the submit and becomes its tooltip, so a grey Create always says what is missing. Enter submits through a real <form>, a refused submit does nothing. onSubmit(args, closeModal) may return a promise: while it runs the submit shows pending, Cancel is off, and every kit field inside freezes with the reason "Running" (submitting freezes it from outside for a submit you run yourself). The dialog does not close on its own after onSubmit; call closeModal when it succeeds so a failure keeps the form open. Selects inside need noPortal. A hand-built form wraps its fields in <FormFreeze submitting={pending}> for the same freeze.

ConfirmModal<Args> (confirm-modal.tsx) is the componentized yes/no gate and stays that: no fields, no children, no disabled reason. A dialog that collects input is a FormModal. preset="delete" styles it destructive: an error border, a trash icon, a solid error-coloured confirm button named for the verb and the thing, and the line "This action cannot be undone." under the message. Give it a title shaped "Delete job?" and the button reads "Delete job"; any other title gives the bare "Delete". confirmLabel sets the label outright, nextProps.content still wins over both, and irreversible={false} drops the line (it is also available as irreversible on a non-delete confirm that cannot be reversed). A message that already says "cannot be undone" is not repeated. A confirm with no title shows "Are you sure?" and warns once in development: name the action instead. Icon sets the title's icon for any confirm. It defaults to the trash on the delete preset and to none otherwise. A non-delete confirm that wants the icon-led title passes Icon="Refresh" (the console's rotate-signing-key modal, gcp row #492 K2); the Banned section below rules out hand-rolling a raw confirm to get there. message is a string, JSX, or a function of the typed Args passed to openModal. beforeCloseCallback(isConfirm) runs before either button closes the dialog. Return { abort: true } to keep it open, which is the unsaved changes guard. onConfirm is not auto awaited then closed, so a handler can keep the modal open on failure. ConfirmModalId is the default id for the common single confirm dialog per app case. No real call site in either app relies on this default; every one passes its own elementId. The remaining knobs, undocumented until the plan-45 parity audit: nextProps (the confirm Button's props, the mechanism behind the relabel row below), backProps (the cancel Button's), markdown (renders message through the same markdown-lite that backs Alert, confirm-modal.tsx:31-32,72-77), size, loading, onCancel, modalKey, and the className/titleClassName/messageClassName slots.

Deferred: beforeCloseCallback has no shipped usage in either app as of this sweep. Message markdown parsing from the pre kit app is intentionally not ported. message takes string, JSX, or a function, never a markdown string.

Laws it enforces#

  • Every action answers. A FormModal submit explains why it is off, shows pending while it runs, and freezes the fields it is saving.
  • Machine values stay exact, human text stays human. The delete confirm names its action in words ("Delete job") and says "This action cannot be undone." in a sentence, never a bare "Yes".
  • Tables bleed to the edge of the surface they sit in: a Table in a modal body meets both dialog edges.
  • Fit, then truncate, never clip: scrolled content runs to the dialog's own edge instead of vanishing behind a padded strip, and the title divider marks where it passes under the header.

Sanctioned combinations#

CombinationWhat it producesWhere usedWhy
Modal size="md" with ModalTitle (plain title string) and a tabbed bodyThe record preview modal, the canon's worked casewalmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:830-849md is the workbench size. Per module tabs adapt to what the caller already knows.
ConfirmModal preset="delete" with onConfirm submitting a form actionDestructive gate before a real deletespeedway/app/routes/account/accounts.tsx:933-945, walmart-mvp/frontend/src/pages/Jobs.tsx:1001-1011Canon §A5: ConfirmModal for every destructive action. The delete preset supplies the warning styling so no caller reinvents it.
ConfirmModal (no preset) with nextProps={{ content: "..." }} overriding the default "Confirm" labelA non delete gate whose action verb needs to match its own titlewalmart-mvp/frontend/src/pages/Admin.tsx:362-371Comment in the source: "A dialog titled 'Turn on live submit' answering 'Confirm' makes the reader re-read the title to learn what they are agreeing to."
ConfirmModal with onConfirm={(args, close) => { close(); doAsyncWork(); }}, closing before the async work resolvesA confirm that hands off to a toast for the pending and settled affordancewalmart-mvp/frontend/src/pages/ErrorManagement.tsx:1140-1149The modal itself carries no loading state for work that outlives it. pushAlert.promise is the pending affordance instead (see toast.md).
<Modal> body with no extra horizontal padding classBody content flush with the title's own insetwalmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:850-851 (comment: "doubling it pushed the body in twice")Canon §A10: the shell owns horizontal inset once. modal-box pads 24px, and modal.tsx:52 strips top padding because ModalTitle owns its own.

Banned combinations#

Body content that re-adds horizontal padding is banned. The shell already pads 24px each side (modal-box, modal.tsx:47-49), so a body div with its own px-6 doubles it. This was a real bug in two sibling modals for weeks. "Both rendered perfectly well, just inset twice as far as the title above them, and no typecheck or review surfaces that" (10-overlays.md §A10). It was swept 2026-08-08 and is now preserved as a comment at the fixed call site.

A generic grey empty or error state inside a modal is banned. Canon §A11 requires an empty region to use PageInfo size="sm" with a tone that carries meaning, and a failed load must render the structured error block, never a bare text-error span. The stage logs modal's bare error span was the incident that established this.

A one off raw confirm instead of ConfirmModal is banned. The canon (10-overlays.md §A5) frames this as a single flagged inconsistency; speedway actually carries four, walmart none:

  • speedway/app/routes/workspaces/jobs/job.tsx:292 and :326, a rerun and resume pair
  • speedway/app/routes/workspaces/jobs/setup.tsx:943, a template delete
  • speedway/app/routes/admin/loadsheets.tsx:247, the admin loadsheets delete

That last one is the trap. It calls the global confirm( bare rather than window.confirm, so a grep for window.confirm misses it, which is exactly how an earlier version of this paragraph concluded the loadsheets case had been removed. Search \bconfirm\(.

Treat every one as tech debt against ConfirmModal, not a sanctioned exception, regardless of which the canon happens to cite.

A copy affordance on a status value inside modal chrome is banned. Canon §A12: copy belongs on values that name the thing (an identifier, an error message), never on values reporting how work is going (a status pill, a completion ratio). The test is mechanical: if it renders in the status vocabulary, it must not carry copy.

A raw <button> in a footer is banned. The back/next slots already render through FooterButton, which is the kit Button (modal-elements.tsx), carrying its focus ring, disabled styling, and loading spinner for free. A hand rolled <button> beside it drops all three the moment the footer disables during a save.

A nested Card in a body is banned. Both ModalBody and ModalSection already draw the surface, the padding, and the section rule, and a Card inside them doubles the border and the corner radius against the dialog's own. Reach for ModalSection instead.

What this saves you#

Three canon rules are structural here rather than remembered.

Modal identity cannot drift from its mount (10 §A3). Identity lives in the store (modal-store.ts: selectedModalId, selectedModalKey, selectedModalArgs) and useModal() is the only sanctioned way in, so "store-driven, string-identified, rendered locally at the callsite" is what the API already forces. The kit goes one further and ships useModalMountWarning, which fires at runtime when a modal's declared id and its mount state disagree. That is unusual: most canon rules have a mechanism, this one has an enforcement.

Confirmation is a component, not a browser primitive (10 §A5). ConfirmModal is complete and a drop-in. The rule survives only because nothing stops someone typing confirm(, which is the Banned combinations entry above. The lint rule matching \bconfirm\( is written and paste-ready in method/app-patterns/12-primitives-and-rules.md, but no repo has a lint layer to carry it yet, so until one exists this is the one rule on this page a primitive has not actually retired.

A confirm footer cannot double-fire. confirm-modal.tsx routes through useAsyncAction, the same hook behind Button's promisify, so re-entry protection is inherited rather than opted into.

The boundary worth holding: none of this reports an outcome. 10 §A5 and 10 §A8 also require that the action starts before the modal closes and that its result is reported, and a confirm that closes silently on success is still buildable. Sanctioned combination three above is the shipped answer, handing the pending and settled affordance to pushAlert.promise.

The body scrolls, the shell does not (10 §A13). ModalTitle and ModalFooter stick to their edges while the space between them scrolls. The shell's own CSS drops modal-box's bottom padding the moment a footer is present, so the split is structural rather than a rule someone has to apply by hand.

Classified in method/app-patterns/12-primitives-and-rules.md.

Before you adopt this#

Five questions to answer before reaching for Modal.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, Modal renders itself from elementId; no shell mounts it centrally.
  2. Does this app already ship a local implementation of the same thing? Four raw confirm( call sites remain speedway tech debt, not a sanctioned exception.
  3. Does this app's kit pin reach the version this component or prop landed in? The record header anatomy (identifier/subtitle) landed kit 0.0.23; confirm the pin reaches it.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? ModalTitle's close button wraps its own Tooltip content="Close"; the body's controls name themselves.
  5. Which canon §A rules bind this surface, and which does the composition break? §A10 caps horizontal inset once; §A5 requires ConfirmModal over a raw confirm(.

Travels with#

Tooltip: ModalTitle's close button wraps itself in a Tooltip content="Close" placement="bottom" so the tip can never paint past the modal's own top edge (modal-elements.tsx:74-80). ModalFooterButton.tooltip wraps a footer button the same way. A string title or subtitle gets the same treatment, wrapped in its own Tooltip carrying the untruncated text, since either can run longer than the header allows.

CopyButton: the record header anatomy's identifier slot renders one internally (modal-elements.tsx:49-58). Do not wrap a ModalTitle's identifier in a second one.

Select and Dropdown: both pass noPortal when they sit inside a Modal, or the menu renders outside the dialog and is clipped. select.md and dropdown.md carry the reason and the shipped call sites.

SchemaForm: pass noPortal on the form itself when it sits inside a Modal. It reaches every Select the form renders (and any Dropdown a widget opens), so a choice field's listbox opens inside the dialog instead of behind its top layer, without the caller writing a Select at all.

PageInfo, EmptyState, PageError: canon §A11 requires these, not bespoke markup, for any empty or failed load state that appears inside a modal body.

Button: ModalFooter's back and next render through the kit Button, not a raw <button> (modal-elements.tsx), which is why color, variant, and shade are per button props rather than className overrides.

Snippet#

import { ConfirmModal } from "@versable-git/ui";
// walmart-mvp/frontend/src/pages/Jobs.tsx:1001-1011
<ConfirmModal<JobListEntry>
elementId={DELETE_JOB_MODAL}
preset="delete"
title="Delete this job?"
message={(j) =>
`This permanently deletes "${j?.name}" and every part it currently owns. Parts already ` +
`published to Walmart are kept (just detached from this job). Everything else, including its ` +
`review history, is gone for good.`
}
onConfirm={confirmDeleteJob}
/>
@versable-git/ui · reference, canon, and method, read in place