Drawer
A panel that slides in from the edge of the screen, with drag-to-dismiss support on the entire surface.
Installation
Install the component Drawer in your project using the CLI.
Drawer.tsx
pnpm dlx behsseui@latest add DrawerInstall the component manually.
Create a ui folder at the root of the project, then a component folder inside it, and finally a Drawer.tsx file in that folder.
Copy and paste the following code into your project.
ui/components/Drawer.tsx
1"use client"23import type { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from "react"4import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"5import { cn } from "@/lib/utils"67// Context8type DrawerContextType = {9 open: boolean10 onOpenChange: (open: boolean) => void11}1213const DrawerContext = createContext<DrawerContextType | undefined>(undefined)1415function useDrawer() {16 const context = useContext(DrawerContext)17 if (!context) {18 throw new Error("Drawer components must be used within a Drawer")19 }20 return context21}2223// Root component24type DrawerProps = {25 children: ReactNode26 open?: boolean27 defaultOpen?: boolean28 onOpenChange?: (open: boolean) => void29}3031export function Drawer({32 children,33 open: controlledOpen,34 defaultOpen = false,35 onOpenChange,36}: DrawerProps) {37 const [internalOpen, setInternalOpen] = useState(defaultOpen)3839 const isControlled = controlledOpen !== undefined40 const open = isControlled ? controlledOpen : internalOpen4142 const handleOpenChange = useCallback((newOpen: boolean) => {43 if (!isControlled) {44 setInternalOpen(newOpen)45 }46 onOpenChange?.(newOpen)47 }, [isControlled, onOpenChange])4849 return (50 <DrawerContext.Provider value={{ open, onOpenChange: handleOpenChange }}>51 {children}52 </DrawerContext.Provider>53 )54}5556// Trigger57type DrawerTriggerProps = {58 children: ReactNode59 className?: string60 asChild?: boolean61} & ButtonHTMLAttributes<HTMLButtonElement>6263export function DrawerTrigger({ children, className, asChild, ...props }: DrawerTriggerProps) {64 const { onOpenChange } = useDrawer()6566 if (asChild) {67 return (68 <span onClick={() => onOpenChange(true)} className={className}>69 {children}70 </span>71 )72 }7374 return (75 <button76 type="button"77 className={className}78 onClick={() => onOpenChange(true)}79 {...props}80 >81 {children}82 </button>83 )84}8586// Content87type DrawerContentProps = {88 children: ReactNode89 className?: string90 side?: "left" | "right" | "top" | "bottom"91 overlay?: boolean92} & HTMLAttributes<HTMLDivElement>9394export function DrawerContent({95 children,96 className,97 side = "right",98 overlay = false,99 ...props100}: DrawerContentProps) {101 const { open, onOpenChange } = useDrawer()102 const [shouldRender, setShouldRender] = useState(false)103 const [isVisible, setIsVisible] = useState(false)104 const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null)105 const rafRef = useRef<number | null>(null)106 const panelRef = useRef<HTMLDivElement>(null)107 const startYRef = useRef<number>(0)108 const startXRef = useRef<number>(0)109 const isDraggingRef = useRef(false)110111 // Mount / unmount animation112 useEffect(() => {113 if (animationTimeoutRef.current) {114 clearTimeout(animationTimeoutRef.current)115 animationTimeoutRef.current = null116 }117 if (rafRef.current) {118 cancelAnimationFrame(rafRef.current)119 rafRef.current = null120 }121122 if (open) {123 const isMobile = window.innerWidth < 1024124 if (isMobile) {125 document.body.style.overflow = "hidden"126 }127128 setIsVisible(false)129 setShouldRender(true)130131 rafRef.current = requestAnimationFrame(() => {132 animationTimeoutRef.current = setTimeout(() => {133 setIsVisible(true)134 animationTimeoutRef.current = null135 }, 20)136 rafRef.current = null137 })138 } else if (shouldRender) {139 document.body.style.overflow = ""140141 setIsVisible(false)142 animationTimeoutRef.current = setTimeout(() => {143 setShouldRender(false)144 animationTimeoutRef.current = null145 }, 500)146 }147148 return () => {149 document.body.style.overflow = ""150 if (animationTimeoutRef.current) {151 clearTimeout(animationTimeoutRef.current)152 animationTimeoutRef.current = null153 }154 if (rafRef.current) {155 cancelAnimationFrame(rafRef.current)156 rafRef.current = null157 }158 }159 }, [open, shouldRender])160161 // Escape key162 useEffect(() => {163 const handleEscape = (e: KeyboardEvent) => {164 if (e.key === "Escape") {165 onOpenChange(false)166 }167 }168169 if (open) {170 document.addEventListener("keydown", handleEscape)171 }172173 return () => {174 document.removeEventListener("keydown", handleEscape)175 }176 }, [open, onOpenChange])177178 // Drag-to-dismiss on the entire container179 useEffect(() => {180 const panel = panelRef.current181 if (!panel || !shouldRender || !isVisible) return182183 const isVertical = side === "bottom" || side === "top"184 const threshold = 80185186 const onPointerDown = (e: PointerEvent) => {187 const target = e.target as HTMLElement188 if (target.closest("button, a, input, textarea, select, [role='button']")) return189190 isDraggingRef.current = true191 startYRef.current = e.clientY192 startXRef.current = e.clientX193 panel.style.transition = "none"194 }195196 const onPointerMove = (e: PointerEvent) => {197 if (!isDraggingRef.current) return198 e.preventDefault()199200 if (isVertical) {201 const deltaY = e.clientY - startYRef.current202 if (side === "bottom") {203 panel.style.transform = `translateY(${Math.max(0, deltaY)}px)`204 } else {205 panel.style.transform = `translateY(${Math.min(0, deltaY)}px)`206 }207 } else {208 const deltaX = e.clientX - startXRef.current209 if (side === "right") {210 panel.style.transform = `translateX(${Math.max(0, deltaX)}px)`211 } else {212 panel.style.transform = `translateX(${Math.min(0, deltaX)}px)`213 }214 }215 }216217 const onPointerUp = (e: PointerEvent) => {218 if (!isDraggingRef.current) return219 isDraggingRef.current = false220221 const shouldClose = isVertical222 ? (side === "bottom" && e.clientY - startYRef.current > threshold) ||223 (side === "top" && e.clientY - startYRef.current < -threshold)224 : (side === "right" && e.clientX - startXRef.current > threshold) ||225 (side === "left" && e.clientX - startXRef.current < -threshold)226227 if (shouldClose) {228 onOpenChange(false)229 } else {230 panel.style.transition = "transform 300ms ease-out"231 panel.style.transform = ""232 const reset = () => {233 panel.style.transition = ""234 panel.removeEventListener("transitionend", reset)235 }236 panel.addEventListener("transitionend", reset)237 }238 }239240 panel.addEventListener("pointerdown", onPointerDown)241 document.addEventListener("pointermove", onPointerMove)242 document.addEventListener("pointerup", onPointerUp)243244 return () => {245 panel.removeEventListener("pointerdown", onPointerDown)246 document.removeEventListener("pointermove", onPointerMove)247 document.removeEventListener("pointerup", onPointerUp)248 }249 }, [shouldRender, isVisible, side, onOpenChange])250251 if (!shouldRender) return null252253 const getTranslateClass = () => {254 switch (side) {255 case "right":256 return isVisible ? "translate-x-0" : "translate-x-full"257 case "left":258 return isVisible ? "translate-x-0" : "-translate-x-full"259 case "bottom":260 return isVisible ? "translate-y-0" : "translate-y-full"261 case "top":262 return isVisible ? "translate-y-0" : "-translate-y-full"263 }264 }265266 const getPositionClasses = () => {267 switch (side) {268 case "right":269 return "top-0 right-0 h-full w-full sm:w-96 border-l shadow-[-8px_0_24px_-8px_rgba(0,0,0,0.2)]"270 case "left":271 return "top-0 left-0 h-full w-full sm:w-96 border-r shadow-[8px_0_24px_-8px_rgba(0,0,0,0.2)]"272 case "bottom":273 return "bottom-0 left-0 w-full h-auto max-h-[80vh] border-t shadow-[0_-8px_24px_-8px_rgba(0,0,0,0.2)] rounded-t-[10px]"274 case "top":275 return "top-0 left-0 w-full h-auto max-h-[80vh] border-b shadow-[0_8px_24px_-8px_rgba(0,0,0,0.2)] rounded-b-[10px]"276 }277 }278279 return (280 <>281 {open && (282 <div283 className={cn(284 "fixed inset-0 z-60 transition-opacity duration-500",285 overlay ? "bg-black/50" : "bg-transparent",286 isVisible ? "opacity-100" : "opacity-0"287 )}288 onClick={() => onOpenChange(false)}289 />290 )}291292 <div293 ref={panelRef}294 role="dialog"295 aria-modal="true"296 className={cn(297 "fixed z-70 bg-background transition-transform duration-500 ease-in-out border-border flex flex-col touch-none",298 getPositionClasses(),299 getTranslateClass(),300 className301 )}302 {...props}303 >304 {children}305 </div>306 </>307 )308}309310// Handle (barre visuelle optionnelle, typiquement pour le bottom drawer)311type DrawerHandleProps = {312 className?: string313} & HTMLAttributes<HTMLDivElement>314315export function DrawerHandle({ className, ...props }: DrawerHandleProps) {316 return (317 <div318 className={cn(319 "mx-auto mt-4 mb-2 h-1.5 w-12 shrink-0 cursor-grab rounded-full bg-muted-foreground/30 active:cursor-grabbing",320 className321 )}322 {...props}323 />324 )325}326327// Header328type DrawerHeaderProps = {329 children: ReactNode330 className?: string331} & HTMLAttributes<HTMLDivElement>332333export function DrawerHeader({ children, className, ...props }: DrawerHeaderProps) {334 return (335 <div336 className={cn("px-6 py-4 border-b border-border border-dashed", className)}337 {...props}338 >339 {children}340 </div>341 )342}343344// Title345type DrawerTitleProps = {346 children: ReactNode347 className?: string348} & HTMLAttributes<HTMLHeadingElement>349350export function DrawerTitle({ children, className, ...props }: DrawerTitleProps) {351 return (352 <h2353 className={cn("text-lg font-semibold leading-none tracking-tight", className)}354 {...props}355 >356 {children}357 </h2>358 )359}360361// Description362type DrawerDescriptionProps = {363 children: ReactNode364 className?: string365} & HTMLAttributes<HTMLParagraphElement>366367export function DrawerDescription({ children, className, ...props }: DrawerDescriptionProps) {368 return (369 <p370 className={cn("text-sm text-muted-foreground", className)}371 {...props}372 >373 {children}374 </p>375 )376}377378// Body379type DrawerBodyProps = {380 children: ReactNode381 className?: string382} & HTMLAttributes<HTMLDivElement>383384export function DrawerBody({ children, className, ...props }: DrawerBodyProps) {385 return (386 <div387 className={cn("p-6 overflow-y-auto flex-1", className)}388 {...props}389 >390 {children}391 </div>392 )393}394395// Footer396type DrawerFooterProps = {397 children: ReactNode398 className?: string399} & HTMLAttributes<HTMLDivElement>400401export function DrawerFooter({ children, className, ...props }: DrawerFooterProps) {402 return (403 <div404 className={cn("flex items-center px-6 py-4 border-t border-border border-dashed", className)}405 {...props}406 >407 {children}408 </div>409 )410}411412// Close413type DrawerCloseProps = {414 children: ReactNode415 className?: string416 asChild?: boolean417} & ButtonHTMLAttributes<HTMLButtonElement>418419export function DrawerClose({ children, className, onClick, asChild, ...props }: DrawerCloseProps) {420 const { onOpenChange } = useDrawer()421422 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {423 onClick?.(e)424 onOpenChange(false)425 }426427 if (asChild) {428 return (429 <span onClick={() => onOpenChange(false)} className={className}>430 {children}431 </span>432 )433 }434435 return (436 <button437 type="button"438 className={className}439 onClick={handleClick}440 {...props}441 >442 {children}443 </button>444 )445}446Usages
Different variants and use cases for the Drawer component.
Default
A basic drawer opening from the right side. Drag anywhere on the panel to dismiss.
Default.tsx
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer Title</DrawerTitle>
<DrawerDescription>
This is a drawer description.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<p>Drawer body content goes here.</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button>Save</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>Bottom with Handle
A bottom drawer with an optional visual handle bar. Drag anywhere on the panel to dismiss.
Bottom with Handle.tsx
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Bottom Drawer</Button>
</DrawerTrigger>
<DrawerContent side="bottom" overlay>
<DrawerHandle />
<DrawerHeader>
<DrawerTitle>Bottom Drawer</DrawerTitle>
<DrawerDescription>
Drag anywhere on the panel to close.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<p>This drawer slides up from the bottom. Drag down anywhere to dismiss.</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Left Side
A drawer opening from the left side. Drag left anywhere to dismiss.
Left Side.tsx
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Left Drawer</Button>
</DrawerTrigger>
<DrawerContent side="left">
<DrawerHeader>
<DrawerTitle>Navigation</DrawerTitle>
<DrawerDescription>
Browse through the menu.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<nav className="flex flex-col gap-2">
<a href="#" className="text-sm hover:underline">Home</a>
<a href="#" className="text-sm hover:underline">About</a>
<a href="#" className="text-sm hover:underline">Contact</a>
</nav>
</DrawerBody>
</DrawerContent>
</Drawer>With Overlay
A drawer with a dark overlay backdrop. Drag or click overlay to dismiss.
With Overlay.tsx
<Drawer>
<DrawerTrigger asChild>
<Button>Open with Overlay</Button>
</DrawerTrigger>
<DrawerContent overlay>
<DrawerHeader>
<DrawerTitle>Overlay Drawer</DrawerTitle>
<DrawerDescription>
Click the overlay or drag to close.
</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<p>This drawer has a dark backdrop overlay behind it.</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button>Confirm</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>