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 Drawer

Install 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"
2
3import type { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from "react"
4import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"
5import { cn } from "@/lib/utils"
6
7// Context
8type DrawerContextType = {
9 open: boolean
10 onOpenChange: (open: boolean) => void
11}
12
13const DrawerContext = createContext<DrawerContextType | undefined>(undefined)
14
15function useDrawer() {
16 const context = useContext(DrawerContext)
17 if (!context) {
18 throw new Error("Drawer components must be used within a Drawer")
19 }
20 return context
21}
22
23// Root component
24type DrawerProps = {
25 children: ReactNode
26 open?: boolean
27 defaultOpen?: boolean
28 onOpenChange?: (open: boolean) => void
29}
30
31export function Drawer({
32 children,
33 open: controlledOpen,
34 defaultOpen = false,
35 onOpenChange,
36}: DrawerProps) {
37 const [internalOpen, setInternalOpen] = useState(defaultOpen)
38
39 const isControlled = controlledOpen !== undefined
40 const open = isControlled ? controlledOpen : internalOpen
41
42 const handleOpenChange = useCallback((newOpen: boolean) => {
43 if (!isControlled) {
44 setInternalOpen(newOpen)
45 }
46 onOpenChange?.(newOpen)
47 }, [isControlled, onOpenChange])
48
49 return (
50 <DrawerContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
51 {children}
52 </DrawerContext.Provider>
53 )
54}
55
56// Trigger
57type DrawerTriggerProps = {
58 children: ReactNode
59 className?: string
60 asChild?: boolean
61} & ButtonHTMLAttributes<HTMLButtonElement>
62
63export function DrawerTrigger({ children, className, asChild, ...props }: DrawerTriggerProps) {
64 const { onOpenChange } = useDrawer()
65
66 if (asChild) {
67 return (
68 <span onClick={() => onOpenChange(true)} className={className}>
69 {children}
70 </span>
71 )
72 }
73
74 return (
75 <button
76 type="button"
77 className={className}
78 onClick={() => onOpenChange(true)}
79 {...props}
80 >
81 {children}
82 </button>
83 )
84}
85
86// Content
87type DrawerContentProps = {
88 children: ReactNode
89 className?: string
90 side?: "left" | "right" | "top" | "bottom"
91 overlay?: boolean
92} & HTMLAttributes<HTMLDivElement>
93
94export function DrawerContent({
95 children,
96 className,
97 side = "right",
98 overlay = false,
99 ...props
100}: 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)
110
111 // Mount / unmount animation
112 useEffect(() => {
113 if (animationTimeoutRef.current) {
114 clearTimeout(animationTimeoutRef.current)
115 animationTimeoutRef.current = null
116 }
117 if (rafRef.current) {
118 cancelAnimationFrame(rafRef.current)
119 rafRef.current = null
120 }
121
122 if (open) {
123 const isMobile = window.innerWidth < 1024
124 if (isMobile) {
125 document.body.style.overflow = "hidden"
126 }
127
128 setIsVisible(false)
129 setShouldRender(true)
130
131 rafRef.current = requestAnimationFrame(() => {
132 animationTimeoutRef.current = setTimeout(() => {
133 setIsVisible(true)
134 animationTimeoutRef.current = null
135 }, 20)
136 rafRef.current = null
137 })
138 } else if (shouldRender) {
139 document.body.style.overflow = ""
140
141 setIsVisible(false)
142 animationTimeoutRef.current = setTimeout(() => {
143 setShouldRender(false)
144 animationTimeoutRef.current = null
145 }, 500)
146 }
147
148 return () => {
149 document.body.style.overflow = ""
150 if (animationTimeoutRef.current) {
151 clearTimeout(animationTimeoutRef.current)
152 animationTimeoutRef.current = null
153 }
154 if (rafRef.current) {
155 cancelAnimationFrame(rafRef.current)
156 rafRef.current = null
157 }
158 }
159 }, [open, shouldRender])
160
161 // Escape key
162 useEffect(() => {
163 const handleEscape = (e: KeyboardEvent) => {
164 if (e.key === "Escape") {
165 onOpenChange(false)
166 }
167 }
168
169 if (open) {
170 document.addEventListener("keydown", handleEscape)
171 }
172
173 return () => {
174 document.removeEventListener("keydown", handleEscape)
175 }
176 }, [open, onOpenChange])
177
178 // Drag-to-dismiss on the entire container
179 useEffect(() => {
180 const panel = panelRef.current
181 if (!panel || !shouldRender || !isVisible) return
182
183 const isVertical = side === "bottom" || side === "top"
184 const threshold = 80
185
186 const onPointerDown = (e: PointerEvent) => {
187 const target = e.target as HTMLElement
188 if (target.closest("button, a, input, textarea, select, [role='button']")) return
189
190 isDraggingRef.current = true
191 startYRef.current = e.clientY
192 startXRef.current = e.clientX
193 panel.style.transition = "none"
194 }
195
196 const onPointerMove = (e: PointerEvent) => {
197 if (!isDraggingRef.current) return
198 e.preventDefault()
199
200 if (isVertical) {
201 const deltaY = e.clientY - startYRef.current
202 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.current
209 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 }
216
217 const onPointerUp = (e: PointerEvent) => {
218 if (!isDraggingRef.current) return
219 isDraggingRef.current = false
220
221 const shouldClose = isVertical
222 ? (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)
226
227 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 }
239
240 panel.addEventListener("pointerdown", onPointerDown)
241 document.addEventListener("pointermove", onPointerMove)
242 document.addEventListener("pointerup", onPointerUp)
243
244 return () => {
245 panel.removeEventListener("pointerdown", onPointerDown)
246 document.removeEventListener("pointermove", onPointerMove)
247 document.removeEventListener("pointerup", onPointerUp)
248 }
249 }, [shouldRender, isVisible, side, onOpenChange])
250
251 if (!shouldRender) return null
252
253 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 }
265
266 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 }
278
279 return (
280 <>
281 {open && (
282 <div
283 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 )}
291
292 <div
293 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 className
301 )}
302 {...props}
303 >
304 {children}
305 </div>
306 </>
307 )
308}
309
310// Handle (barre visuelle optionnelle, typiquement pour le bottom drawer)
311type DrawerHandleProps = {
312 className?: string
313} & HTMLAttributes<HTMLDivElement>
314
315export function DrawerHandle({ className, ...props }: DrawerHandleProps) {
316 return (
317 <div
318 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 className
321 )}
322 {...props}
323 />
324 )
325}
326
327// Header
328type DrawerHeaderProps = {
329 children: ReactNode
330 className?: string
331} & HTMLAttributes<HTMLDivElement>
332
333export function DrawerHeader({ children, className, ...props }: DrawerHeaderProps) {
334 return (
335 <div
336 className={cn("px-6 py-4 border-b border-border border-dashed", className)}
337 {...props}
338 >
339 {children}
340 </div>
341 )
342}
343
344// Title
345type DrawerTitleProps = {
346 children: ReactNode
347 className?: string
348} & HTMLAttributes<HTMLHeadingElement>
349
350export function DrawerTitle({ children, className, ...props }: DrawerTitleProps) {
351 return (
352 <h2
353 className={cn("text-lg font-semibold leading-none tracking-tight", className)}
354 {...props}
355 >
356 {children}
357 </h2>
358 )
359}
360
361// Description
362type DrawerDescriptionProps = {
363 children: ReactNode
364 className?: string
365} & HTMLAttributes<HTMLParagraphElement>
366
367export function DrawerDescription({ children, className, ...props }: DrawerDescriptionProps) {
368 return (
369 <p
370 className={cn("text-sm text-muted-foreground", className)}
371 {...props}
372 >
373 {children}
374 </p>
375 )
376}
377
378// Body
379type DrawerBodyProps = {
380 children: ReactNode
381 className?: string
382} & HTMLAttributes<HTMLDivElement>
383
384export function DrawerBody({ children, className, ...props }: DrawerBodyProps) {
385 return (
386 <div
387 className={cn("p-6 overflow-y-auto flex-1", className)}
388 {...props}
389 >
390 {children}
391 </div>
392 )
393}
394
395// Footer
396type DrawerFooterProps = {
397 children: ReactNode
398 className?: string
399} & HTMLAttributes<HTMLDivElement>
400
401export function DrawerFooter({ children, className, ...props }: DrawerFooterProps) {
402 return (
403 <div
404 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}
411
412// Close
413type DrawerCloseProps = {
414 children: ReactNode
415 className?: string
416 asChild?: boolean
417} & ButtonHTMLAttributes<HTMLButtonElement>
418
419export function DrawerClose({ children, className, onClick, asChild, ...props }: DrawerCloseProps) {
420 const { onOpenChange } = useDrawer()
421
422 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
423 onClick?.(e)
424 onOpenChange(false)
425 }
426
427 if (asChild) {
428 return (
429 <span onClick={() => onOpenChange(false)} className={className}>
430 {children}
431 </span>
432 )
433 }
434
435 return (
436 <button
437 type="button"
438 className={className}
439 onClick={handleClick}
440 {...props}
441 >
442 {children}
443 </button>
444 )
445}
446

Usages

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>