Skip to content

Drawer

A bottom sheet with a swipe handle, for touch-first flows.

Category
Overlays
Adoption
Not used yet
import { Drawer } from "@oration/canon/components/drawer";
packages/canon/src/components/drawer.tsx
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 * 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

The footer holds one filled button, the action the drawer exists for. On phones footer buttons stack full width, primary on top.

Exits are faster than enters

A swipe closes it in time with the gesture: the exit runs --drawer-swipe-strength times 400ms, so a fast flick leaves faster than a slow drag.

Anatomy#

Approve invoice?

$18,240.00, due Friday

  1. Scrim. Black at 25% (55% in dark), fading as the drawer is dragged away. With snap points it never drops below 50% of that.
  2. 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.
  3. 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.
  4. Header. DrawerHeader, 16px padding with none below, DrawerTitle (16px, weight 500) and DrawerDescription (14px Slate Meta, balanced). Centered for top and bottom drawers below 768px.
  5. Body. Your content, inside a clipping column. Add min-h-0 flex-1 overflow-y-auto to a wrapper to scroll it.
  6. Footer. DrawerFooter, pushed down with mt-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#

States
StateTreatment
ClosedNothing is rendered.
OpeningSlides 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.
OpenModal by default: focus trapped, page inert. Text inside is selectable until a drag starts.
SwipingFollows the pointer with no transition, and the scrim fades in proportion. Text selection is turned off.
SnappedWith snapPoints, rests at the nearest point on release. A fast flick can skip points unless snapToSequentialPoints is set.
Nested openWhen a drawer opens inside it, the parent scales down by 5%, peeks 1rem, dims to 95% brightness and fades its content out.
ClosingSlides back out, in time with the release velocity when swiped.

Behavior#

  • Built on Base UI Drawer. DrawerTrigger opens it; a swipe toward swipeDirection, Esc, a click on the scrim or any DrawerClose closes it.
  • There is no close button. Show the swipe handle with showSwipeHandle, add a Close in the footer, or both.
  • swipeDirection sets the edge: down (the default) enters from the bottom, up from the top, left and right from the sides.
  • snapPoints takes fractions of the viewport height (0 to 1), pixel numbers or px and rem strings. Control the current point with snapPoint and onSnapPointChange.
  • 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.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA 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 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA 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 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, 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 6remA swipe, Esc, the scrim

Do and don't#

Edit memo

Do. Show the swipe handle, and add a Close button when the drawer holds a form.

Edit memo

Don't. Ship a drawer with no handle and no Close. Nothing on screen says how to get out.
Do. Use a drawer on touch screens and a sheet or dialog on desktop for the same task.
Don't. Use a bottom drawer for record details on a 1440px screen. It covers the list the details belong to.

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 by DrawerTitle and described by DrawerDescription. 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.
Keyboard interactions
KeysAction
EnterOpens it from the trigger.
TabMoves through the controls inside.
EscCloses it and returns focus.

Design tokens#

Design tokens
TokenUsed for
--popoverPanel fill and the bleed past the edge
--borderThe hairline along the free edge
--mutedThe swipe handle pill
--muted-foregroundDrawerDescription
--radius-xl12px corners on the free edge
bg-black/25The scrim, bg-black/55 in dark
--drawer-swipe-progressSet 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.

Props of Drawer
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes, including by swipe.
swipeDirection"up" | "down" | "left" | "right""down"The direction that dismisses it, and so its edge.
showSwipeHandlebooleanfalseDraws the handle pill at the grabbing edge.
snapPoints(number | string)[]No defaultResting positions: fractions of the viewport, pixels, or px and rem strings.
snapPointnumber | string | nullNo defaultThe current snap point, when controlled.
defaultSnapPointnumber | string | nullNo defaultThe starting snap point, when uncontrolled.
onSnapPointChange(snapPoint: number | string | null, eventDetails) => voidNo defaultCalled when it settles on a new snap point.
snapToSequentialPointsbooleanfalseStops a fast flick from skipping snap points.
modalboolean | "trap-focus"truefalse keeps the page interactive and hides the scrim.
disablePointerDismissalbooleanfalseKeeps a click on the scrim from closing it.

DrawerTrigger

The button that opens it.

Other props spread onto Base UI Drawer.Trigger (<button>).

Props of DrawerTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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.

Props of DrawerContent
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.

DrawerFooter

Pushed to the bottom, stacked buttons.

Other props spread onto <div>.

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.