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 /dashboard/accounts/:acctId/sites/:siteId/settings/locations

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>
</>