A panel that slides in from any edge and follows your finger, with snap points, a working handle and nested drawers that stack.
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { NumberFlow } from "@/components/ui/number-flow"
export function DrawerDemo() {
const [goal, setGoal] = React.useState(350)
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Set daily goal
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Move goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="flex items-center justify-center gap-6 p-6">
<Button
variant="outline"
size="icon"
aria-label="Decrease goal"
disabled={goal <= 200}
onClick={() => setGoal(goal - 10)}
>
<IconMinus />
</Button>
<div className="flex min-w-32 flex-col items-center">
<NumberFlow
value={goal}
className="text-6xl font-semibold tracking-tight"
/>
<span className="text-xs text-muted-foreground uppercase">
Calories / day
</span>
</div>
<Button
variant="outline"
size="icon"
aria-label="Increase goal"
disabled={goal >= 600}
onClick={() => setGoal(goal + 10)}
>
<IconPlus />
</Button>
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Save goal</DrawerClose>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/drawer.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react cn - 3
Copy and paste the following code into your project.
components/ui/drawer.tsx "use client" import * as React from "react" import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer" import { cn } from "cn" import { DialogLayerProvider, useDialogLayer } from "@/components/ui/sheet" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type DrawerSwipeDirection = NonNullable< DrawerPrimitive.Root.Props["swipeDirection"] > type DrawerStack = { swipeDirection: DrawerSwipeDirection register: () => () => void } type DrawerContextValue = { modal: DrawerPrimitive.Root.Props["modal"] swipeDirection: DrawerSwipeDirection showSwipeHandle: boolean hasSnapPoints: boolean stacked: boolean stackedInParent: boolean registerWithParent: (() => () => void) | null } const DrawerStackContext = React.createContext<DrawerStack | null>(null) const DrawerContext = React.createContext<DrawerContextValue | null>(null) function useDrawer(part: string) { const context = React.useContext(DrawerContext) if (!context) { throw new Error(`<${part}> must be used within <Drawer>.`) } return context } function Drawer<Payload>({ modal = true, swipeDirection = "down", showSwipeHandle, snapPoints, children, ...props }: DrawerPrimitive.Root.Props<Payload> & { showSwipeHandle?: boolean }) { const parent = React.useContext(DrawerStackContext) const [stackedChildren, setStackedChildren] = React.useState(0) const register = React.useCallback(() => { setStackedChildren((count) => count + 1) return () => setStackedChildren((count) => count - 1) }, []) const stack = React.useMemo<DrawerStack>( () => ({ swipeDirection, register }), [swipeDirection, register] ) const stackedInParent = parent?.swipeDirection === swipeDirection const registerWithParent = stackedInParent ? parent.register : null const hasSnapPoints = snapPoints != null && snapPoints.length > 0 const axisIsVertical = swipeDirection === "down" || swipeDirection === "up" const context = React.useMemo<DrawerContextValue>( () => ({ modal, swipeDirection, showSwipeHandle: showSwipeHandle ?? axisIsVertical, hasSnapPoints, stacked: stackedChildren > 0, stackedInParent, registerWithParent, }), [ modal, swipeDirection, showSwipeHandle, axisIsVertical, hasSnapPoints, stackedChildren, stackedInParent, registerWithParent, ] ) return ( <DialogLayerProvider> <DrawerStackContext.Provider value={stack}> <DrawerContext.Provider value={context}> <DrawerPrimitive.Root data-slot="drawer" modal={modal} swipeDirection={swipeDirection} snapPoints={snapPoints} {...props} > {children} </DrawerPrimitive.Root> </DrawerContext.Provider> </DrawerStackContext.Provider> </DialogLayerProvider> ) } function DrawerTrigger<Payload>(props: DrawerPrimitive.Trigger.Props<Payload>) { return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> } function DrawerPortal(props: DrawerPrimitive.Portal.Props) { return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> } function DrawerClose(props: DrawerPrimitive.Close.Props) { return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> } function DrawerVirtualKeyboardProvider( props: DrawerPrimitive.VirtualKeyboardProvider.Props ) { return <DrawerPrimitive.VirtualKeyboardProvider {...props} /> } function DrawerOverlay({ className, forceRender, ...props }: DrawerPrimitive.Backdrop.Props) { const { hasSnapPoints, stackedInParent } = useDrawer("DrawerOverlay") const nested = useDialogLayer() > 1 const layered = nested && !stackedInParent return ( <DrawerPrimitive.Backdrop data-slot="drawer-overlay" data-nested={layered ? "" : undefined} data-snap-points={hasSnapPoints ? "" : undefined} forceRender={forceRender ?? layered} className={mergeClassName( cn( "fixed inset-0 z-50 min-h-dvh opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress,0)))] transition-opacity duration-300 ease-drawer select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute [&[data-swiping]:not([data-ending-style])]:duration-0", layered ? "bg-black/20 dark:bg-black/30" : "bg-black/40 dark:bg-black/60" ), className )} {...props} /> ) } function DrawerSwipeHandle({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-swipe-handle" aria-hidden="true" className={cn( "group/drawer-handle relative z-10 flex shrink-0 cursor-grab touch-none transition-opacity duration-200 ease-out-cubic group-data-[stack]/drawer-popup:group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-[swipe-axis=x]/drawer-popup:w-4 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-4 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted-foreground/30 after:transition-colors after:duration-150 group-data-[swipe-axis=x]/drawer-popup:after:h-10 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-10 hover:after:bg-muted-foreground/50 active:cursor-grabbing active:after:bg-muted-foreground/60", className )} {...props} /> ) } function DrawerStackRegistration({ register }: { register: () => () => void }) { React.useLayoutEffect(() => register(), [register]) return null } const drawerPopupClassName = cn( "group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,height,opacity] duration-300 ease-drawer outline-none [interpolate-size:allow-keywords] focus-visible:outline-hidden data-swiping:cursor-grabbing data-swiping:select-none forced-colors:border", "after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full", "[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-4rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:calc(100dvh-4rem)] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]", "[--bleed:3rem] [--peek:1rem] [--stack-offset:0px] [--stack-progress:clamp(0,var(--drawer-swipe-progress,0),1)] [--stack-scale:calc(1-0.02*max(0,var(--nested-drawers,0)-var(--stack-progress)))] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]", "data-[stack]:[--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] data-[stack]:[--stack-peek-offset:max(0px,calc((var(--nested-drawers,0)-var(--stack-progress))*var(--peek)))] data-[stack]:[--stack-scale:clamp(0,calc(max(0,calc(1-var(--nested-drawers,0)*var(--stack-step)))+var(--stack-step)*var(--stack-progress)),1)] data-[stack]:data-nested-drawer-open:overflow-hidden data-[stack]:data-[swipe-axis=x]:[--stack-offset:calc(var(--stack-peek-offset)+var(--stack-shrink)*100%)] data-[stack]:data-[swipe-axis=y]:[--stack-offset:calc(var(--stack-peek-offset)+var(--stack-shrink)*var(--stack-height))] data-[stack]:data-nested-drawer-open:data-[swipe-axis=y]:h-(--stack-height)", "data-ending-style:transform-(--closed-transform) data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:transform-(--closed-transform) motion-reduce:transition-opacity motion-reduce:[--closed-transform:translate3d(var(--translate-x,0px),var(--translate-y,0px),0)] motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 [&[data-nested-drawer-swiping]:not([data-ending-style])]:duration-0 [&[data-swiping]:not([data-ending-style])]:duration-0", "data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=x]:rtl:flex-row-reverse", "data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:pb-[env(safe-area-inset-bottom)] data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)-var(--stack-offset))] data-[swipe-direction=down]:data-snap-points:pb-[max(env(safe-area-inset-bottom),calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]", "data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:pt-[env(safe-area-inset-top)] data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)+var(--stack-offset))]", "data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:pl-[env(safe-area-inset-left)] data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x,0px)+var(--stack-offset))]", "data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:pr-[env(safe-area-inset-right)] data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x,0px)-var(--stack-offset))]" ) function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) { const { modal, swipeDirection, showSwipeHandle, hasSnapPoints, stacked, registerWithParent, } = useDrawer("DrawerContent") const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x" return ( <DrawerPortal> {modal === true ? <DrawerOverlay /> : null} <DrawerPrimitive.Viewport data-slot="drawer-viewport" data-modal={modal === true ? "" : undefined} className="pointer-events-none fixed inset-0 z-50 data-modal:pointer-events-auto" > <DrawerPrimitive.Popup data-slot="drawer-popup" data-swipe-axis={swipeAxis} data-snap-points={hasSnapPoints ? "" : undefined} data-stack={stacked ? "" : undefined} className={mergeClassName(drawerPopupClassName, className)} {...props} > {registerWithParent ? ( <DrawerStackRegistration register={registerWithParent} /> ) : null} {showSwipeHandle ? <DrawerSwipeHandle /> : null} <DrawerPrimitive.Content data-slot="drawer-content" className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-out-cubic select-text group-data-swiping/drawer-popup:select-none [[data-stack][data-nested-drawer-open]:not([data-nested-drawer-swiping])>&]:opacity-0" > {children} </DrawerPrimitive.Content> </DrawerPrimitive.Popup> </DrawerPrimitive.Viewport> </DrawerPortal> ) } function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-header" className={cn( "flex shrink-0 flex-col gap-1.5 p-4 pb-0 text-start group-data-[swipe-axis=y]/drawer-popup:max-sm:text-center", className )} {...props} /> ) } function DrawerBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-body" className={cn( "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4 wrap-anywhere", className )} {...props} /> ) } function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-footer" className={cn( "mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0 max-sm:[&>*:not([data-slot=button-status])]:h-10", className )} {...props} /> ) } function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) { return ( <DrawerPrimitive.Title data-slot="drawer-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere text-foreground", className )} {...props} /> ) } function DrawerDescription({ className, ...props }: DrawerPrimitive.Description.Props) { return ( <DrawerPrimitive.Description data-slot="drawer-description" className={mergeClassName( "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } const createDrawerHandle = DrawerPrimitive.createHandle export { Drawer, DrawerPortal, DrawerOverlay, DrawerSwipeHandle, DrawerTrigger, DrawerClose, DrawerVirtualKeyboardProvider, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, DrawerTitle, DrawerDescription, createDrawerHandle, } export type { DrawerSwipeDirection }components/ui/sheet.tsx "use client" import * as React from "react" import { Drawer as SheetPrimitive } from "@base-ui/react/drawer" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const DialogLayerContext = React.createContext(0) function DialogLayerProvider({ children }: { children: React.ReactNode }) { const depth = React.useContext(DialogLayerContext) return ( <DialogLayerContext.Provider value={depth + 1}> {children} </DialogLayerContext.Provider> ) } function useDialogLayer() { return React.useContext(DialogLayerContext) } type SwipeDirection = "up" | "down" | "left" | "right" const SheetRootContext = React.createContext< ((direction: SwipeDirection) => void) | null >(null) function Sheet<Payload>({ swipeDirection, ...props }: SheetPrimitive.Root.Props<Payload>) { const [contentDirection, setContentDirection] = React.useState<SwipeDirection>("right") return ( <DialogLayerProvider> <SheetRootContext.Provider value={setContentDirection}> <SheetPrimitive.Root data-slot="sheet" swipeDirection={swipeDirection ?? contentDirection} {...props} /> </SheetRootContext.Provider> </DialogLayerProvider> ) } function SheetTrigger<Payload>(props: SheetPrimitive.Trigger.Props<Payload>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> } function SheetPortal(props: SheetPrimitive.Portal.Props) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetClose(props: SheetPrimitive.Close.Props) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> } const sheetOverlayVariants = cva( "fixed inset-0 z-50 min-h-dvh opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:duration-200 sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:ease-out-quint sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:data-ending-style:duration-150 [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { nested: { false: "bg-black/40 dark:bg-black/60", true: "bg-black/20 dark:bg-black/30", }, }, defaultVariants: { nested: false, }, } ) function SheetOverlay({ className, forceRender, ...props }: SheetPrimitive.Backdrop.Props) { const nested = useDialogLayer() > 1 return ( <SheetPrimitive.Backdrop data-slot="sheet-overlay" data-nested={nested ? "" : undefined} forceRender={forceRender ?? nested} className={mergeClassName(sheetOverlayVariants({ nested }), className)} {...props} /> ) } const sheetContentVariants = cva( "fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-none bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,translate,scale,opacity,top] duration-300 ease-drawer outline-none [--bleed:3rem] focus-visible:outline-hidden data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-nested-dialog-open:scale-[calc(1-0.02*var(--nested-dialogs))] data-nested-drawer-open:scale-[calc(1-0.02*var(--nested-drawers))] data-swiping:cursor-grabbing data-swiping:select-none motion-reduce:transition-opacity motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 rtl:[--sheet-dir:-1] forced-colors:border [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { side: { top: "inset-x-0 top-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-b-2xl pt-(--bleed) [--sheet-close-top:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-y-[calc(-100%+var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(-100%+var(--bleed))]", right: "inset-y-0 end-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-s-2xl pe-(--bleed) [--sheet-close-end:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] sm:max-w-[calc(24rem+var(--bleed))]", bottom: "inset-x-0 bottom-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl pb-[calc(var(--bleed)+env(safe-area-inset-bottom))] [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-y-[calc(100%-var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(100%-var(--bleed))]", left: "inset-y-0 start-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-e-2xl ps-(--bleed) [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] sm:max-w-[calc(24rem+var(--bleed))]", }, }, defaultVariants: { side: "right", }, } ) const sheetHandleVariants = cva( "group/sheet-handle absolute z-10 flex cursor-grab touch-none items-center justify-center active:cursor-grabbing", { variants: { side: { top: "inset-x-0 bottom-0 h-6", right: "inset-y-0 start-0 w-6", bottom: "inset-x-0 top-0 h-6", left: "inset-y-0 end-0 w-6", }, }, } ) const sheetHandleBarVariants = cva( "rounded-full bg-muted-foreground/30 transition-colors duration-150 group-hover/sheet-handle:bg-muted-foreground/50 group-active/sheet-handle:bg-muted-foreground/60", { variants: { side: { top: "h-1 w-10", right: "h-10 w-1", bottom: "h-1 w-10", left: "h-10 w-1", }, }, } ) type SheetSide = NonNullable<VariantProps<typeof sheetContentVariants>["side"]> const SheetContentContext = React.createContext<{ showCloseButton: boolean }>({ showCloseButton: false }) function resolveSwipeDirection(side: SheetSide, rtl: boolean): SwipeDirection { if (side === "top") { return "up" } if (side === "bottom") { return "down" } return (side === "right") !== rtl ? "right" : "left" } function SheetContent({ className, children, side = "right", showCloseButton = true, dir, ...props }: SheetPrimitive.Popup.Props & VariantProps<typeof sheetContentVariants> & { showCloseButton?: boolean }) { const resolvedSide = side ?? "right" const setSwipeDirection = React.useContext(SheetRootContext) const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton]) React.useEffect(() => { const rtl = (dir ?? getComputedStyle(document.documentElement).direction) === "rtl" setSwipeDirection?.(resolveSwipeDirection(resolvedSide, rtl)) }, [dir, resolvedSide, setSwipeDirection]) return ( <SheetContentContext.Provider value={context}> <SheetPortal> <SheetOverlay /> <SheetPrimitive.Viewport data-slot="sheet-viewport" dir={dir} className="fixed inset-0 z-50" > <SheetPrimitive.Popup data-slot="sheet-content" data-side={resolvedSide} dir={dir} className={mergeClassName( sheetContentVariants({ side: resolvedSide }), className )} {...props} > <div data-slot="sheet-handle" aria-hidden="true" className={sheetHandleVariants({ side: resolvedSide })} > <div className={sheetHandleBarVariants({ side: resolvedSide })} /> </div> <SheetPrimitive.Content data-slot="sheet-inner" className="flex min-h-0 flex-1 flex-col gap-4" > {children} </SheetPrimitive.Content> {showCloseButton ? ( <SheetPrimitive.Close data-slot="sheet-close-button" aria-label="Close" render={<Button variant="ghost" size="icon-sm" />} className="absolute end-(--sheet-close-end,0.75rem) top-(--sheet-close-top,0.75rem) z-20 rounded-(--sheet-close-radius,var(--radius-md)) text-muted-foreground hover:text-foreground" > <IconX /> </SheetPrimitive.Close> ) : null} </SheetPrimitive.Popup> </SheetPrimitive.Viewport> </SheetPortal> </SheetContentContext.Provider> ) } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { const { showCloseButton } = React.useContext(SheetContentContext) return ( <div data-slot="sheet-header" className={cn( "flex flex-col gap-1.5 px-6 pt-6", showCloseButton && "pe-12", className )} {...props} /> ) } function SheetBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-body" className={cn( "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 wrap-anywhere", className )} {...props} /> ) } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn( "mt-auto flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10", className )} {...props} /> ) } function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere", className )} {...props} /> ) } function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={mergeClassName( "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } const createSheetHandle = SheetPrimitive.createHandle export { Sheet, SheetTrigger, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, SheetClose, sheetContentVariants, sheetOverlayVariants, createSheetHandle, DialogLayerProvider, useDialogLayer, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions } - 4
Update the import paths to match your project setup.
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>Open</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>This action cannot be undone.</DrawerDescription>
</DrawerHeader>
<DrawerBody>{/* content */}</DrawerBody>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
├── DrawerBody
└── DrawerFooter
└── DrawerCloseDirections#
Set swipeDirection on <Drawer /> to pick the edge. The drawer opens from that edge and swipes back towards it. Top and bottom drawers show a handle by default.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const directions = [
{ value: "up", label: "Top" },
{ value: "right", label: "Right" },
{ value: "down", label: "Bottom" },
{ value: "left", label: "Left" },
] as const
export function DrawerDirections() {
return (
<div className="flex flex-wrap justify-center gap-2">
{directions.map(({ value, label }) => (
<Drawer key={value} swipeDirection={value}>
<DrawerTrigger render={<Button variant="outline" />}>
{label}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Swipe {value} to close</DrawerTitle>
<DrawerDescription>
The drawer follows your finger and keeps the momentum when you
let go.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
)
}Snap points#
Pass snapPoints to rest a bottom drawer at preset heights. Numbers from 0 to 1 are fractions of the viewport, larger numbers are pixels, and strings take px or rem. The visible part always fits its content, so nothing hides below the screen.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const snapPoints = ["20rem", 1]
const places = [
"Blue Bottle Coffee",
"Tartine Bakery",
"Mission Dolores Park",
"Ferry Building",
"Golden Gate Park",
"Lands End Trail",
"Twin Peaks",
"Coit Tower",
"Alamo Square",
"Baker Beach",
"Crissy Field",
"Japanese Tea Garden",
]
export function DrawerSnapPoints() {
return (
<Drawer snapPoints={snapPoints}>
<DrawerTrigger render={<Button variant="outline" />}>
Nearby places
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Nearby places</DrawerTitle>
<DrawerDescription>
Drag up to see the full list, or down to close.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<ul className="flex flex-col divide-y">
{places.map((place) => (
<li key={place} className="py-3 text-sm">
{place}
</li>
))}
</ul>
</DrawerBody>
</DrawerContent>
</Drawer>
)
}Scrollable content#
<DrawerBody /> scrolls on its own, so the header and footer stay in place. Swiping only starts once the body is scrolled back to the top.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const sections = Array.from({ length: 12 }, (_, index) => index + 1)
export function DrawerScrollable() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Terms of service
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Terms of service</DrawerTitle>
<DrawerDescription>Last updated October 2026.</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
{sections.map((section) => (
<p key={section}>
{section}. By using the service you agree to these terms. We may
update them from time to time, and we will let you know when
something important changes. Keep your account details safe and
tell us if you think someone else has access.
</p>
))}
</div>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button />}>Accept</DrawerClose>
<DrawerClose render={<Button variant="outline" />}>
Decline
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Forms and the keyboard#
Wrap <DrawerContent /> in <DrawerVirtualKeyboardProvider /> when a bottom drawer holds text fields. On phones, the focused field scrolls into view above the software keyboard instead of hiding behind it. Keep the fields in <DrawerBody /> so the header and footer stay put.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerVirtualKeyboardProvider,
} from "@/components/ui/drawer"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function DrawerKeyboard() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Delivery details
</DrawerTrigger>
<DrawerVirtualKeyboardProvider>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Delivery details</DrawerTitle>
<DrawerDescription>
Tap a field on your phone. It stays above the keyboard.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<FieldGroup>
<Field>
<FieldLabel>Name</FieldLabel>
<Input name="name" autoComplete="name" />
</Field>
<Field>
<FieldLabel>Phone</FieldLabel>
<Input name="phone" type="tel" autoComplete="tel" />
</Field>
<Field>
<FieldLabel>Street address</FieldLabel>
<Input name="street" autoComplete="street-address" />
</Field>
<Field>
<FieldLabel>Apartment</FieldLabel>
<Input name="apartment" />
</Field>
<Field>
<FieldLabel>City</FieldLabel>
<Input name="city" autoComplete="address-level2" />
</Field>
<Field>
<FieldLabel>Postal code</FieldLabel>
<Input name="postal" autoComplete="postal-code" />
</Field>
</FieldGroup>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button />}>Save address</DrawerClose>
</DrawerFooter>
</DrawerContent>
</DrawerVirtualKeyboardProvider>
</Drawer>
)
}Nested#
A drawer opened from a drawer on the same edge stacks on top. The ones behind shrink, peek out above it and follow your finger as you swipe the top one away.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerNested() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Choose a plan
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Choose a plan</DrawerTitle>
<DrawerDescription>
Compare plans, then open the details of the one you like.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Drawer>
<DrawerTrigger render={<Button />}>Pro plan details</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Pro plan</DrawerTitle>
<DrawerDescription>
The first drawer steps back behind this one.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Drawer>
<DrawerTrigger render={<Button />}>Checkout</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Checkout</DrawerTitle>
<DrawerDescription>
Swipe down to go back one step.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Back
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Back
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Confirm from a drawer#
Dialogs, alert dialogs, sheets and drawers on another edge layer on top instead of stacking. The drawer steps back and a lighter backdrop covers it.
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerConfirm() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Photo options
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>IMG_2048.heic</DrawerTitle>
<DrawerDescription>Taken on October 3, 2026.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>Share</DrawerClose>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Delete photo
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete “IMG_2048.heic”?</AlertDialogTitle>
<AlertDialogDescription>
It will be removed from all your devices.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Responsive#
Change swipeDirection with a media query to show a side panel on desktop and a bottom sheet on phones.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const query = "(min-width: 768px)"
function subscribe(onChange: () => void) {
const media = window.matchMedia(query)
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}
function useIsDesktop() {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false
)
}
export function DrawerResponsive() {
const isDesktop = useIsDesktop()
return (
<Drawer swipeDirection={isDesktop ? "right" : "down"}>
<DrawerTrigger render={<Button variant="outline" />}>
Notifications
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Notifications</DrawerTitle>
<DrawerDescription>
A side panel on desktop and a bottom sheet on phones.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Non-modal#
With modal={false} there is no backdrop, the page keeps scrolling and focus can leave the drawer.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerNonModal() {
return (
<Drawer modal={false} swipeDirection="right">
<DrawerTrigger render={<Button variant="outline" />}>
Open inspector
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Inspector</DrawerTitle>
<DrawerDescription>
The page stays scrollable and clickable while this is open.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Controlled#
Pass open and onOpenChange to open it from anywhere, without a trigger.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
export function DrawerControlled() {
const [open, setOpen] = React.useState(false)
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
Open from anywhere
</Button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Controlled drawer</DrawerTitle>
<DrawerDescription>
Opened from a plain button, without a trigger.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}Detached triggers#
Share one drawer between several triggers with createDrawerHandle. Each trigger passes a payload that the drawer renders through a function child.
"use client"
import { Button } from "@/components/ui/button"
import {
createDrawerHandle,
Drawer,
DrawerBody,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const contacts = createDrawerHandle<{ name: string; email: string }>()
export function DrawerDetached() {
return (
<div className="flex flex-wrap justify-center gap-2">
<DrawerTrigger
handle={contacts}
payload={{ name: "Olivia Martin", email: "[email protected]" }}
render={<Button variant="outline" size="sm" />}
>
Olivia
</DrawerTrigger>
<DrawerTrigger
handle={contacts}
payload={{ name: "Jackson Lee", email: "[email protected]" }}
render={<Button variant="outline" size="sm" />}
>
Jackson
</DrawerTrigger>
<Drawer handle={contacts}>
{({ payload }) => (
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{payload?.name}</DrawerTitle>
<DrawerDescription>{payload?.email}</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<p className="text-sm text-muted-foreground">
One drawer, opened by either trigger.
</p>
</DrawerBody>
</DrawerContent>
)}
</Drawer>
</div>
)
}Right to left#
Pass dir="rtl" to <DrawerContent /> to mirror its content. swipeDirection names a physical edge, so "left" stays on the left and the handle stays on the inner edge.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerRtl() {
return (
<div dir="rtl" className="flex flex-wrap justify-center gap-2">
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
من الأسفل
</DrawerTrigger>
<DrawerContent dir="rtl">
<DrawerHeader>
<DrawerTitle>تعديل الملف الشخصي</DrawerTitle>
<DrawerDescription>
يتم حفظ التغييرات عند النقر على حفظ.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button />}>حفظ</DrawerClose>
<DrawerClose render={<Button variant="outline" />}>
إلغاء
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer swipeDirection="left" showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
من اليسار
</DrawerTrigger>
<DrawerContent dir="rtl">
<DrawerHeader>
<DrawerTitle>القائمة</DrawerTitle>
<DrawerDescription>اسحب إلى اليسار للإغلاق.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
إغلاق
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
)
}| Key | Action |
|---|---|
| EnterSpace | On the trigger, opens the drawer and moves focus inside it. |
| TabShift + Tab | Moves between focusable elements. Focus stays inside a modal drawer. |
| Esc | Closes the topmost drawer and returns focus to its trigger. |
- The drawer is a dialog.
<DrawerTitle />labels it and<DrawerDescription />describes it, so always include a title. - Swiping is never the only way out: Escape, the backdrop and a
<DrawerClose />button all close it too. - The handle is decorative and hidden from assistive tech. With a mouse, text inside the drawer can be selected without dragging it.
- With reduced motion enabled, the drawer fades in and out instead of sliding. Dragging still follows the pointer.
Built on the Base UI drawer. Every part accepts the props of the primitive it wraps.
| Prop | Type | Default |
|---|---|---|
swipeDirectionThe edge it opens from and the direction that dismisses it. | "up" | "down" | "left" | "right" | "down" |
showSwipeHandleShows the handle. Defaults to true for up and down, false for left and right. | boolean | – |
snapPointsHeights a vertical drawer can rest at. 0–1 is a fraction of the viewport, >1 is pixels, strings take px or rem. | (number | string)[] | – |
snapPoint | number | string | null | – |
defaultSnapPoint | number | string | null | – |
onSnapPointChange | (snapPoint, details) => void | – |
defaultOpen | boolean | false |
open | boolean | – |
onOpenChange | (open: boolean, details) => void | – |
onOpenChangeCompleteCalled after the open or close animation ends. | (open: boolean) => void | – |
modalThe backdrop only renders when true. | boolean | "trap-focus" | true |
disablePointerDismissalKeep it open when the backdrop is clicked. | boolean | false |
handle | DrawerHandle<Payload> | – |
children | ReactNode | ({ payload }) => ReactNode | – |
| Prop | Type | Default |
|---|---|---|
handle | DrawerHandle<Payload> | – |
payload | Payload | – |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="drawer-trigger" | Target the trigger in CSS. |
data-popup-open | Present while its drawer is open. |
Renders the portal, backdrop, viewport and popup, plus the handle. Vertical drawers fit their content up to the viewport height minus 4rem. Side drawers are 75% wide, up to 24rem from the sm breakpoint. Override with h-* or w-*, or scope it to one axis with data-[swipe-axis=y]:.
| Prop | Type | Default |
|---|---|---|
initialFocus | boolean | RefObject | (type) => HTMLElement | boolean | – |
finalFocus | boolean | RefObject | (type) => HTMLElement | boolean | – |
className | string | (state) => string | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="drawer-popup" | The drawer panel. |
data-slot="drawer-content" | The inner wrapper around your children. It scrolls when nothing else does. |
data-swipe-direction | up, right, down or left. |
data-swipe-axis | x or y. |
data-open | Present while the drawer is open. |
data-starting-style | Present while it animates in. |
data-ending-style | Present while it animates out. |
data-swiping | Present while it is being dragged. |
data-snap-points | Present when the drawer has snap points. |
data-expanded | Present at the full-height snap point. |
data-nested-drawer-open | Present while another drawer is open on top. |
data-stack | Present while a drawer on the same edge is stacked on top. |
--drawer-inset | Floats the drawer away from the viewport edges. Defaults to 0px. |
--drawer-bleed-background | Fills the area revealed when dragged past its edge. Defaults to the popover color. |
--drawer-swipe-movement-x | Horizontal drag distance. A -y variable exists too. |
--drawer-snap-point-offset | How far the current snap point sits below the top. |
--nested-drawers | How many drawers are open on top. |
Rendered by <DrawerContent /> when modal is true. It fades as you swipe, and stays at least half visible when there are snap points. A drawer layered over a sheet or a drawer on another edge gets a lighter backdrop.
| Attribute | Description |
|---|---|
data-slot="drawer-overlay" | The backdrop. |
data-nested | Present on lighter backdrops of layered drawers. |
--drawer-overlay-min-opacity | The lowest opacity it fades to while swiping. 0, or 0.5 with snap points. |
Rendered by <DrawerContent /> on the inner edge when showSwipeHandle is on. The whole drawer can be dragged, so the handle is a visual cue.
| Attribute | Description |
|---|---|
data-slot="drawer-swipe-handle" | The handle. |
Plain <div> elements that lay out the drawer. The header centers its text in vertical drawers on small screens, the body scrolls and takes the remaining space, and the footer stacks its actions.
| Attribute | Description |
|---|---|
data-slot="drawer-header" | Title and description. |
data-slot="drawer-body" | Scrollable content. |
data-slot="drawer-footer" | Actions. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <h2> |
| Attribute | Description |
|---|---|
data-slot="drawer-title" | Labels the drawer. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <p> |
| Attribute | Description |
|---|---|
data-slot="drawer-description" | Describes the drawer. |
Renders no element. Place it inside <Drawer />, around <DrawerContent />. While the software keyboard is open, it adds room below the drawer’s scroll container, scrolls the focused field into view, and makes taps on fields open the keyboard on iOS. Drawers without it are unaffected.
| Prop | Type | Default |
|---|---|---|
children | ReactNode | – |
| Attribute | Description |
|---|---|
--drawer-keyboard-inset | Set on the viewport while the keyboard is open: how much of it overlaps the page. Use it with a 0px fallback. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="drawer-close" | Closes the drawer when pressed. |
createDrawerHandle<Payload>() returns a handle that connects a <Drawer /> to triggers rendered elsewhere. Create it once, outside your component.