Pattern
Modal
A small detail view or a single action over the page: header, a body that scrolls, one tinted footer band. A confirmation is ConfirmModal.
Copy it from /
Form modal
A single action on a form: Cancel on the left, the verb on the right tied to the form by id, and busy while it saves. Clear the name and press Duplicate to see the validation sentence.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ forms - Screenshot
- docs/
agents/ ui-patterns/ modal-footer-1440.png
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
// RVF calls this only with valid values.
const handleSubmit = () => {
if (formRef.current) {
saveHold.hold(
fetcher.submit(new FormData(formRef.current), { method: 'post' }),
)
}
}
<ModalShell
isOpen={isOpen}
onRequestClose={onClose}
isBusy={saveHold.isHeld}
header={{
icon: 'form',
title: 'Duplicate form',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: onClose,
},
],
primary: {
label: 'Duplicate',
submit: {
form: 'duplicate-form',
},
},
}}
>
<Form
id="duplicate-form"
formRef={formRef}
validator={validator}
fetcher={fetcher}
onSubmit={handleSubmit}
>
{/* the new form name */}
</Form>
</ModalShell>Busy during a save
From the press of the primary until the save is finished, the primary shows its spinner, the body is inert and dimmed, and the close button, Cancel, the backdrop, and Escape do nothing. A successful save closes the modal; a refusal keeps it open with its errors, and focus returns to the primary.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ forms
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
// RVF calls this only with valid values.
const handleSubmit = () => {
if (formRef.current) {
saveHold.hold(
fetcher.submit(new FormData(formRef.current), { method: 'post' }),
)
}
}
useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data?.success) onClose()
}, [fetcher.state, fetcher.data, onClose])
<ModalShell
isOpen={isOpen}
onRequestClose={onClose}
isBusy={saveHold.isHeld}
header={{
icon: 'form',
title: 'Duplicate form',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: onClose,
},
],
primary: {
label: 'Duplicate',
submit: {
form: 'duplicate-form',
},
},
}}
>
<Form
id="duplicate-form"
formRef={formRef}
validator={validator}
fetcher={fetcher}
onSubmit={handleSubmit}
>
{/* the new form name */}
</Form>
</ModalShell>Modal with a caption and a description
The caption names the record above the title, and the description says what the action does. Built like the transfer report resend, which has no caption: the xl width, placed at the top because its body loads, busy while it sends, and a click on the backdrop keeps the typed recipients.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animals/ :group/ :id/ edit - Screenshot
- docs/
agents/ ui-patterns/ modal-1440.png
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const formRef = useRef<HTMLFormElement>(null)
const saveHold = useHeldRequest(fetcher.data)
// RVF calls this only with valid values.
const handleSubmit = () => {
if (formRef.current) {
saveHold.hold(
fetcher.submit(new FormData(formRef.current), { method: 'post' }),
)
}
}
<ModalShell
isOpen={isOpen}
onRequestClose={onClose}
size="xl"
placement="top"
isDismissable={false}
isBusy={saveHold.isHeld}
header={{
caption: 'Pepper',
icon: 'animal',
title: 'Resend Transfer Report',
description: 'Transferred to Brookhaven Animal Shelter on Oct 12, 2026. The report goes to the addresses below.',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: onClose,
},
],
primary: {
label: 'Send Report',
pendingLabel: 'Sending...',
submit: {
form: 'transfer-out-form',
},
},
}}
>
<Form
id="transfer-out-form"
formRef={formRef}
validator={validator}
fetcher={fetcher}
onSubmit={handleSubmit}
>
{/* the report fields */}
</Form>
</ModalShell>Danger confirmation
A destructive confirm is the filled red button on the right, Cancel on the left. Escape cancels; the backdrop does nothing. Deleting a question closes at once, since nothing posts: the form editor removes the question, and its Save applies it.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ forms/ :id
const closeConfirmation = () => setIsConfirming(false)
<ConfirmModal
isOpen={isConfirming}
onCancel={closeConfirmation}
icon="danger"
title="Delete this question?"
description={'"What inspired you to adopt at this time?" will be removed from this form when you save.'}
cancelLabel="Cancel"
confirm={{
label: 'Delete question',
tone: 'danger',
onPress: () => {
closeConfirmation()
deleteQuestion(questionId)
},
}}
/>Primary confirmation
The tone is danger when the action deletes or removes something, or takes away a person's access or application; primary when it only changes state. Leaving a page while a bulk update runs loses nothing, so the confirm is the primary button, with the primary icon. It closes at once, and the navigation goes on.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group
const closeConfirmation = () => setIsConfirming(false)
<ConfirmModal
isOpen={isConfirming}
onCancel={closeConfirmation}
icon="primary"
title="Leave while the update runs?"
description="The update keeps running, but this page won't be able to show you how it ended."
cancelLabel="Stay"
confirm={{
label: 'Leave',
onPress: () => {
closeConfirmation()
blocker.proceed?.()
},
}}
/>Busy while the confirm posts
A confirm that posts keeps the confirmation open and busy until the post settles: the confirm spins, and Cancel and Escape do nothing. useHeldRequest holds it from the press, before the request reports pending, until the request settles.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ settings/ locations
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const deleteHold = useHeldRequest(fetcher.data)
const closeConfirmation = () => setIsConfirming(false)
<ConfirmModal
isOpen={isConfirming || deleteHold.isHeld}
onCancel={closeConfirmation}
isBusy={deleteHold.isHeld}
icon="danger"
title="Delete Sunny Paws Adoption Center?"
description="This location will be removed."
cancelLabel="Cancel"
confirm={{
label: 'Delete',
tone: 'danger',
onPress: () => {
deleteHold.hold(fetcher.submit({ intent: 'delete-location', id }, { method: 'post' }))
closeConfirmation()
},
}}
/>