component — overlays
Drawer
An edge-anchored modal panel you can throw off the screen with a finger: it slides in from any side and drags back out again, built on react-aria's Modal with `motion` for the slide, the drag-to-dismiss gesture and the notch you grab it by, closing on a flick or a quarter-screen pull. It enters and leaves in 0.2s easeInOut, and the scrim's blur tweens with it rather than snapping. Sheet is the same panel without the gesture, and that gesture is the one question that chooses between the two: reach for Drawer where a thumb should be able to dismiss the panel — a bottom sheet on a phone — and for Sheet otherwise, which is the plainer of the two and has the fuller Dialog surface (a close X, `role="alertdialog"`, and Dialog's own header, body and footer).
- overlay
- drawer
- panel
- modal
- motion
- gesture
- drag-to-dismiss
- mobile
03 examples
Default
A bottom drawer with a notch, header, body, and footer.
From the right
Set `side` to slide the panel in from any edge.
Floating
`isFloat` insets the panel from the edges and rules it all round.
source
drawer.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { use } from "react"
import type {
DialogProps,
DialogTriggerProps,
HeadingProps,
ModalOverlayProps,
TextProps,
} from "react-aria-components"
import {
composeRenderProps,
Dialog,
DialogTrigger,
Heading,
ModalOverlay,
Modal as ModalPrimitive,
OverlayTriggerStateContext,
Text,
} from "react-aria-components"
import { cn } from "@/lib/utils"
import { Button, type ButtonProps } from "@/components/button"
/**
* Drawer — quebi design system
*
* A draggable sliding panel that enters from any edge. Built on react-aria's
* Modal/ModalOverlay for accessibility and `motion` for the slide + drag-to-
* dismiss gesture. Composes the same overlay surface tokens as Dialog.
*
* Sheet is this same panel without the gesture, and the gesture is the one
* question that chooses between them: a panel a thumb should be able to throw
* off the screen is a Drawer, and anything else is a Sheet — which is the
* plainer of the two and carries the fuller Dialog surface.
*
* Two shapes, as with Sheet: `Drawer` is react-aria's `DialogTrigger` and takes
* a `DrawerTrigger` plus a `DrawerContent`; when state decides instead — a
* drawer a route opens, say — render `DrawerContent` on its own with
* `isOpen`/`onOpenChange` and leave `Drawer` out.
*
* Surface: bg-quebi-elevated, lifted by `shadow-quebi-float`. Docked to an
* edge it is square and draws one hairline, on the side facing the page;
* floating, it is ruled all round at the surface radius. Titles are Outfit light at the title size.
*/
const DrawerRoot = motion.create(ModalPrimitive)
const DrawerOverlay = motion.create(ModalOverlay)
const Drawer = (props: DialogTriggerProps) => <DialogTrigger {...props} />
interface DrawerContentProps
extends Omit<ModalOverlayProps, "className" | "children" | "isDismissable">,
Pick<DialogProps, "aria-label" | "aria-labelledby" | "role" | "children" | "className"> {
isFloat?: boolean
isBlurred?: boolean
className?: string
side?: "top" | "bottom" | "left" | "right"
notch?: boolean
}
const DrawerContent = ({
side = "bottom",
isFloat = false,
isBlurred = true,
notch = true,
children,
className,
role = "dialog",
// Held back from the overlay's props and handed to the Dialog below: the
// label describes the dialog, not the scrim around it. Same reasoning as
// SheetContent and ModalContent — and until now `aria-label` was declared,
// spread onto the scrim and overruled by a hardcoded "Drawer" on the Dialog,
// so a drawer could not be named at all.
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
...props
}: DrawerContentProps) => {
// A drawer with a trigger reads its open state off the DialogTrigger above
// it; a drawer a route opens has no trigger and controls `isOpen` itself.
// Both are supported shapes, and `DrawerContentProps` has accepted `isOpen`
// all along — this used to throw on the second one rather than use it.
const state = use(OverlayTriggerStateContext)
// Modal guards its scrim with `motion-reduce:backdrop-blur-none`; the blur is
// an animated value here, so the same guard has to be a hook.
const prefersReducedMotion = useReducedMotion()
const blurAmount = isBlurred && !prefersReducedMotion ? "blur(8px)" : "blur(0px)"
return (
<AnimatePresence>
{(props.isOpen ?? state?.isOpen) && (
<DrawerOverlay
isDismissable
isOpen={props.isOpen ?? state?.isOpen}
onOpenChange={props.onOpenChange ?? state?.setOpen}
// The blur is a motion value rather than a `backdrop-blur-sm` class so
// it can tween: as a class it was applied the moment the overlay
// mounted and removed the moment AnimatePresence unmounted it, which
// is the hard cut reported in task #142 — the scrim had already faded
// to transparent while the page was still fully blurred, then snapped
// sharp. 8px is what `backdrop-blur-sm` resolves to, so the drawer
// still matches the Modal's scrim at rest. (Motion writes the
// unprefixed property only; Safari below 18 simply gets no blur.)
initial={{ backgroundColor: "rgba(0, 0, 0, 0)", backdropFilter: "blur(0px)" }}
animate={{
backgroundColor: "rgba(0, 0, 0, 0.6)",
backdropFilter: blurAmount,
}}
exit={{ backgroundColor: "rgba(0, 0, 0, 0)", backdropFilter: "blur(0px)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="fixed inset-0 z-50 will-change-auto [--visual-viewport-vertical-padding:32px]"
>
{({ state }) => (
<DrawerRoot
className={cn(
"fixed max-h-full touch-none overflow-hidden border-quebi-hairline bg-quebi-elevated align-middle text-quebi-fg shadow-quebi-float will-change-transform",
isFloat && "rounded-(--q-radius-surface) border",
side === "top" && (isFloat ? "inset-x-2 top-2" : "inset-x-0 top-0 border-b"),
side === "right" &&
[
"w-full max-w-xs overflow-y-auto",
"**:[[slot=header]]:text-start",
isFloat ? "inset-y-2 right-2" : "inset-y-0 right-0 h-auto border-l",
].join(" "),
side === "bottom" &&
(isFloat ? "inset-x-2 bottom-2" : "inset-x-0 bottom-0 border-t"),
side === "left" &&
[
"w-full max-w-xs overflow-y-auto",
"**:[[slot=header]]:text-start",
isFloat ? "inset-y-2 left-2" : "inset-y-0 left-0 h-auto border-r",
].join(" "),
className,
)}
animate={{ x: 0, y: 0 }}
initial={{
x: side === "left" ? "-100%" : side === "right" ? "100%" : 0,
y: side === "top" ? "-100%" : side === "bottom" ? "100%" : 0,
}}
exit={{
x: side === "left" ? "-100%" : side === "right" ? "100%" : 0,
y: side === "top" ? "-100%" : side === "bottom" ? "100%" : 0,
}}
drag={side === "left" || side === "right" ? "x" : "y"}
whileDrag={{ cursor: "grabbing" }}
dragConstraints={{
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
dragTransition={{
bounceStiffness: 600,
bounceDamping: 20,
}}
// Same length and curve as the overlay above, so the scrim and its
// blur clear exactly when the panel has finished leaving.
transition={{ duration: 0.2, ease: "easeInOut" }}
onDragEnd={(_, { offset, velocity }) => {
if (side === "bottom" && (velocity.y > 150 || offset.y > screen.height * 0.25)) {
state.close()
}
if (side === "top" && (velocity.y < -150 || offset.y < screen.height * 0.25)) {
state.close()
}
if (side === "left" && velocity.x < -150) {
state.close()
}
if (side === "right" && velocity.x > 150) {
state.close()
}
}}
dragElastic={{
top: side === "top" ? 1 : 0,
bottom: side === "bottom" ? 1 : 0,
left: side === "left" ? 1 : 0,
right: side === "right" ? 1 : 0,
}}
dragPropagation
>
<Dialog
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
role={role}
className={cn(
"relative flex flex-col overflow-hidden outline-hidden will-change-auto",
side === "top" || side === "bottom"
? "mx-auto max-h-[calc(var(--visual-viewport-height)-var(--visual-viewport-vertical-padding))] max-w-lg"
: "h-full",
)}
>
{notch && side === "bottom" && (
<div className="notch sticky top-0 mx-auto mt-2.5 h-1.5 w-10 shrink-0 touch-pan-y rounded-full bg-quebi-pressed" />
)}
{children as React.ReactNode}
{notch && side === "top" && (
<div className="notch sticky bottom-0 mx-auto mb-2.5 h-1.5 w-10 shrink-0 touch-pan-y rounded-full bg-quebi-pressed" />
)}
</Dialog>
</DrawerRoot>
)}
</DrawerOverlay>
)}
</AnimatePresence>
)
}
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
slot="header"
className={cn("flex flex-col p-4 text-center sm:text-start", className)}
{...props}
/>
)
}
const DrawerTitle = ({ className, ...props }: HeadingProps) => (
<Heading
slot="title"
className={cn("font-display text-quebi-fg text-quebi-title", className)}
{...props}
/>
)
const DrawerDescription = ({ className, ...props }: TextProps) => (
<Text
slot="description"
className={cn("text-quebi-body-s text-quebi-fg-muted", className)}
{...props}
/>
)
const DrawerBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
slot="body"
className={cn(
"isolate flex max-h-[calc(var(--visual-viewport-height)-var(--visual-viewport-vertical-padding))] flex-col overflow-auto px-4 py-1 will-change-scroll",
className,
)}
{...props}
/>
)
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
slot="footer"
className={cn(
"isolate mt-auto flex flex-col-reverse justify-end gap-2 p-4 sm:flex-row",
className,
)}
{...props}
/>
)
}
const DrawerClose = ({ className, intent = "outline", ref, ...props }: ButtonProps) => {
return <Button slot="close" className={className} ref={ref} intent={intent} {...props} />
}
/**
* The library's Button, matching `DialogTrigger` and `SheetTrigger`. It was
* react-aria's unstyled primitive, which meant every call site put a `Button`
* inside it — a `<button>` inside a `<button>`, and an unstyled one on any
* consumer who took the component at its word.
*/
const DrawerTrigger = ({ className, ...props }: ButtonProps) => (
<Button
className={composeRenderProps(className, (resolved) => cn("cursor-pointer", resolved))}
{...props}
/>
)
export type { DrawerContentProps }
export {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
}