Qure UI
Components

Drawer

A dialog anchored to an edge of the screen, for panels rather than questions.

'use client'import { Badge } from '@/registry/qure/ui/badge'import { Button } from '@/registry/qure/ui/button'import {  Drawer, DrawerClose, DrawerContent, DrawerDescription,  DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from '@/registry/qure/ui/drawer'export default function DrawerDemo() {  return (    <Drawer>      <DrawerTrigger render={<Button variant="secondary">Study details</Button>} />      <DrawerContent>        <DrawerHeader>          <DrawerTitle>CT Thorax — ACC-4471902</DrawerTitle>          <DrawerDescription>            Sunita Deshmukh, 62F. Acquired 09:14 today, 412 slices.          </DrawerDescription>        </DrawerHeader>        <dl className="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 text-sm">          <dt className="text-muted-foreground">Protocol</dt>          <dd>Low-dose, non-contrast</dd>          <dt className="text-muted-foreground">Referrer</dt>          <dd>Respiratory clinic</dd>          <dt className="text-muted-foreground">Priority</dt>          <dd><Badge tone="urgent">Urgent</Badge></dd>        </dl>        <DrawerFooter>          <DrawerClose render={<Button variant="tertiary">Close</Button>} />          <Button>Open in viewer</Button>        </DrawerFooter>      </DrawerContent>    </Drawer>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/drawer.json

Usage

import {
  Drawer, DrawerClose, DrawerContent, DrawerDescription,
  DrawerFooter, DrawerHeader, DrawerScroll, DrawerTitle, DrawerTrigger,
} from '@/components/ui/drawer'
<Drawer side="right">
  <DrawerTrigger render={<Button variant="secondary">Study details</Button>} />
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>CT Thorax — ACC-4471902</DrawerTitle>
    </DrawerHeader>
  </DrawerContent>
</Drawer>

When to use it instead of Dialog

A Dialog asks a question and expects an answer. A drawer holds a panel: a study's details beside the images, filters over a worklist, a form long enough that a centred box would fight the page for vertical space.

The surface, the scrim and the shadow are Dialog's on purpose — a drawer that looked like a different product would be one. What differs is where it comes from, that it can be swiped away, and that it can be left open beside a live page.

Use Alert Dialog when the answer is destructive; a drawer is much too easy to dismiss for that.

Composition

<Drawer side="right">     {/* owns open state; picks the swipe direction from side */}
  <DrawerTrigger />       {/* usually render={<Button/>} */}
  <DrawerContent>         {/* backdrop + viewport + popup + Base UI's Content */}
    <DrawerHeader>        {/* title and description, fixed */}
      <DrawerTitle />
      <DrawerDescription />
    </DrawerHeader>
    <DrawerScroll />      {/* the part that scrolls, if there is one */}
    <DrawerFooter />      {/* actions, fixed to the bottom */}
  </DrawerContent>
</Drawer>

DrawerContent renders Base UI's Drawer.Content inside the popup for you. That part is not decoration: it is what lets a mouse select text in the panel without the drag being read as a swipe. A drawer built without it feels subtly broken in a way that is hard to trace.

DrawerScroll takes whatever height is left after the header and footer, so the title stays legible and the Save button never scrolls out of reach.

Sides

'use client'import { Button } from '@/registry/qure/ui/button'import {  Drawer, DrawerClose, DrawerContent, DrawerDescription,  DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from '@/registry/qure/ui/drawer'const sides = ['right', 'left', 'top', 'bottom'] as constexport default function DrawerSide() {  return (    <div className="flex flex-wrap justify-center gap-3">      {sides.map((side) => (        <Drawer key={side} side={side}>          <DrawerTrigger render={<Button variant="secondary" size="sm">{side}</Button>} />          <DrawerContent>            <DrawerHeader>              <DrawerTitle>Opened from the {side}</DrawerTitle>              <DrawerDescription>                Swipe it back towards the {side} edge, or press Escape.              </DrawerDescription>            </DrawerHeader>            <DrawerFooter>              <DrawerClose render={<Button variant="tertiary">Close</Button>} />            </DrawerFooter>          </DrawerContent>        </Drawer>      ))}    </div>  )}

side is ours, not Base UI's — the primitive has no placement prop at all, because placement is entirely CSS. What it does have is swipeDirection, and the two must agree or a drawer on the left is dismissed by dragging it further onto the screen. Drawer derives one from the other, so setting side is enough.

Right is the default and the right default: it is where a details panel goes next to content the reader is still looking at. Bottom is for touch. Top is for something arriving unbidden, which is usually a Toast instead.

A form in a drawer

'use client'import { Button } from '@/registry/qure/ui/button'import {  Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter,  DrawerHeader, DrawerScroll, DrawerTitle, DrawerTrigger,} from '@/registry/qure/ui/drawer'import { Field, FieldDescription, FieldLabel } from '@/registry/qure/ui/field'import { Input } from '@/registry/qure/ui/input'import {  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/registry/qure/ui/select'import { Textarea } from '@/registry/qure/ui/textarea'const lobes = {  rul: 'Right upper lobe',  rml: 'Right middle lobe',  rll: 'Right lower lobe',  lul: 'Left upper lobe',  lll: 'Left lower lobe',}/** * The case a drawer is actually for: a form too long for a centred box. * `DrawerScroll` takes the height that is left, so the title stays legible * and the Save button never scrolls out of reach. */export default function DrawerForm() {  return (    <Drawer>      <DrawerTrigger render={<Button variant="secondary">Add a finding</Button>} />      <DrawerContent>        <DrawerHeader>          <DrawerTitle>Add a finding</DrawerTitle>          <DrawerDescription>ACC-4471902 — CT Thorax, Sunita Deshmukh.</DrawerDescription>        </DrawerHeader>        <DrawerScroll className="grid gap-5">          <Field name="finding">            <FieldLabel>Finding</FieldLabel>            <Input placeholder="Spiculated nodule" />          </Field>          <Field name="location">            <FieldLabel>Location</FieldLabel>            <Select items={lobes} defaultValue="rul">              <SelectTrigger aria-label="Location">                <SelectValue />              </SelectTrigger>              <SelectContent>                {Object.entries(lobes).map(([value, label]) => (                  <SelectItem key={value} value={value}>{label}</SelectItem>                ))}              </SelectContent>            </Select>          </Field>          <Field name="size">            <FieldLabel>Longest axis</FieldLabel>            <Input placeholder="11" />            <FieldDescription>Millimetres, measured on the axial series.</FieldDescription>          </Field>          <Field name="notes">            <FieldLabel>Notes</FieldLabel>            <Textarea rows={4} placeholder="Up from 8mm on the September study." />          </Field>        </DrawerScroll>        <DrawerFooter>          <DrawerClose render={<Button variant="tertiary">Cancel</Button>} />          <Button>Save finding</Button>        </DrawerFooter>      </DrawerContent>    </Drawer>  )}

The case a drawer earns over a dialog. Four fields and a textarea in a centred box either overflow the viewport or squeeze the content; against the edge there is a full screen height to work with.

A bottom sheet with snap points

'use client'import { Button } from '@/registry/qure/ui/button'import { Checkbox } from '@/registry/qure/ui/checkbox'import {  Drawer, DrawerClose, DrawerContent, DrawerDescription,  DrawerFooter, DrawerHeader, DrawerScroll, DrawerTitle, DrawerTrigger,} from '@/registry/qure/ui/drawer'import { Label } from '@/registry/qure/ui/label'const filters = [  'Unread',  'Assigned to me',  'Past SLA',  'AI flagged — high confidence',  'Prior study available',  'Addendum requested',]/** * Snap points let a bottom sheet rest part-open. 0.45 shows the first few * filters over a worklist the reader can still see; dragging it up takes it * to full height. Fractions are of the viewport; numbers above 1, and * strings like '320px', are absolute. */export default function DrawerSnap() {  return (    <Drawer side="bottom" snapPoints={[0.45, 1]} defaultSnapPoint={0.45}>      <DrawerTrigger render={<Button variant="secondary">Filter worklist</Button>} />      <DrawerContent className="mx-auto max-w-lg">        <DrawerHeader>          <DrawerTitle>Filters</DrawerTitle>          <DrawerDescription>Drag the sheet up for the full list.</DrawerDescription>        </DrawerHeader>        <DrawerScroll className="grid gap-4">          {filters.map((filter) => (            <div key={filter} className="flex items-center gap-3">              <Checkbox id={filter} />              <Label htmlFor={filter}>{filter}</Label>            </div>          ))}        </DrawerScroll>        <DrawerFooter>          <DrawerClose render={<Button variant="tertiary">Clear</Button>} />          <DrawerClose render={<Button>Apply</Button>} />        </DrawerFooter>      </DrawerContent>    </Drawer>  )}

snapPoints lets the sheet rest part-open. Fractions are of the viewport height, numbers above 1 are pixels, and strings like '320px' or '30rem' are taken as written. Here 0.45 shows the first few filters while the worklist behind stays visible, and dragging up goes to full height.

The grabber bar appears automatically on the top and bottom edges, where the affordance is a drag rather than a click. It is aria-hidden and has no behaviour of its own — it is a mark, not a control. Override with showGrabber.

Non-modal

'use client'import { Button } from '@/registry/qure/ui/button'import {  Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger,} from '@/registry/qure/ui/drawer'import { Input } from '@/registry/qure/ui/input'/** * `modal={false}` leaves the page underneath live: no focus trap, no scroll * lock, no scrim swallowing clicks. This is the mode a viewer sidebar wants — * a radiologist reads the panel and scrolls the images at the same time. * * The trade is that nothing tells a screen-reader user the rest of the page * is still there to be used, so keep the panel short and give it a title. */export default function DrawerNonModal() {  return (    <div className="flex flex-col items-center gap-4">      <Drawer modal={false}>        <DrawerTrigger render={<Button variant="secondary">Open reference panel</Button>} />        <DrawerContent>          <DrawerHeader>            <DrawerTitle>Lung-RADS reference</DrawerTitle>            <DrawerDescription>              The field below still takes focus while this is open, and the page still scrolls.            </DrawerDescription>          </DrawerHeader>          <dl className="grid grid-cols-[64px_1fr] gap-x-4 gap-y-2 text-sm">            <dt className="text-muted-foreground">2</dt>            <dd>Benign. Continue annual screening.</dd>            <dt className="text-muted-foreground">3</dt>            <dd>Probably benign. Six-month LDCT.</dd>            <dt className="text-muted-foreground">4A</dt>            <dd>Suspicious. Three-month LDCT or PET/CT.</dd>            <dt className="text-muted-foreground">4B</dt>            <dd>Suspicious. Chest CT, PET/CT or tissue sampling.</dd>          </dl>        </DrawerContent>      </Drawer>      <Input className="w-64" placeholder="Still reachable while open" />    </div>  )}

modal={false} leaves the page underneath live: no focus trap, no scroll lock, and clicks pass through the area outside the panel. This is what a viewer sidebar wants — a radiologist reading the panel and scrolling the images at once.

modal="trap-focus" is the middle setting: focus stays inside, but the page still scrolls.

A non-modal drawer tells assistive technology nothing about the rest of the page still being available. Keep it short, always give it a DrawerTitle, and do not use it for anything the user must finish.

Dismissal, tested

Pressing Escape closes the drawer. Clicking the scrim closes it. Dragging it towards its own edge past roughly half its width closes it, and releasing short of that springs it back — the scrim lightens with the drag, because Base UI publishes the gesture's progress as --drawer-swipe-progress and the stylesheet reads it.

disablePointerDismissal keeps the scrim click from closing it. Escape still works, and there is no prop that stops it — the same as Alert Dialog, and correct for the same reason: a dialog with no keyboard exit is a trap.

Two named traps

Drawer.Handle is not the grabber bar. In Base UI it is the object that lets a trigger rendered elsewhere on the page open a drawer defined somewhere else — the equivalent of Preview Card's handle. It is re-exported here as createDrawerHandle to keep the two apart. The grabber is drawn by DrawerContent.

Drawer.Provider, Drawer.Indent and Drawer.IndentBackground in Base UI's anatomy are for the iOS pattern where the page behind scales back as the sheet rises. DrawerContent does not render them; add them yourself around the root if you want that effect.

API Reference

Everything Base UI's Drawer accepts. The ones you will reach for, plus ours:

Prop

Type

On DrawerContent:

Prop

Type

On this page