Drawer
pnpm dlx shadcn@latest add …/r/drawer.jsonAn accessible React drawer for Next.js and Tailwind CSS: an iOS bottom sheet on phones that drags to close and works with the keyboard, a dialog on larger screens.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function MoveToTrash() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Move to trash</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move to trash?</DrawerTitle>
<DrawerDescription>You can restore it for 30 days.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button variant="destructive">Move to trash</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
// For the page to step back behind the sheet, wrap your app once (app/layout.tsx):
// <body>
// <div data-vaul-drawer-wrapper="" className="min-h-svh bg-background">{children}</div>
// </body>
// and let content reach the screen's edges, so the sheet clears the home indicator:
// export const viewport = { viewportFit: "cover" }"use client";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useId, useState } from "react";
type Mode = "sheet" | "auto" | "dialog";
const modes: { value: Mode; label: string }[] = [
{ value: "sheet", label: "A sheet" },
{ value: "auto", label: "Sheet on phones" },
{ value: "dialog", label: "A dialog" },
];
/*
* A form in a drawer. On a phone, focus a field: the sheet sits on the keyboard, the field stays in view above
* the pinned buttons, and scrolling while typing never closes it. Saving (the button, or Enter in a field)
* closes it, so it's controlled. The setting picks what it opens as; an app usually leaves it on auto.
*/
export default function DrawerProfile() {
const id = useId();
const [mode, setMode] = useState<Mode>("sheet");
const [open, setOpen] = useState(false);
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-mode`} className="text-sm text-muted-foreground">
Opens as
</label>
<Select value={mode} onValueChange={(value) => setMode(value as Mode)}>
<SelectTrigger id={`${id}-mode`} size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modes.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 items-center justify-center">
<Drawer mode={mode} open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DrawerTrigger>
<DrawerContent className="sm:max-w-md">
<form
className="grid gap-6"
onSubmit={(event) => {
event.preventDefault();
setOpen(false);
}}
>
<DrawerHeader>
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>Change how others see you. Save when you're done.</DrawerDescription>
</DrawerHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name
</label>
<Input id={`${id}-name`} defaultValue="Maya Chen" autoComplete="name" />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-username`} className="text-sm font-medium">
Username
</label>
<Input id={`${id}-username`} defaultValue="maya" autoComplete="username" />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-bio`} className="text-sm font-medium">
Bio
</label>
<Textarea
id={`${id}-bio`}
defaultValue="Designer in Lisbon. I make calm interfaces."
minRows={3}
maxRows={6}
/>
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button type="submit">Save changes</Button>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</div>
</div>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motion vaulAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --size-sheet-top-gap: max(2.75rem, calc(env(safe-area-inset-top) + 0.5rem)); --size-sheet-max: calc(100svh - max(2.75rem, calc(env(safe-area-inset-top) + 0.5rem))); --size-sheet-inset-bottom: calc(1rem + env(safe-area-inset-bottom)); --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, useMergedRef, } from "@/components/ui/dialog"; import { duration } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { XIcon } from "lucide-react"; import { useReducedMotion } from "motion/react"; import { type ComponentProps, type CSSProperties, createContext, type ReactNode, type RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { Drawer as Vaul } from "vaul"; /* * Drawer: on phones, an iOS bottom sheet (owner's brief, 2026-10-26); on larger screens, our centred Dialog. * The sheet is Vaul (a drawer on Radix Dialog): drag it down to close, and the page behind steps back. * - Which one is chosen as it opens and kept while it's open, so it never swaps mid-use (a keyboard or a turn of * the phone can change the screen's size). `mode` can force either. * - The sheet never scrolls itself. Vaul hangs an invisible filler under it, so a scrolling sheet scrolls into * blank space: the sheet is a fixed shell, and its children scroll in a body inside it, one column that can't * grow wider than the screen. A footer sticks to the bottom, above the home indicator. * - The keyboard is ours, not Vaul's: the visual viewport says how much of the screen it covers. The sheet sits * on top of it and fits what's left; the focused field scrolls into view above the footer. While it's up, only * the grabber drags the sheet, so scrolling while typing never closes it, and a pull down at the top of the * content hides the keyboard first, as iOS does. * - Fields are 16px on phones inside it, so iOS doesn't zoom in when one is focused. * - A drawer opened from a sheet is a sheet on a sheet: the one behind steps back, also when code opens it * (Vaul only does that for a press on its trigger). * - Opening doesn't focus the first field (that would raise the keyboard): focus goes to the sheet itself. * - Motion: Vaul's own (its iOS curve and drag). Reduced motion: the sheet only fades and the page doesn't step * back. Clean up: a quick open and close can leave the page untouchable, black or scaled, so it's put back * once no dialog is open. */ /** Phones: below Tailwind's md breakpoint */ const PHONE = "(max-width: 767px)"; /** Vaul's own timing and step back (vaul/src/constants.ts), so a sheet pushed back by code moves like one * pushed back by Vaul */ const VAUL_TRANSITION = "transform 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; const VAUL_NESTED_STEP = 16; /** How far a pull down at the top of the content goes before it hides the keyboard: a deliberate fingertip */ const PULL_TO_HIDE_KEYBOARD = 48; type Shown = "sheet" | "dialog"; /** How much of the screen the keyboard covers: its top edge from the bottom, and the height left above it */ type Keyboard = { bottom: number; height: number }; type DrawerState = { shown: Shown; open: boolean; keyboard: Keyboard | null; /** The sheet's own node, so a sheet opened from it can push it back */ node: RefObject<HTMLDivElement | null>; }; const DrawerContext = createContext<DrawerState | null>(null); function useDrawer(part: string) { const drawer = useContext(DrawerContext); if (!drawer) throw new Error(`${part} must be used inside Drawer`); return drawer; } function subscribePhone(onChange: () => void) { const query = window.matchMedia(PHONE); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); } /** Whether the screen is phone-sized; false on the server, so the first paint matches it */ function usePhone() { return useSyncExternalStore( subscribePhone, () => window.matchMedia(PHONE).matches, () => false, ); } const isField = (node: unknown): node is HTMLElement => node instanceof HTMLElement && (node.isContentEditable || node.matches("input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select")); /** The keyboard, read from the visual viewport while a field has focus; null while it's down */ function useKeyboard(active: boolean) { const [keyboard, setKeyboard] = useState<Keyboard | null>(null); useEffect(() => { const viewport = window.visualViewport; if (!active || !viewport) { setKeyboard(null); return; } const update = () => { const bottom = Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)); const height = Math.round(viewport.height); const up = isField(document.activeElement) && bottom > 0; setKeyboard((was) => !up ? null : was?.bottom === bottom && was.height === height ? was : { bottom, height }, ); }; update(); viewport.addEventListener("resize", update); viewport.addEventListener("scroll", update); document.addEventListener("focusin", update); document.addEventListener("focusout", update); return () => { viewport.removeEventListener("resize", update); viewport.removeEventListener("scroll", update); document.removeEventListener("focusin", update); document.removeEventListener("focusout", update); }; }, [active]); return keyboard; } /** * A quick open and close can leave Vaul's page styles behind: clicks blocked, a black background, the page * scaled or fixed in place. Once no dialog is open, put them back (and the scroll position with them). */ function unstick() { if (document.querySelector('[role="dialog"][data-state="open"]')) return; const { body } = document; body.style.removeProperty("pointer-events"); body.style.removeProperty("background"); if (body.style.position === "fixed") { const x = -Number.parseFloat(body.style.left || "0"); const y = -Number.parseFloat(body.style.top || "0"); for (const property of ["position", "top", "left", "right", "height"]) body.style.removeProperty(property); window.scrollTo(x, y); } const wrapper = document.querySelector<HTMLElement>("[data-vaul-drawer-wrapper]"); wrapper?.style.removeProperty("transform"); wrapper?.style.removeProperty("border-radius"); } export type DrawerProps = { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** A sheet on phones and a dialog on larger screens (auto), or always one of them */ mode?: "auto" | "sheet" | "dialog"; children?: ReactNode; }; function Drawer({ open, defaultOpen, onOpenChange, mode = "auto", children }: DrawerProps) { const parent = useContext(DrawerContext); const reduce = useReducedMotion() ?? false; const phone = usePhone(); const node = useRef<HTMLDivElement | null>(null); const [own, setOwn] = useState(defaultOpen ?? false); const current = open ?? own; const setOpen = useCallback( (next: boolean) => { setOwn(next); onOpenChange?.(next); }, [onOpenChange], ); // Chosen while closed and kept while open, so it never swaps mid-use const wanted: Shown = mode === "auto" ? (phone ? "sheet" : "dialog") : mode; const [shown, setShown] = useState<Shown>(wanted); if (!current && shown !== wanted) setShown(wanted); const sheet = shown === "sheet"; const nested = sheet && parent?.shown === "sheet"; const keyboard = useKeyboard(sheet && current); // A sheet on a sheet: the one behind steps back while this one is open useEffect(() => { const behind = parent?.node.current; if (!nested || !current || !behind) return; const width = behind.offsetWidth; behind.style.transition = VAUL_TRANSITION; behind.style.transform = `scale(${(width - VAUL_NESTED_STEP) / width}) translate3d(0, -${VAUL_NESTED_STEP}px, 0)`; return () => { behind.style.transform = "translate3d(0, 0, 0)"; }; }, [nested, current, parent]); // Leaving the page with a sheet open (or mid-exit) must not leave its styles behind useEffect( () => () => { requestAnimationFrame(unstick); }, [], ); const state = useMemo<DrawerState>( () => ({ shown, open: current, keyboard, node }), [shown, current, keyboard], ); if (!sheet) { return ( <DrawerContext.Provider value={state}> <Dialog open={current} onOpenChange={setOpen}> {children} </Dialog> </DrawerContext.Provider> ); } const Root = nested ? Vaul.NestedRoot : Vaul.Root; return ( <DrawerContext.Provider value={state}> <Root open={current} onOpenChange={setOpen} // The page steps back (it needs a wrapper with data-vaul-drawer-wrapper); a nested sheet pushes the // sheet behind instead shouldScaleBackground={!nested && !reduce} setBackgroundColorOnScale repositionInputs={false} handleOnly={keyboard !== null} onAnimationEnd={(isOpen) => { if (!isOpen) unstick(); }} > {children} </Root> </DrawerContext.Provider> ); } function DrawerTrigger(props: ComponentProps<typeof Vaul.Trigger>) { const { shown } = useDrawer("DrawerTrigger"); if (shown === "dialog") return <DialogTrigger {...props} />; return <Vaul.Trigger data-slot="drawer-trigger" {...props} />; } function DrawerClose(props: ComponentProps<typeof Vaul.Close>) { const { shown } = useDrawer("DrawerClose"); if (shown === "dialog") return <DialogClose {...props} />; return <Vaul.Close data-slot="drawer-close" {...props} />; } export type DrawerContentProps = ComponentProps<typeof Vaul.Content> & { /** Show a close button in the top corner */ showCloseButton?: boolean; }; function DrawerContent({ showCloseButton = true, ...props }: DrawerContentProps) { const { shown } = useDrawer("DrawerContent"); // The dialog's parts keep Dialog's own slots, which its styles rely on if (shown === "dialog") return <DialogContent showCloseButton={showCloseButton} {...props} />; return <SheetContent showCloseButton={showCloseButton} {...props} />; } function SheetContent({ className, children, style, ref, showCloseButton, onOpenAutoFocus, ...props }: DrawerContentProps) { const { open, keyboard, node } = useDrawer("DrawerContent"); const reduce = useReducedMotion() ?? false; const setRef = useMergedRef(node, ref); const body = useRef<HTMLDivElement | null>(null); const touch = useRef<{ y: number; top: boolean } | null>(null); // As the keyboard comes up (or changes size), the focused field scrolls into view above the footer const bottom = keyboard?.bottom; const height = keyboard?.height; useEffect(() => { const scroller = body.current; if (bottom === undefined || height === undefined || !scroller) return; const footer = scroller.querySelector<HTMLElement>("[data-slot=drawer-footer]"); scroller.style.scrollPaddingBottom = footer ? `${footer.offsetHeight}px` : ""; const frame = requestAnimationFrame(() => { const field = document.activeElement; if (field instanceof HTMLElement && scroller.contains(field)) { field.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" }); } }); return () => cancelAnimationFrame(frame); }, [bottom, height, reduce]); // On the keyboard: lifted by its height and no taller than the screen left above it const lift: CSSProperties | undefined = keyboard ? { bottom: keyboard.bottom, maxHeight: `calc(${keyboard.height}px - var(--size-sheet-top-gap))` } : undefined; // Reduced motion: Vaul's slide becomes its fade (its own keyframes) const calm: CSSProperties | undefined = reduce ? { animationName: open ? "fadeIn" : "fadeOut", animationDuration: `${duration.base}s` } : undefined; return ( <Vaul.Portal> <Vaul.Overlay data-slot="drawer-overlay" className="fixed inset-0 z-(--z-overlay) bg-overlay" /> <Vaul.Content data-slot="drawer-content" ref={setRef} className={cn( // A fixed shell that never scrolls; on a large screen (mode="sheet") it keeps a phone's width "fixed inset-x-0 bottom-0 z-(--z-modal) mx-auto flex max-h-(--size-sheet-max) w-full max-w-lg flex-col rounded-t-2xl bg-background text-foreground shadow-lg outline-hidden", // Fields at 16px on phones, so iOS doesn't zoom in on focus "max-md:[&_:is(input,select,textarea)]:text-base", // The header stops short of the close button, so a long title never runs under it showCloseButton && "**:data-[slot=drawer-header]:pe-10", className, )} style={{ ...lift, ...calm, ...style }} onOpenAutoFocus={(event) => { onOpenAutoFocus?.(event); if (event.defaultPrevented) return; // Into the sheet, but not into its first field: that would raise the keyboard event.preventDefault(); node.current?.focus({ preventScroll: true }); }} {...props} > {/* The grabber: Vaul's handle, which stays draggable while the keyboard is up. Its own styles are unlayered, so ours need !important. */} <Vaul.Handle data-slot="drawer-handle" className="absolute! inset-x-0! top-2! mx-auto! h-1.25! w-9! rounded-full! bg-foreground/20! opacity-100!" /> <div ref={body} data-slot="drawer-body" className={cn( // One column that can't grow wider than the screen; the room under the last row clears the home // indicator, unless a footer already does "grid min-h-0 flex-1 auto-rows-max grid-cols-1 gap-4 overflow-x-hidden overflow-y-auto overscroll-contain px-6 pt-6 pb-(--size-sheet-inset-bottom)", "has-data-[slot=drawer-footer]:pb-0", )} onTouchStart={(event) => { touch.current = { y: event.touches[0]?.clientY ?? 0, top: event.currentTarget.scrollTop <= 0 }; }} onTouchMove={(event) => { // With the keyboard up, a pull down at the top hides it first, as iOS does const start = touch.current; if (!keyboard || !start?.top) return; if ((event.touches[0]?.clientY ?? 0) - start.y > PULL_TO_HIDE_KEYBOARD) { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); touch.current = null; } }} > {children} </div> {showCloseButton ? ( <Vaul.Close asChild> {/* iOS's close: an ✕ in a grey circle, centred on the title's line */} <Button data-slot="drawer-close" variant="secondary" size="icon-sm" aria-label="Close" className="absolute end-4 top-5 rounded-full text-muted-foreground hover:text-foreground" > <XIcon /> </Button> </Vaul.Close> ) : null} </Vaul.Content> </Vaul.Portal> ); } function DrawerHeader({ className, ...props }: ComponentProps<"div">) { const { shown } = useDrawer("DrawerHeader"); if (shown === "dialog") return <DialogHeader className={className} {...props} />; return <div data-slot="drawer-header" className={cn("grid gap-1.5", className)} {...props} />; } /** * Actions. In the sheet they're pinned to its bottom, full width with the main one on top, above the home * indicator; in the dialog, a row at the end. Put the main action last, as in Dialog. */ function DrawerFooter({ className, ...props }: ComponentProps<"div">) { const { shown } = useDrawer("DrawerFooter"); if (shown === "dialog") return <DialogFooter className={className} {...props} />; return ( <div data-slot="drawer-footer" className={cn( "sticky bottom-0 -mx-6 flex flex-col-reverse gap-2 bg-background px-6 pt-3 pb-(--size-sheet-inset-bottom)", className, )} {...props} /> ); } function DrawerTitle({ className, ...props }: ComponentProps<typeof Vaul.Title>) { const { shown } = useDrawer("DrawerTitle"); if (shown === "dialog") return <DialogTitle className={className} {...props} />; return ( <Vaul.Title data-slot="drawer-title" className={cn("text-base font-semibold", className)} {...props} /> ); } function DrawerDescription({ className, ...props }: ComponentProps<typeof Vaul.Description>) { const { shown } = useDrawer("DrawerDescription"); if (shown === "dialog") { return <DialogDescription className={className} {...props} />; } return ( <Vaul.Description data-slot="drawer-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> ); } export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, };Update the import paths to match your project.
Examples
Long content
The sheet grows to just under the status bar; its content scrolls on its own and the buttons stay pinned.
"use client";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const sections = [
{
title: "Your account",
text: "You're responsible for what happens under your account, and for keeping its password safe.",
},
{
title: "Your content",
text: "What you upload stays yours. You let us store and show it so the service can work.",
},
{
title: "Acceptable use",
text: "Don't break the law, harm others, or try to get round the limits of your plan.",
},
{
title: "Payments",
text: "Paid plans renew each month until you cancel. Cancelling stops the next renewal.",
},
{
title: "Privacy",
text: "We collect only what the service needs. The privacy policy says what, and why.",
},
{
title: "Changes",
text: "We'll tell you about changes that matter at least 30 days before they apply.",
},
{
title: "Ending",
text: "You can close your account at any time. We may close one that breaks these terms.",
},
];
/*
* Long content: the sheet grows to just under the status bar, its body scrolls on its own, and the buttons
* stay pinned at the bottom. Drag it down from the top of the text, or by the grabber, to close it.
*/
export default function DrawerScrolling() {
return (
<Drawer mode="sheet">
<DrawerTrigger asChild>
<Button variant="outline">Read the terms</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Terms of service</DrawerTitle>
<DrawerDescription>Updated 1 October 2026</DrawerDescription>
</DrawerHeader>
{sections.map((section) => (
<section key={section.title} className="grid gap-1.5">
<h3 className="text-sm font-medium">{section.title}</h3>
<p className="text-sm text-muted-foreground">{section.text}</p>
</section>
))}
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Decline</Button>
</DrawerClose>
<DrawerClose asChild>
<Button>Accept</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}A sheet on a sheet
A drawer opened from a sheet: the first steps back behind the second.
"use client";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const people = ["Olivia Martin", "Jackson Lee", "Isabella Nguyen"];
/*
* A sheet opened from a sheet: the first steps back behind the second, and comes forward again when it closes.
* The inner drawer follows its parent, so it's a sheet here too.
*/
export default function DrawerNested() {
return (
<Drawer mode="sheet">
<DrawerTrigger asChild>
<Button variant="outline">Share</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Share the Q4 plan</DrawerTitle>
<DrawerDescription>These people can open it.</DrawerDescription>
</DrawerHeader>
<ul className="grid gap-2 text-sm">
{people.map((person) => (
<li key={person}>{person}</li>
))}
</ul>
<DrawerFooter>
<Drawer mode="sheet">
<DrawerTrigger asChild>
<Button>Add people</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add people</DrawerTitle>
<DrawerDescription>They'll get an email with a link.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button>Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}API reference
Drawer
Holds the open state. What it opens as is chosen as it opens and kept while it's open, so it never swaps mid-use.
| Prop | Type | Default | Description |
|---|---|---|---|
mode? | "auto" | "sheet" | "dialog" | "auto" | A sheet on phones (under 768px) and a dialog elsewhere, or always one of them. |
open? | boolean | – | Whether it's open (controlled). |
defaultOpen? | boolean | false | Open on first render. |
onOpenChange? | (open: boolean) => void | – | Called as it opens and closes, by any means (a drag, Escape, the backdrop, a button). |
DrawerTrigger
Opens it. Use asChild to make your Button the trigger.
| Prop | Type | Default | Description |
|---|
DrawerContent
The sheet (with a grabber, and a body that scrolls) or the dialog window. Focus goes into it, not to its first field, so the keyboard doesn't jump up. Also accepts every Radix Dialog Content prop.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton? | boolean | true | Show a close button in the top corner. |
DrawerHeader
The title and description, stopping short of the close button.
| Prop | Type | Default | Description |
|---|
DrawerFooter
Actions. In the sheet they're pinned to the bottom, full width with the last one on top, above the home indicator; in the dialog, a row at the end. Put the main action last.
| Prop | Type | Default | Description |
|---|
DrawerTitle
Names the drawer for screen readers. Required.
| Prop | Type | Default | Description |
|---|
DrawerDescription
Describes it, read after the title.
| Prop | Type | Default | Description |
|---|
DrawerClose
Closes it. Use asChild with a Button.
| Prop | Type | Default | Description |
|---|
Related components
An accessible React dialog for Next.js and Tailwind CSS: a modal window that grows out of its trigger, keeps focus inside and returns it, built on Radix Dialog.
An accessible React sheet for Next.js: a side panel that slides in from any edge of the screen, for forms, filters and navigation, built on Radix Dialog.
An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.