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 docs/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 noModalIdenum. Each app owns its own id space so one app's modals never collide with another's namespace (modal.types.ts:6-11).modalKeydistinguishes instances sharing anelementId, 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).sizeis"xs" | "sm" | "md" | "lg" | "xl", default"md". Fixed pixel presets (475, 700, 1000, 1400, full), every one capped at90vwso nothing overflows a narrow viewport. Pick the smallest that fits the job.onClosefires on any native close (ESC, backdrop, header close button), not on a programmaticcloseModal()call site.classNamestyles the innermodal-box.dialogClassNamestyles the<dialog>itself.
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), and onIdentifierCopied(value) for the toast that fires after a successful copy. Passing none of the three leaves a plain single line title untouched. The anatomy is canon (10-overlays.md §A9): "an identity glyph, then a column of a primary line above a secondary line." As of this sweep, onIdentifierCopied and the structured identifier/subtitle props only appear in the kit's own playground gallery (apps/playground/src/app/components/modal/examples/record-anatomy.tsx:14-17). Walmart's own record modal (PartPreviewModal.tsx:831-848) still hand rolls the identical pattern with a separate CopyButton, predating the anatomy's adoption. Treat the canon's claim that this was already swept as the target, not yet the shipped state, until that call site is updated. ModalTitleProps also carries toolbar (a right-edge slot beside the close button), hideClose, hideBorder, iconSize, and the className/titleClassName slots (modal.types.ts:44-67), previously undocumented here.
ModalFooter takes back/next (each a ModalFooterButton with content, Icon, onClick, loading, disabled, tooltip), spread to push the pair to opposite ends, and loading to disable both and drive the primary's spinner. Both buttons render as ghost variants, since "the primary action is text emphasis only" (10-overlays.md §A4).
ModalTitle's hideBorder (modal.types.ts:63, drawn at modal-elements.tsx:22,34) and ModalFooter's own hideBorder (modal.types.ts:92, drawn at modal-elements.tsx:125,137) are independent, first-class options, not a paired toggle. Drop the header border when the title sits directly on a plain surface and a rule underneath would repeat the page's own separation. Drop the footer border when the body's last field or row already closes the box visually, a form whose final input touches the bottom, so a second line on top of it reads as a stray stripe rather than a boundary.
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. This is doc 48 B13 (kit commit 6ff8ac3): before it, modal-box forced scrollbar-gutter: stable, which reserved a scrollbar-width lane on the right even when the body was not scrolling, leaving a 39px right inset against a 24px left one. Dropping that rule fixed the asymmetry to 24px on both sides, and the same commit fixed the footer's own padding stack, cutting the gap under the footer buttons from 30px to 18px.
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.
ConfirmModal<Args> (confirm-modal.tsx) is the componentized yes/no gate. preset="delete" styles it destructive (error border, trash icon, "Yes, delete" label). 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:53), 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.
Sanctioned combinations#
| Combination | What it produces | Where used | Why |
|---|---|---|---|
Modal size="md" with ModalTitle (plain title string) and a tabbed body | The record preview modal, the canon's worked case | walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:830-849 | md is the workbench size. Per module tabs adapt to what the caller already knows. |
ConfirmModal preset="delete" with onConfirm submitting a form action | Destructive gate before a real delete | speedway/app/routes/account/accounts.tsx:933-945, walmart-mvp/frontend/src/pages/Jobs.tsx:1001-1011 | Canon §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" label | A non delete gate whose action verb needs to match its own title | walmart-mvp/frontend/src/pages/Admin.tsx:362-371 | Comment 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 resolves | A confirm that hands off to a toast for the pending and settled affordance | walmart-mvp/frontend/src/pages/ErrorManagement.tsx:1140-1149 | The 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 class | Body content flush with the title's own inset | walmart-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:48 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:292and:326, a rerun and resume pairspeedway/app/routes/workspaces/jobs/setup.tsx:943, a template deletespeedway/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.
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
docs/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 docs/app-patterns/12-primitives-and-rules.md.
Before you adopt this#
Five questions to answer before reaching for Modal.
- 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. - 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. - 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. - Does the component derive its own accessible name and keyboard path, or must the call site supply them?
ModalTitle's close button wraps its ownTooltip content="Close"; the body's controls name themselves. - Which canon §A rules bind this surface, and which does the composition break? §A10 caps horizontal inset once; §A5 requires
ConfirmModalover a rawconfirm(.
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.
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.
PageInfo, EmptyState, PageError: canon §A11 requires these, not bespoke markup, for any empty or failed load state that appears inside a modal body.
The kit Button is not yet wired into ModalFooter. The footer buttons are still plain daisyUI btn classes by deliberate choice, tracked against the house Button landing (modal-elements.tsx:88-90).
Snippet#
// 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}/>