Drawer
A bottom sheet with a swipe handle, for touch-first flows.
- Status
- Experimental
- Level
- Organism
- Category
- Overlays
- Adoption
- Not used yet
import { Drawer } from "@oration/canon/components/drawer";packages/canon/src/components/drawer.tsximport { Button } from "@oration/canon/components/button";import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@oration/canon/components/drawer";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); return ( <Drawer open={open} onOpenChange={setOpen} showSwipeHandle> <DrawerTrigger render={<Button type="button" variant="outline" />}> Review INV-20931 </DrawerTrigger> <DrawerContent> <div className="mx-auto flex w-full max-w-md flex-col"> <DrawerHeader> <DrawerTitle>Approve INV-20931?</DrawerTitle> <DrawerDescription> $18,240.00 to Northwind Freight, due Friday, Oct 2. </DrawerDescription> </DrawerHeader> <dl className="m-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-[10px] bg-muted/70 p-3 text-13"> <dt className="text-muted-foreground">PO</dt> <dd className="text-right font-mono text-xs leading-5"> PO-7731 </dd> <dt className="text-muted-foreground">Received</dt> <dd className="text-right tabular-nums">Sep 2, 2026</dd> <dt className="text-muted-foreground">Payment run</dt> <dd className="text-right font-mono text-xs leading-5"> PR-0412 </dd> </dl> <DrawerFooter> <Button type="button" onClick={() => { setOpen(false); toast.add({ type: "success", title: "INV-20931 approved", description: "It goes out with Friday's run.", }); }} > Approve invoice </Button> <DrawerClose render={<Button type="button" variant="outline" />} > Cancel </DrawerClose> </DrawerFooter> </div> </DrawerContent> </Drawer> );}Usage#
Drawer is a panel that people can drag and swipe: from the bottom by default, or from any edge, with optional snap points and nested drawers. It is built for touch, where a bottom sheet that follows the finger feels native, and it is the only overlay in the suite that tracks a gesture. Nothing in the product uses it yet; on desktop and for record details, Sheet is the right choice. The easy mistake is shipping one without a way to close it that people can see: it has no close button, so show the swipe handle or put a Close in the footer.
When to use
- For a short task on a phone, such as approving an invoice from a notification, where swiping down to dismiss is expected.
- For a picker or a filter list on touch screens that people open, choose from and flick away.
- With snap points, for a list that peeks at half height and expands to full when dragged up.
When not to use
- For record details or a long edit on desktop. Use Sheet
- For a focused task the page waits on, on any screen size. Use Dialog
- For a confirmation. Use Confirm dialog
- For a menu of actions on a row. Use Dropdown menu
The One Filled Button Rule
Exits are faster than enters
--drawer-swipe-strength times 400ms, so a fast flick leaves faster than a slow drag.Anatomy#
Approve invoice?
$18,240.00, due Friday
- Scrim. Black at 25% (55% in dark), fading as the drawer is dragged away. With snap points it never drops below 50% of that.
- Swipe handle. With
showSwipeHandle: a 96 by 4px Well Gray pill in a 12px strip at the grabbing edge. Decorative; the whole panel is draggable. - Panel. Popover White with 12px corners on the free edge and a hairline along it. From the bottom it is full width and up to the viewport height minus 6rem; from a side it is 75% wide, 24rem from 640px.
- Header.
DrawerHeader, 16px padding with none below,DrawerTitle(16px, weight 500) andDrawerDescription(14px Slate Meta, balanced). Centered for top and bottom drawers below 768px. - Body. Your content, inside a clipping column. Add
min-h-0 flex-1 overflow-y-autoto a wrapper to scroll it. - Footer.
DrawerFooter, pushed down withmt-auto, 16px padding with none above, buttons stacked 8px apart.
Examples#
Directions
swipeDirection picks the edge and the gesture that dismisses it. Side drawers are 75% wide, 24rem from 640px; top and bottom drawers are full width and as tall as their content.
import { Button } from "@oration/canon/components/button";import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@oration/canon/components/drawer";export function Directions() { const directions = ["down", "up", "left", "right"] as const; const edge = { down: "From the bottom", up: "From the top", left: "From the left", right: "From the right", }; return ( <> {directions.map((direction) => ( <Drawer key={direction} swipeDirection={direction} showSwipeHandle > <DrawerTrigger render={ <Button type="button" variant="outline" size="sm" /> } > {edge[direction]} </DrawerTrigger> <DrawerContent> <div className="flex flex-1 flex-col"> <DrawerHeader> <DrawerTitle>Payment run PR-0412</DrawerTitle> <DrawerDescription> Swipe {direction} to dismiss, or press Esc. </DrawerDescription> </DrawerHeader> <DrawerFooter className="pt-4"> <DrawerClose render={ <Button type="button" variant="outline" /> } > Close </DrawerClose> </DrawerFooter> </div> </DrawerContent> </Drawer> ))} </> );}Snap points and a scrolling list
snapPoints={[0.5, 1]} opens at half height and expands when dragged up; the scrim stays at least half strength. Give the list min-h-0 flex-1 overflow-y-auto so it scrolls inside the panel.
import { Button } from "@oration/canon/components/button";import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@oration/canon/components/drawer";import { toast } from "@oration/canon/components/toast";import { CheckIcon, FilterIcon } from "lucide-react";import * as React from "react";export function SnapPoints() { const suppliers = [ "Bayline Packaging", "Greenway Fleet", "Halcyon", "Kestrel Office Supply", "Northwind Freight", "Orchard Street", "Pioneer Metals", "Summit Janitorial", ]; const [chosen, setChosen] = React.useState<string[]>(["Halcyon"]); return ( <Drawer snapPoints={[0.5, 1]} showSwipeHandle> <DrawerTrigger render={<Button type="button" variant="outline" />}> <FilterIcon data-icon="inline-start" aria-hidden="true" /> Filter by supplier </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Filter by supplier</DrawerTitle> <DrawerDescription> Drag up for the full list. {chosen.length} selected. </DrawerDescription> </DrawerHeader> <ul className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2"> {suppliers.map((supplier) => { const on = chosen.includes(supplier); return ( <li key={supplier}> <button type="button" aria-pressed={on} onClick={() => setChosen((all) => on ? all.filter( (s) => s !== supplier, ) : [...all, supplier], ) } className="flex h-11 w-full items-center justify-between rounded-lg px-3 text-left text-sm outline-none transition-colors duration-150 ease-out hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > {supplier} {on ? ( <CheckIcon aria-hidden="true" className="size-4 text-primary" /> ) : null} </button> </li> ); })} </ul> <DrawerFooter className="border-t border-border pt-4"> <DrawerClose render={ <Button type="button" onClick={() => toast.add({ title: `Showing ${chosen.length} ${chosen.length === 1 ? "supplier" : "suppliers"}`, }) } /> } > Show invoices </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> );}States#
| State | Treatment |
|---|---|
| Closed | Nothing is rendered. |
| Opening | Slides fully in from its edge over 450ms on cubic-bezier(0.22, 1, 0.36, 1); the scrim fades in over 450ms on the drawer curve. |
| Open | Modal by default: focus trapped, page inert. Text inside is selectable until a drag starts. |
| Swiping | Follows the pointer with no transition, and the scrim fades in proportion. Text selection is turned off. |
| Snapped | With snapPoints, rests at the nearest point on release. A fast flick can skip points unless snapToSequentialPoints is set. |
| Nested open | When a drawer opens inside it, the parent scales down by 5%, peeks 1rem, dims to 95% brightness and fades its content out. |
| Closing | Slides back out, in time with the release velocity when swiped. |
Behavior#
- Built on Base UI Drawer.
DrawerTriggeropens it; a swipe towardswipeDirection, Esc, a click on the scrim or anyDrawerClosecloses it. - There is no close button. Show the swipe handle with
showSwipeHandle, add a Close in the footer, or both. swipeDirectionsets the edge:down(the default) enters from the bottom,upfrom the top,leftandrightfrom the sides.snapPointstakes fractions of the viewport height (0 to 1), pixel numbers orpxandremstrings. Control the current point withsnapPointandonSnapPointChange.modal={false}leaves the page interactive and drops the scrim.- Focus moves in on open and returns to the trigger on close, as with Dialog.
- A 3rem bleed in the panel color extends past the edge, so over-dragging never shows a gap.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer (this page) | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
Edit memo
Edit memo
Content#
- Titles are short and name the task: Approve INV-20931, Filter invoices.
- The description gives the one fact needed to decide: $18,240.00 to Northwind Freight, due Friday.
- Footer buttons follow Button: verb first, and Cancel or Close last.
Accessibility#
- The popup is
role="dialog", labelled byDrawerTitleand described byDrawerDescription. Always render a title. - Swiping is never the only way out: Esc and the scrim close it, and a Close button in the footer serves people who can't drag.
- The swipe handle is
aria-hidden; it's a visual cue only. - Focus is trapped while it's modal and returns to the trigger on close.
- Hit areas in the footer are full width on phones and at least 32px tall.
| Keys | Action |
|---|---|
| Enter | Opens it from the trigger. |
| Tab | Moves through the controls inside. |
| Esc | Closes it and returns focus. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Panel fill and the bleed past the edge |
--border | The hairline along the free edge |
--muted | The swipe handle pill |
--muted-foreground | DrawerDescription |
--radius-xl | 12px corners on the free edge |
bg-black/25 | The scrim, bg-black/55 in dark |
--drawer-swipe-progress | Set by Base UI while dragging; drives the scrim opacity |
API reference#
Drawer
The root. Holds open and snap state.
Other props spread onto Base UI Drawer.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes, including by swipe. |
swipeDirection | "up" | "down" | "left" | "right" | "down" | The direction that dismisses it, and so its edge. |
showSwipeHandle | boolean | false | Draws the handle pill at the grabbing edge. |
snapPoints | (number | string)[] | No default | Resting positions: fractions of the viewport, pixels, or px and rem strings. |
snapPoint | number | string | null | No default | The current snap point, when controlled. |
defaultSnapPoint | number | string | null | No default | The starting snap point, when uncontrolled. |
onSnapPointChange | (snapPoint: number | string | null, eventDetails) => void | No default | Called when it settles on a new snap point. |
snapToSequentialPoints | boolean | false | Stops a fast flick from skipping snap points. |
modal | boolean | "trap-focus" | true | false keeps the page interactive and hides the scrim. |
disablePointerDismissal | boolean | false | Keeps a click on the scrim from closing it. |
DrawerTrigger
The button that opens it.
Other props spread onto Base UI Drawer.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own button, such as <Button variant="outline" />. |
DrawerContent
The panel, rendered in a portal with the viewport, the scrim (when modal) and the handle.
Other props spread onto Base UI Drawer.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged onto the popup. Set [--drawer-height:…] for a fixed height. |
DrawerHeader
16px padding with none below; centered for vertical drawers on phones.
Other props spread onto <div>.
No props of its own.
DrawerTitle
Labels the drawer.
Other props spread onto Base UI Drawer.Title (<h2>).
No props of its own.
DrawerDescription
Describes the drawer.
Other props spread onto Base UI Drawer.Description (<p>).
No props of its own.
DrawerClose
Closes the drawer. Render a button through it.
Other props spread onto Base UI Drawer.Close (<button>).
No props of its own.
DrawerSwipeHandle
The handle pill. Rendered by DrawerContent when showSwipeHandle is set.
Other props spread onto <div>.
No props of its own.
DrawerOverlay
The scrim. Rendered by DrawerContent when modal.
Other props spread onto Base UI Drawer.Backdrop.
No props of its own.
DrawerPortal
Portals to the body. Rendered by DrawerContent.
Other props spread onto Base UI Drawer.Portal.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
No file in apps/web imports it; every edge panel in the product is a Sheet. Hence experimental.
The panel takes its edge from a CSS border and has no shadow or ring. DESIGN.md gives dialogs and sheets the dialog shadow, and the Hairline-and-Lift Rule rules out a border as a raised surface's edge.
It opens over 450ms on its own curve, cubic-bezier(0.22, 1, 0.36, 1). DESIGN.md caps state changes at 320ms and gives edge panels the drawer curve.
Reduced motion isn't handled. The slide is driven by its own transform variables, which the global reduced-motion rule doesn't zero, so the drawer still slides.
There is no close button and no prop for one, unlike Dialog and Sheet.
The swipe handle pill is Well Gray on Popover White, which is very faint in light mode.