Skip to content
Drivn
6 min read

Drivn vs shadcn/ui — Drawer Component Compared

Side-by-side comparison of Drivn Drawer vs shadcn/ui — native <dialog> element with four sides and zero runtime deps versus the Vaul bottom-sheet wrapper.

Two libraries, two slide-in panels, and almost nothing shared underneath. shadcn/ui's Drawer is a styling layer over Vaul — Emil Kowalski's drag-to-dismiss sheet library — so the published recipe re-exports Drawer, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, and DrawerClose straight from vaul. Gesture tracking, snap points, momentum, and background inerting all execute inside that runtime. Drivn's Drawer starts at the native <dialog> element instead. Calling showModal() promotes the panel into the browser's top layer, which hands you focus containment, the backdrop, and Escape for free — what stays in the file is a context provider, one effect, and a styles object.

The public surface splits the same way. shadcn gives you ten flat exports and rebuilds the header out of <DrawerHeader>, <DrawerTitle>, and <DrawerDescription> at every call site, with a bottom sheet as the default shape and other edges arriving through a direction prop wired to Vaul's transform math. Drivn ships six dot-notation parts — Drawer, Drawer.Trigger, Drawer.Content, Drawer.Close, Drawer.Header, Drawer.Footer — and the side prop on Drawer.Content covers left, right, top, and bottom from one styles.sides map.

What follows compares the two on primitive, dependency footprint, animation, dismissal, and fit, with the shadcn side taken from its published drawer.tsx recipe rather than a hypothetical wrapper. If the drag gesture is the point, Vaul is a fair reason to pick shadcn — Drivn's Drawer is a side-anchored Dialog with four edges.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveNative <dialog> elementVaul (drag-to-dismiss library)
Runtime UI dependenciesZerovaul
Subcomponents to import6 (Drawer, .Trigger, .Content, .Close, .Header, .Footer)10 flat exports
Default siderightbottom
Available sidesleft, right, top, bottom (one component)bottom, top, left, right via direction prop
Drag-to-dismiss
Title surfacetitle + description props on Drawer.Header<DrawerTitle> + <DrawerDescription> components
Focus trapBrowser via showModal()Vaul focus management
Outside-click dismissBuilt-inBuilt-in (Vaul)
Escape dismissNative cancel eventVaul keydown handler
AnimationCSS transitions on translate + starting:Vaul transform + tailwindcss-animate
Controlled APIopen + onOpenChangeopen + onOpenChange
LicenseMITMIT
Copy-paste install

API side-by-side

shadcn/ui's Drawer is a flat namespace of ten named exports — Drawer, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerClose — and the title row is built by composing <DrawerHeader> around <DrawerTitle> and <DrawerDescription>. Every drawer repeats that scaffold. Drivn collapses the surface into dot notation. Drawer.Trigger opens, Drawer.Content accepts a side prop, and Drawer.Header takes title and description as string props instead of nested components.

The call site goes from ten imports and four nested wrappers to two imports and a flat children block. The full Drivn surface, including the controlled-state hook into Drawer, stays one file you can edit after install. Need a chromeless drawer? Drop Drawer.Header and Drawer.Footer and render plain children inside Drawer.Content.

1// shadcn/ui — flat exports + composed header
2import {
3 Drawer,
4 DrawerContent,
5 DrawerDescription,
6 DrawerFooter,
7 DrawerHeader,
8 DrawerTitle,
9 DrawerTrigger,
10} from '@/components/ui/drawer'
11import { Button } from '@/components/ui/button'
12
13export function EditDrawer() {
14 return (
15 <Drawer>
16 <DrawerTrigger asChild>
17 <Button>Edit profile</Button>
18 </DrawerTrigger>
19 <DrawerContent>
20 <DrawerHeader>
21 <DrawerTitle>Edit profile</DrawerTitle>
22 <DrawerDescription>
23 Make changes to your profile.
24 </DrawerDescription>
25 </DrawerHeader>
26 <DrawerFooter>
27 <Button>Save</Button>
28 </DrawerFooter>
29 </DrawerContent>
30 </Drawer>
31 )
32}
33
34// Drivn — dot notation + side prop + string props on Header
35import { Drawer } from '@/components/ui/drawer'
36import { Button } from '@/components/ui/button'
37
38export default function Page() {
39 return (
40 <Drawer>
41 <Drawer.Trigger>Edit profile</Drawer.Trigger>
42 <Drawer.Content side="right">
43 <Drawer.Close />
44 <Drawer.Header
45 title="Edit profile"
46 description="Make changes to your profile."
47 />
48 <Drawer.Footer>
49 <Button>Save</Button>
50 </Drawer.Footer>
51 </Drawer.Content>
52 </Drawer>
53 )
54}

Dependency footprint

npx shadcn add drawer writes a wrapper file and adds vaul to your package.json. Vaul is a focused, well-built library — around 30 KB minified — and it earns the line: drag-to-dismiss, snap points, momentum, and iOS body scaling are hard problems solved once. The shadcn file mostly re-exports Vaul's primitives and paints them with the same bg-background and rounded-top utilities you see across the other recipes, with tailwindcss-animate covering the keyframes.

npx drivn add drawer adds nothing to package.json. The Drivn source imports four things — React, the X icon from lucide-react, your local cn helper, and Drivn's Button for the trigger — and the shipped file runs a little over two hundred lines. Everything the panel needs to behave like a modal comes from <dialog> itself, so no portal package, no presence layer, and no animation plugin land in the tree. The tradeoff is honest: no drag gesture, no snap points. When those define the interaction, Vaul is the right tool for that one surface.

1// shadcn/ui — package.json after `npx shadcn add drawer`
2{
3 "dependencies": {
4 "vaul": "^1.x"
5 }
6}
7
8// Drivn — package.json after `npx drivn add drawer`
9// (no new dependencies added)

Animation technique

shadcn's Drawer animates through Vaul. The library reads pointer events, runs transform math during the drag, and snaps to a closed or open position with a spring-style easing when the gesture ends. The visual result is a bottom sheet that follows your finger, and the timing is tuned for mobile.

Drivn animates with plain CSS transitions on translate, using Tailwind 4's starting: and group-open: variants. Each side gets its own translate utility: translate-x-[100%] group-open:translate-x-[0%] for the right side, mirrored for left, and the equivalent on the y axis for top and bottom. The overlay fades via open:bg-overlay open:backdrop-blur-sm on the same <dialog> element. The transition is 300 ms, ease-out, and CSS-only — no JavaScript timer drives the animation. Copy the styles.sides block from drawer.ts into another component and the same slide-in pattern applies.

1// Drivn — animation lives in styles.sides on the registry component
2const styles = {
3 overlay: cn(
4 'group fixed inset-0 m-0 p-0 border-none outline-none',
5 'max-w-none max-h-none w-screen h-dvh overflow-clip',
6 'bg-transparent backdrop-blur-none',
7 'open:bg-overlay open:backdrop-blur-sm',
8 'starting:open:bg-transparent starting:open:backdrop-blur-none',
9 'transition-[display,overlay,background-color,backdrop-filter]',
10 'duration-300 ease-out transition-discrete'
11 ),
12 content: cn(
13 'fixed flex flex-col',
14 'bg-card shadow-xl',
15 'transition-[translate,display,overlay] duration-300',
16 'ease-out transition-discrete'
17 ),
18 sides: {
19 right: cn(
20 'right-0 top-0 h-dvh w-full max-w-[400px]',
21 'border-l border-border',
22 'translate-x-[100%] group-open:translate-x-[0%]',
23 'starting:group-open:translate-x-[100%]'
24 ),
25 left: cn(
26 'left-0 top-0 h-dvh w-full max-w-[400px]',
27 'border-r border-border',
28 'translate-x-[-100%] group-open:translate-x-[0%]',
29 'starting:group-open:translate-x-[-100%]'
30 ),
31 },
32}

Focus, Escape, and outside-click dismiss

shadcn delegates focus trap, Escape, and outside-click handling to Vaul. Vaul covers the edge cases — return focus on close, layered drawers, pointer-down outside detection, body inert on open — at the cost of a runtime dependency tuned for mobile gesture handling.

Drivn leans on the native <dialog> element for the same behaviors. el.showModal() enters the top-layer, traps focus inside, and dispatches a cancel event when Escape is pressed — the component listens for that event and calls setOpen(false) after preventDefault(). Outside-click is a single click handler on the <dialog> that closes when e.target === e.currentTarget. Body overflow is locked to hidden while the drawer is open and restored on close. No portal, no synthetic keydown listener. The behavior is identical to the Dialog component because they share the same primitive.

1React.useEffect(() => {
2 const el = ref.current
3 if (!el) return
4 const onCancel = (e: Event) => {
5 e.preventDefault()
6 setOpen(false)
7 }
8 el.addEventListener('cancel', onCancel)
9 if (open) {
10 if (!el.open) el.showModal()
11 document.body.style.overflow = 'hidden'
12 } else if (el.open) {
13 el.close()
14 document.body.style.overflow = ''
15 }
16 return () => el.removeEventListener('cancel', onCancel)
17}, [open, setOpen])
18
19// outside-click on the <dialog> backdrop
20onClick={(e) => {
21 if (e.target === e.currentTarget) setOpen(false)
22}}

Use cases — when each wins

Pick shadcn's Vaul-backed Drawer when the drawer is a mobile bottom sheet and the drag-to-dismiss gesture is part of the experience. iOS-style sheets that snap to half-height or full-height, photo viewers, and contextual menus on touch devices all benefit from the gesture model Vaul provides. The runtime cost is the price of admission for that feel.

Pick Drivn's Drawer when you want a side-anchored slide-in for desktop or mobile that behaves like a Dialog — settings panels, edit forms, navigation drawers, a notifications tray. Drivn gives you all four sides in one component with one side prop, the API matches every other Drivn compound, and the bundle stays under 2 KB. If you later add a mobile bottom sheet that needs drag gestures, drop in Vaul for that specific surface — the two patterns can coexist without conflict.

Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

No — Drivn Drawer is a side-anchored slide-in, not a gesture-driven bottom sheet. It closes on outside-click, Escape, or the built-in close button. If drag-to-dismiss is critical to your UX, Vaul (the library shadcn wraps) is the right pick for that surface. The two patterns can coexist in the same app — use Drivn Drawer for settings panels and navigation, drop in Vaul for a mobile bottom sheet that needs the gesture.

The native <dialog> element provides top-layer stacking, focus trap, and Escape-via-cancel-event without a runtime dependency. Drivn reuses the same primitive across Dialog and Drawer, which keeps the lifecycle and controlled API identical between the two components. Browser support is universal across evergreen targets, the shipped file stays a little over two hundred lines, and animation is plain CSS transitions on translate.

Yes — pass side="left", side="top", or side="bottom" to Drawer.Content. The styles.sides map in the registry source defines one translate utility per side, so all four edges work out of the box with the same component. The default is right. Each side picks up the correct border (left, right, top, or bottom) and a sensible default width or height — 400 px max-width for side drawers, 400 px height for top and bottom.

Most of it, yes. The controlled API matches — open plus onOpenChange on the root — so any state machine driving a shadcn drawer drives a Drivn drawer as-is. The structural changes happen at the JSX layer: collapse <DrawerHeader><DrawerTitle>...</DrawerTitle><DrawerDescription>...</DrawerDescription></DrawerHeader> into <Drawer.Header title="..." description="..." />, and add the side prop on Drawer.Content if you want left, right, or top instead of the shadcn default of bottom.

The Drawer file starts with "use client" because it manages state with useState and listens to DOM events on the <dialog> element. You can import it from a Server Component, and Next.js draws the client boundary automatically based on the directive at the top of the file. Render the trigger and content inside a server-rendered page — the drawer hydrates on the client without extra configuration in your layout or page.