A panel that slides in from the bottom, left or right edge, drags away, and handles focus like a dialog.
pnpm dlx shadcn@latest add @latheui/drawerFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { AnimatePresence, motion, useDragControls, type PanInfo } from "motion/react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { fade, spring, useMotionSafe } from "@/lib/motion"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
type Side = "bottom" | "left" | "right"
const DrawerContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
}>({ open: false, setOpen: () => {} })
export type DrawerProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Root>,
"open" | "defaultOpen" | "onOpenChange"
> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
function Drawer({ open: openProp, defaultOpen = false, onOpenChange, ...props }: DrawerProps) {
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
return (
<DrawerContext.Provider value={{ open, setOpen }}>
<DialogPrimitive.Root open={open} onOpenChange={setOpen} {...props} />
</DrawerContext.Provider>
)
}
function DrawerTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="drawer-close" {...props} />
}
const layout = {
bottom: {
hidden: { y: "100%" },
axis: "y",
sign: 1,
className:
"inset-x-0 bottom-0 mx-auto max-h-[88dvh] w-full max-w-xl rounded-t-xl border-x border-t pb-[env(safe-area-inset-bottom)]",
},
left: {
hidden: { x: "-100%" },
axis: "x",
sign: -1,
className: "inset-y-0 left-0 h-full w-[min(22rem,calc(100vw-3rem))] border-r",
},
right: {
hidden: { x: "100%" },
axis: "x",
sign: 1,
className: "inset-y-0 right-0 h-full w-[min(22rem,calc(100vw-3rem))] border-l",
},
} as const
export type DrawerContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & {
/** The edge the drawer slides from. */
side?: Side
/** Distance in pixels past which releasing a drag closes the drawer. */
dismissThreshold?: number
}
function DrawerContent({
side = "bottom",
dismissThreshold = 110,
className,
children,
...props
}: DrawerContentProps) {
const { open, setOpen } = React.useContext(DrawerContext)
const { reduced, safe } = useMotionSafe()
const dragControls = useDragControls()
const { hidden, axis, sign, className: sideClass } = layout[side]
function onDragEnd(_: unknown, info: PanInfo) {
const offset = info.offset[axis] * sign
const velocity = info.velocity[axis] * sign
if (offset > dismissThreshold || velocity > 600) setOpen(false)
}
return (
<AnimatePresence>
{open && (
<DialogPrimitive.Portal forceMount>
<DialogPrimitive.Overlay {...slot} forceMount>
<motion.div
data-slot="drawer-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fade}
className="fixed inset-0 z-50 bg-black/40 dark:bg-black/60"
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content {...slot} forceMount {...props}>
<motion.div
data-slot="drawer-content"
data-side={side}
initial={reduced ? { opacity: 0 } : hidden}
animate={reduced ? { opacity: 1 } : { x: 0, y: 0 }}
exit={reduced ? { opacity: 0 } : hidden}
transition={safe(spring.gentle)}
drag={axis}
// Dragging starts from the handle, so the body can still scroll and select.
dragListener={false}
dragControls={dragControls}
dragConstraints={{ top: 0, bottom: 0, left: 0, right: 0 }}
dragElastic={{
top: side === "bottom" ? 0.04 : 0,
bottom: side === "bottom" ? 1 : 0,
left: side === "left" ? 1 : side === "right" ? 0.04 : 0,
right: side === "right" ? 1 : side === "left" ? 0.04 : 0,
}}
onDragEnd={onDragEnd}
className={cn(
"fixed z-50 flex flex-col border-border bg-popover text-popover-foreground shadow-xl shadow-black/10 outline-none",
sideClass,
className
)}
>
{side === "bottom" && (
<div
aria-hidden="true"
onPointerDown={(event) => dragControls.start(event)}
className="flex h-7 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
>
<span className="h-1 w-9 rounded-full bg-input" />
</div>
)}
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-5 pb-5",
side !== "bottom" && "pt-5"
)}
>
{children}
</div>
</motion.div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)}
</AnimatePresence>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="drawer-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="drawer-footer" className={cn("mt-auto flex flex-col gap-2 pt-2", className)} {...props} />
)
}
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="drawer-title"
className={cn("text-base leading-6 font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm leading-6 text-pretty text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
}Depends on
Also accepts Radix Dialog.Root props.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the drawer opens or closes. |
Also accepts Radix Dialog.Content props.
| Prop | Type | Default | Description |
|---|---|---|---|
| side | "bottom" | "left" | "right" | "bottom" | The edge the drawer slides from. |
| dismissThreshold | number | 110 | Drag distance in pixels past which releasing closes the drawer. |
| className | string | — | Classes for the panel. |
The same parts as Dialog. DrawerTitle is required for an accessible name.
The sheet is centered with a max width on large screens. Remove it for an edge-to-edge sheet.
<DrawerContent className="max-w-none">…</DrawerContent>