Skip to content
Drivn
5 min read

How to Add a Drawer to a React App

Step-by-step guide to adding a copy-and-own slide-in Drawer to any React project with the Drivn CLI — native <dialog> element, four sides, no portal.

A drawer is a modal panel that slides in from an edge of the screen — a filter panel from the right, a navigation menu from the left, a details sheet from the bottom. Building one well means promoting the panel above the page, trapping focus inside it, closing on Escape and outside clicks, locking body scroll, and animating the slide smoothly in both directions. Most teams stack a portal library, a focus-trap package, and an animation library to get all of that.

Drivn writes the component into your repository instead. The CLI copies a drawer.tsx file built on the browser's native <dialog> element — calling showModal() handles the top layer, focus trap, and Escape for free, so there is no Radix primitive, no Vaul, and no floating-ui in your package.json. The slide is pure CSS: transition-discrete plus @starting-style variants animate the transform, so there is no animation runtime either. After install the file is yours: read it end to end, restyle the styles object, and ship today. Because it tracks open state with React hooks and drives the native element from an effect, it is a client component marked 'use client', while the page around it can still render on the server.

This guide adds the Drawer to an existing React app in about ten minutes — install the CLI, render it uncontrolled, switch to controlled state, and slide it from any edge. It works the same in Vite + React or a Next.js App Router project. For the Next.js-specific walkthrough see the Next.js Drawer guide; for live patterns see the Drawer examples.

Prerequisites

Before installing the Drawer, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your CSS, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. The Drawer composes Drivn's own Button for its trigger and imports the X icon from lucide-react for the close control — the CLI resolves both and adds lucide-react to your package.json during install if it is missing.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Drawer source file. The command prompts once for your install directory (defaulting to src/components/ui/), writes drawer.tsx, and resolves its component dependency by also writing button.tsx, since the Drawer imports Button directly for its trigger. It adds lucide-react to your package.json if it is not already present. No global config file is created — both files are TypeScript you edit like any other component. Confirm they landed in your editor, then commit them. If your project uses a monorepo layout or a non-standard path, the CLI docs cover the flags for targeting a custom location during install.

1# add the Drawer (the CLI also writes its Button dependency)
2npx drivn add drawer
3
4# verify both files were written
5ls src/components/ui/drawer.tsx src/components/ui/button.tsx

Step 2 — Render an uncontrolled drawer

Open the page where the Drawer should live and import it from your UI directory. The compound API is Drawer, Drawer.Trigger, Drawer.Content, Drawer.Close, Drawer.Header, and Drawer.Footer. In the simplest uncontrolled form the trigger opens the panel and the built-in close button, Escape key, and backdrop click all close it, so you write no useState at all. Drawer.Header takes title and description as string props instead of nested components, and anything you nest between the header and footer becomes the scrollable body. Because the Drawer holds open state with useState and drives the native <dialog> element from an effect, the source begins with 'use client' — in a Next.js App Router project keep this in a small client component and render it as an island. See the Drawer docs for the full prop table.

1'use client'
2import { Drawer } from '@/components/ui/drawer'
3import { Button } from '@/components/ui/button'
4
5export function EditPanel() {
6 return (
7 <Drawer>
8 <Drawer.Trigger>Edit Profile</Drawer.Trigger>
9 <Drawer.Content side="right">
10 <Drawer.Close />
11 <Drawer.Header
12 title="Edit Profile"
13 description="Make changes to your profile."
14 />
15 <div className="flex-1 overflow-y-auto p-6">
16 Body content goes here.
17 </div>
18 <Drawer.Footer>
19 <Button>Save Changes</Button>
20 </Drawer.Footer>
21 </Drawer.Content>
22 </Drawer>
23 )
24}

Step 3 — Switch to controlled state

The uncontrolled form is enough for a trigger button, but when the drawer's open state depends on app logic — opening it after a mutation resolves, or closing it from a keyboard shortcut — pass open and onOpenChange to the root. The source reads const open = controlled ?? uncontrolled, so supplying open switches the component into controlled mode and your useState value becomes the single source of truth. Update it inside onOpenChange to keep the trigger, backdrop, and Escape key in sync. This is the pattern to reach for when a parent owns several drawers or a server action returns and you want to surface a details panel. Bind open to your state and update it in the callback.

1'use client'
2import { useState } from 'react'
3import { Drawer } from '@/components/ui/drawer'
4
5export function FilterDrawer() {
6 const [open, setOpen] = useState(false)
7 return (
8 <Drawer open={open} onOpenChange={setOpen}>
9 <Drawer.Trigger>Filters</Drawer.Trigger>
10 <Drawer.Content side="left">
11 <Drawer.Close />
12 <Drawer.Header title="Filters" />
13 <div className="flex-1 overflow-y-auto p-6">
14 Filter controls here.
15 </div>
16 </Drawer.Content>
17 </Drawer>
18 )
19}

Step 4 — Slide from any edge and customize styles

The Content sub-component takes a side prop — 'left', 'right', 'top', or 'bottom', defaulting to 'right'. The value indexes into a styles.sides map that sets the panel's position, size, and starting transform. A right drawer is max-w-[400px] pinned to the right edge with translate-x-[100%]; when the dialog opens, group-open:translate-x-[0%] slides it home, and the starting: variant sets where the animation begins. Top and bottom drawers are full-width and h-[400px], translating on the Y axis instead. To restyle globally, open src/components/ui/drawer.tsx and edit the styles object — header, title, description, footer, and close each read from your Tailwind tokens, so changing the colors in theming re-themes every Drawer at once. The styles.sides slice below is copied verbatim from drawer.ts.

1sides: {
2 right: cn(
3 'right-0 top-0 h-dvh w-full max-w-[400px]',
4 'border-l border-border',
5 'translate-x-[100%] group-open:translate-x-[0%]',
6 'starting:group-open:translate-x-[100%]'
7 ),
8 left: cn(
9 'left-0 top-0 h-dvh w-full max-w-[400px]',
10 'border-r border-border',
11 'translate-x-[-100%] group-open:translate-x-[0%]',
12 'starting:group-open:translate-x-[-100%]'
13 ),
14 // ...top, bottom
15}
Get started

Install Drivn in one command

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

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add drawer. The Drawer has no dependency on Next.js or any router — it renders anywhere React and Tailwind reach the DOM. Vite + React is the most common non-Next setup and works without extra configuration; the CLI also writes the Button dependency the Drawer uses for its trigger and adds lucide-react for the close icon.

The Drawer is a client component — it is marked 'use client' because it holds open state with useState and drives the native <dialog> element from an effect. Your surrounding page and layout still render on the server. The usual pattern is a small client component holding the Drawer and its trigger, rendered as an island inside an otherwise server-rendered route. Next.js inserts the client boundary at the import automatically.

No. The Drawer renders the browser's native <dialog> element and calls showModal(), which promotes it to the top layer above all page content and traps focus without any portal or focus-trap dependency. The slide animation is pure CSS — transition-discrete combined with @starting-style variants animates the panel's transform. There is no Vaul, no framer-motion, and no floating-ui; the whole behavior lives in the one file the CLI writes, plus lucide-react for the icon and Drivn's Button.

Pass a side prop to Drawer.Content'left', 'right', 'top', or 'bottom'. It defaults to 'right'. The value indexes a styles.sides map that sets the panel position and its starting transform, so a left drawer slides in from the left edge and a bottom drawer rises from the bottom. You can mix sides across a single app and edit the width or height of each in the source file you own after install.

Both are wired in the Content source. A backdrop click closes because the outer onClick checks e.target === e.currentTarget and calls setOpen(false). Escape is handled through the native cancel event — an effect calls e.preventDefault() then setOpen(false) so React stays the source of truth. The same effect calls showModal() on open, locks body scroll by setting document.body.style.overflow to hidden, and restores it on close.