Overlay

Drawer

A sheet hung from an edge, and a gesture that throws it back. It hangs from any of the four, and the edge it hangs from has no border — an edge that is off the screen does not need drawing.

Usage

import { Drawer } from '@carabine/ui/drawer';
<Drawer
  title="Filters"
  description="Narrow the list"
  content={<Filters />}
  footer={<Button variant="primary" full>Show 42 results</Button>}
>
  <Button variant="secondary">Filters</Button>
</Drawer>

Only the pill starts a drag. gripOnly is on by default, because a sheet whose whole body is draggable is a sheet you cannot scroll.

One value, four sides

There is one motion value and it is always positive: how far the sheet is from home. A sign per side maps it onto the axis, so the gesture, the rubber-banding past home and the throw are written once rather than four times with the inequalities flipped.

The capture waits for 4px of travel before it takes the pointer. That is why the close button works: a captured pointer sends its click to the capturing element, not to whatever was under the finger — so capturing on pointerdown meant every button inside the sheet was pressing the sheet.

And the sheet is mounted during the render that opens it, not in an effect afterwards:

if (shown && !mounted) setMounted(true);   // during render, on purpose

In an effect, the first commit has no panel in it — so there is nothing to measure and nothing for the entrance to animate from, and the sheet appears fully open. The blur arrives from that entrance's own onComplete, for the same reason a transformed ancestor kills a backdrop-filter: while it is moving, there is nothing to sample.

Examples

Every edge

side decides where it hangs from. size is a height on top and bottom and a width on the sides; leave it off and the sheet is as tall as its content.

<Drawer side="right" size={320} title="From the right" content={<p>…</p>}>
  <Button variant="secondary">right</Button>
</Drawer>

Customization

<Drawer
  side="bottom"
  size="70vh"        // a number is pixels; a string is any CSS length
  radius={24}        // the inner corners. The outer ones are square
  grip={false}       // no pill
  gripOnly={false}   // the body drags too, for a sheet with nothing to scroll
  showClose
  dismissible={false}
  theme="light"
/>

Styling reference

Tailwind utilities plus drawer.css for the body's scrollbar. The material is the toast's, and the geometry is per-side: the corners on the screen edge are square, the ones facing the page are rounded, and the hairline runs on the three edges you can actually see. A border along an edge that is off the screen is a border drawn for nobody.

API reference

PropType
classNamestringAdded to the panel’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Both land on the panel. The trigger is already your element — you style it where you write it — and the panel is the part that portals away from your markup and is otherwise out of reach. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
childrenReactNodeThe trigger
openbooleanControlled
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
side'bottom' | 'top' | 'left' | 'right''bottom'
titleReactNodeNames the sheet
descriptionReactNodeDescribes it
contentReactNodeScrolls, if there is more than fits
footerReactNodeA row at the far end
sizenumber | stringcontentHeight, or width on the sides
radiusnumber20The inner corners. The outer ones are square
dismissiblebooleantrueEscape, the scrim, and the gesture
gripbooleantrueThe pill you pull
gripOnlybooleantrueOnly the pill starts a drag
showClosebooleanfalse
closeLabelstring'Close'
disabledbooleanfalse
theme'dark' | 'light''dark'
containerElement | nulldocument.body