Drawer
A panel that comes in from any edge and covers most of the screen, with a notch you swipe to send it back out.
Installation
npx shadcn@latest add @loomui/drawerUsage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"<Drawer>
<DrawerTrigger>Open</DrawerTrigger>
<DrawerContent side="bottom">
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Narrow the list down.</DrawerDescription>
</DrawerHeader>
<DrawerClose>Done</DrawerClose>
</DrawerContent>
</Drawer>Give every drawer a DrawerTitle. It is what a screen reader announces on the
way in, and it is the one part of the panel that is not optional.
Sides and size
The preview comes from the bottom, which is what a drawer is for most of the time. The other three edges are a prop away.
<DrawerContent side="left" size="20rem" />It stands in for a modal, so it opens to one place and covers most of the
screen. size is 90svh top or bottom, 26rem from the sides. Short of the
whole screen on purpose. That sliver of page still showing is what keeps the
rounded edge visible and stops the drawer reading as a new page.
The animation
The panel is only in the DOM while it is open, so a transition has nothing to start from. It uses keyframes instead, one frame each. The missing frame comes from wherever the panel currently is, so one pair covers all four edges whether the drawer is resting or halfway out under a finger.
The way in does not fill forwards. An animation that holds its last frame outranks inline styles for good, and the drag would have nothing left to move. The way out does fill, or the panel snaps back to open for one frame before it unmounts.
Arriving and settling back take 260ms on cubic-bezier(0.32, 0.72, 0, 1). Most
of the distance is covered in the first third, so it lands soft instead of
stopping dead. Leaving takes 200ms, because nobody is waiting to read what is on
its way out.
The panel and the overlay always share a curve and a duration. Two things that move as one unit but animate on different timing stop reading as one unit.
The contents follow the panel in by 60ms, from 14px out. Late enough to read as the drawer filling itself in, early enough not to look like a second animation.
The notch stretches and darkens while you hold it. It scales rather than
resizing, because transform and opacity are the only properties that skip
layout, and a notch that reflows on every drag stutters on every drag.
The drag
The whole face is the grip. Press anywhere and pull. The notch is a signpost, not the only handle.
Content that scrolls keeps its scroll. Before a drag is granted, the press walks up looking for a scrollable ancestor. Find one that is not already at the top and the gesture belongs to it. Once a drag is granted it holds until you let go, because handing a gesture back halfway is worse than never taking it.
Three other things block a drag. Anything inside [data-no-drag], selected
text, and the first 260ms after opening so the arrival can be scrolled through.
<div data-no-drag>
<Slider />
</div>That is the opt out for anything with its own gesture. A slider, a map, a carousel.
The snap
One place to land, so the drag decides one thing. The drawer goes, or it goes back where it started.
It closes if you pull past a quarter of the panel, or flick faster than 0.2px per millisecond. A quarter rather than a fixed distance. 40px out of a drawer nine hundred tall is a hair trigger, and the same 40px out of a short one is most of the way.
Pull the other way past fully open and the resistance is logarithmic, so it
gives at first and then firms up. That reads as a limit rather than a loose
edge. The panel also carries its own background 200% past its edge in an
::after, so overdragging never opens a gap onto the page behind it.
The overlay tracks the drag, so the page brightens as the panel pulls away. That does more to join the two than the movement itself.
When the answer is closed, the swipe carries on out and the drawer closes on arrival. No snapping back to play an exit it has already been given.
translate is written straight onto the node during a drag, so the gesture
costs no renders and the panel sits exactly under your finger. Nothing is
remembered between openings. Every open starts against the edge.
<DrawerContent showHandle={false} />Without the notch the panel still drags. The notch only says that it does.
Holding it open
<DrawerContent dismissible={false} />The swipe, the overlay and Escape all stop closing it. Leave a DrawerClose
in the panel, or the only way out is unmounting it.
Controlled
const [open, setOpen] = React.useState(false)
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>...</DrawerContent>
</Drawer>Drawer is Radix's dialog root, so open, defaultOpen, onOpenChange and
modal all behave the way they do there.
Parts
| Part | Description |
|---|---|
Drawer | The root. Owns the open state. |
DrawerTrigger | Opens it. Renders a button. |
DrawerContent | The panel itself, plus the overlay and the notch. |
DrawerHeader | Stacks the title and description. |
DrawerTitle | Required. The accessible name of the panel. |
DrawerDescription | Optional supporting line. |
DrawerFooter | Pushes its children to the far end of the panel. |
DrawerClose | Closes it. Renders a button. |
Props
DrawerContent takes everything Radix's Dialog.Content takes, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "bottom" | Edge the drawer comes in from. |
size | string | 90svh / 26rem | How much of the screen it covers. |
showHandle | boolean | true | Draw the notch you take hold of. |
dismissible | boolean | true | Allow swipe, overlay and Escape to close. |
className | string | none | Merged onto the panel. |
Accessibility
The panel is a real modal dialog: focus is trapped inside it, moved back to the trigger on close, and the page behind it is inert. The notch is a pointer affordance and is hidden from assistive tech, so nothing is reachable only by swiping. With reduced motion the panel arrives without the slide.