Pattern
Drawer
A record or a content-heavy flow opened from a page: header, optional pinned region, a body that scrolls, one footer band.
Copy it from /
Edit drawer
Cancel on the left as a ghost button, the verb on the right tied to the form by id, and busy while the save runs. Save with an empty name to see the validation sentence.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ settings/ locations - Screenshot
- docs/
agents/ ui-patterns/ drawer-header-1440.png
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const fetcher = useFetcher<typeof action>()
// Busy from the press of Save, before the fetcher reports the post.
const saveHold = useHeldRequest(fetcher.data)
// Closes the drawer once the save succeeds, as `useCloseAfterSubmit` in
// app/features/forms/routes/dashboard/forms.settings/state.ts does. A failed
// save returns field errors, which RVF shows under their fields, and the
// drawer stays open.
useCloseAfterSubmit(fetcher, onClose)
<Drawer
isOpen={isOpen}
onClose={onClose2}
size="lg"
isBusy={saveHold.isHeld}
header={{
icon: 'location',
title: 'Edit location',
subtitle: 'Address, phone, and what animal profiles show.',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: onClose2,
},
],
primary: {
label: 'Save',
submit: {
form: 'location-form',
},
},
}}
>
<Form
id="location-form"
validator={validator}
fetcher={fetcher}
onSubmitRequest={saveHold.hold}
>
{/* the location fields */}
</Form>
</Drawer>Read drawer with Close
A drawer that only needs closing has a Close primary that only closes. Its body is cards, so it takes the panel tone.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId
<Drawer
isOpen={isOpen}
onClose={onClose2}
size="lg"
bodyTone="panel"
header={{
icon: 'animal',
title: 'Hidden from your website',
}}
footer={{
primary: {
label: 'Close',
onPress: onClose2,
},
}}
>
{/* the list of hidden animals */}
</Drawer>Pinned identity block and tabs on a panel body
The identity block, with its Edit, and the Tabs stay above the scrolling panel body; each tab has its own panel. Secondaries are a button and a download link; the primary greets the applicant.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ submitted-forms/ :kind?review=:id - Screenshot
- docs/
agents/ ui-patterns/ drawer-tabs-footer-1440.png
<Drawer
isOpen={isOpen}
onClose={onClose2}
size="xl"
bodyTone="panel"
header={{
caption: 'Dog Adoption Application · Biscuit +1',
icon: 'form',
title: 'Review submission',
subtitle: 'Submitted Oct 12, 2026 9:41 am',
}}
pinned={<>
<div
className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1"
>
<div
className="min-w-0"
>
<h3
className="text-lg font-semibold tracking-tight text-gray-900"
>
Jamie Alvarez
</h3>
<p
className="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-gray-500"
>
<span
className="break-all"
>
[email protected]
</span>
<span
className="whitespace-nowrap"
>
<span
aria-hidden
className="mr-2"
>
·
</span>
(305) 555-0142
</span>
</p>
</div>
<div
className="flex shrink-0 items-center gap-3"
>
<ListStatus
status="In Progress"
tone="amber"
/>
<SecondaryButton
appearance="ghost"
aria-label="Edit basic information"
onPress={openEditBasicInfo}
className="-my-2 -mr-3"
>
<PencilSquareIcon
className="h-4 w-4"
aria-hidden
/>
Edit
</SecondaryButton>
</div>
</div>
<div
className="mt-4"
>
<Tabs
id={tabsId}
items={[
{
key: 'overview',
label: 'Overview',
},
{
key: 'answers',
label: 'Answers',
count: 42,
indicator: 'flag',
},
{
key: 'contracts',
label: 'Contracts',
count: 0,
},
{
key: 'notes',
label: 'Notes',
count: 1,
},
{
key: 'history',
label: 'History',
count: 2,
},
]}
selected={tab}
onSelect={setTab}
/>
</div>
</>}
footer={{
actions: [
{
label: 'Send contract',
icon: 'contract',
onPress: openSendContract,
},
{
label: 'Download PDF',
icon: 'download',
to: pdfUrl,
download: true,
},
],
primary: {
label: 'Message Jamie',
icon: 'message',
onPress: openMessage,
},
}}
>
<div
{...tabPanelProps(tabsId, tab)}
>
{/* the open tab’s panel */}
</div>
</Drawer>Footer note and a disabled primary
The note sits right-aligned beside the primary it explains, with the secondaries at the left edge; on a phone it takes its own line above the buttons. The primary is disabled until the note’s condition is met: add the contract to enable it, then send to see the busy state.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ submitted-forms/ :kind?review=:id
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const fetcher = useFetcher<typeof action>()
// Busy from the press of Save, before the fetcher reports the post.
const saveHold = useHeldRequest(fetcher.data)
// Closes the drawer once the save succeeds, as `useCloseAfterSubmit` in
// app/features/forms/routes/dashboard/forms.settings/state.ts does. A failed
// save returns field errors, which RVF shows under their fields, and the
// drawer stays open.
useCloseAfterSubmit(fetcher, onClose)
<Drawer
isOpen={isOpen}
onClose={onClose2}
size="lg"
isDismissable={false}
isBusy={saveHold.isHeld}
header={{
icon: 'contract',
title: 'Send contracts',
}}
footer={{
note: {
text: queueNote,
},
actions: [
{
label: 'Cancel',
onPress: onClose2,
},
],
primary: {
label: 'Send contracts',
submit: {
form: 'send-contract-form',
},
isDisabled: queue.length === 0,
},
}}
>
<Form
id="send-contract-form"
validator={validator}
fetcher={fetcher}
onSubmitRequest={saveHold.hold}
>
{/* the contract queue and its settings */}
</Form>
</Drawer>Danger primary and danger secondary
A primary that cannot be taken back is the filled red button, as the bulk edit review step’s Update is when the change clears an adopter. A destructive secondary is the ghost red button, leftmost, before Cancel. A wizard’s footer holds only Back and the primary, so a destructive secondary belongs to a plain drawer. A confirmation is a modal’s job, not a drawer’s.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ volunteers
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const fetcher = useFetcher<typeof action>()
// Busy from the press, before the fetcher reports the post.
const saveHold = useHeldRequest(fetcher.data)
const removeAccess = () =>
saveHold.hold(
fetcher.submit({ intent: 'remove-access' }, { method: 'post' }),
)
<Drawer
isOpen={isOpen}
onClose={onClose2}
isBusy={saveHold.isHeld}
header={{
icon: 'person',
title: 'Dashboard access',
subtitle: 'Jamie Alvarez · Foster coordinator',
}}
footer={{
actions: [
{
label: 'Delete person',
tone: 'danger',
onPress: deletePerson,
},
{
label: 'Cancel',
onPress: onClose2,
},
],
primary: {
label: 'Remove access',
tone: 'danger',
onPress: removeAccess,
},
}}
>
{/* what removing access does */}
</Drawer>View-only drawer with Close
A caller without edit access sees the form read-only. With nothing to save or cancel, the drawer only needs closing, so its one button is a Close primary.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ forms/ settings
<Drawer
isOpen={isOpen}
onClose={onClose2}
header={{
icon: 'setting',
title: 'Fostering',
}}
footer={{
primary: {
label: 'Close',
onPress: onClose2,
},
}}
>
<Form
validator={validator}
readOnly
defaultValues={settings}
>
{/* the settings fields */}
</Form>
</Drawer>Busy during a save
From the press of Save until the save is finished, the primary shows its spinner and the close button, Cancel, the backdrop, and Escape do nothing. Focus waits on the panel and returns to Save if the save fails.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ settings/ locations
import { useHeldRequest } from '~/components/ui/use-held-request.ts'
const fetcher = useFetcher<typeof action>()
// Busy from the press of Save, before the fetcher reports the post.
const saveHold = useHeldRequest(fetcher.data)
// Closes the drawer once the save succeeds, as `useCloseAfterSubmit` in
// app/features/forms/routes/dashboard/forms.settings/state.ts does. A failed
// save returns field errors, which RVF shows under their fields, and the
// drawer stays open.
useCloseAfterSubmit(fetcher, onClose)
<Drawer
isOpen={isOpen}
onClose={onClose2}
isBusy={saveHold.isHeld}
header={{
icon: 'setting',
title: 'Foster care title',
subtitle: 'What an animal’s profile says while it is in foster care.',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: onClose2,
},
],
primary: {
label: 'Save',
submit: {
form: 'foster-title-form',
},
},
}}
>
<Form
id="foster-title-form"
validator={validator}
fetcher={fetcher}
onSubmitRequest={saveHold.hold}
>
{/* the title field */}
</Form>
</Drawer>Wizard in a drawer
Steps share the wizard contract: Cancel on the first step and Back after on the left, "Next" until the last step, which shows its verb. Next and the verb both submit the one form, which stays mounted in its step, and its submit handler moves on or saves.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group
<Drawer
isOpen={isOpen}
onClose={onClose2}
size="lg"
header={{
icon: 'edit',
title: 'Edit 3 animals',
}}
step={step}
steps={[
{
id: 'choose',
label: 'Choose what to change',
content: <>{/* the choose what to change step */}</>,
},
{
id: 'values',
label: 'Set the new values',
content: <Form
id="bulk-edit-form"
validator={validator}
onSubmit={handleStepSubmit}
>
{/* the set the new values step */}
</Form>,
},
{
id: 'review',
label: 'Review',
content: <>{/* the review step */}</>,
},
]}
footer={{
onBack: onBack,
primary: {
label: 'Update 3 animals',
submit: {
form: 'bulk-edit-form',
},
},
}}
/>A drawer over a drawer
A drawer opened from another drawer layers above it on its own, with nothing to set: its backdrop covers the first drawer, whose edge shows beside a narrower second one, and closing it returns to the first. In the app, the send contract drawer opens over the review drawer this way.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ submitted-forms/ :kind?review=:id
<>
<Drawer
isOpen={isListOpen}
onClose={closeLocations}
size="lg"
header={{
icon: 'location',
title: 'Locations',
}}
footer={{
primary: {
label: 'Close',
onPress: closeLocations,
},
}}
>
{/* the locations, each with an Edit that opens the next drawer */}
</Drawer>
<Drawer
isOpen={editingId !== undefined}
onClose={closeEditLocation}
header={{
icon: 'location',
title: 'Edit location',
}}
footer={{
actions: [
{
label: 'Cancel',
onPress: closeEditLocation,
},
],
primary: {
label: 'Save',
submit: {
form: 'location-name-form',
},
},
}}
>
<Form
id="location-name-form"
validator={validator}
fetcher={fetcher}
>
{/* the location fields */}
</Form>
</Drawer>
</>