Skip to content
Drivn
5 min read

Next.js Drawer for the App Router

Add a Next.js Drawer that slides in from any edge with focus trapping and scroll lock. Drivn builds on the native <dialog> element, zero runtime deps.

A slide-in drawer is a modal that enters from an edge of the screen — a filter panel from the right, a nav from the left, a sheet from the bottom. In the Next.js App Router that means the same hard problems a dialog has, plus a transform animation: trap focus, close on Escape, lock body scroll, and slide the panel in and out smoothly, all from a client island in a mostly server-rendered tree. The common approach stacks a portal library on a focus-trap package on an animation library.

Drivn's Drawer does it with the browser. After install it lives in src/components/ui/drawer.tsx, marks itself 'use client', and builds on the native HTML <dialog> element. Calling showModal() promotes the drawer to the top layer, traps focus, and handles Escape for free — no portal, no focus-trap dependency, no z-index war. The slide animation is pure CSS: transition-discrete plus @starting-style variants drive the transform, so there is no animation library either. It imports React, the X icon from lucide-react, the cn utility, and Drivn's own Button, and nothing else.

This guide installs Drivn in a Next.js 16 project, renders the Drawer as a client island, slides it from any edge, and wires controlled open state. Every snippet comes from the component's real API. For the full reference see the Drawer docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Drawer.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes the component source directly into your repository — there is no runtime npm package to version-lock. From the root of your Next.js 16 project run npx drivn add drawer. Because the Drawer imports Drivn's Button for its trigger, the CLI pulls that file in too, and adds lucide-react to your package.json if it is missing — that is the drawer's only third-party dependency, used for the close icon. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own both files; future Drivn releases will not overwrite them. Commit the change for a clean baseline before customizing.

1# from the root of your Next.js 16 project
2npx drivn add drawer

Render as a client island

An App Router page is a server component by default, so it cannot hold the drawer's open state. The Drawer is marked 'use client' at the top of its source because it tracks open with React state and toggles the native element from an effect. The clean pattern is to keep the Drawer and its trigger inside a small client component, then render that island in an otherwise server-rendered page. The compound API is Drawer, Drawer.Trigger, Drawer.Content, Drawer.Close, Drawer.Header, and Drawer.Footer; in the simplest uncontrolled form the trigger manages open state for you, so you write no useState at all. No dynamic() import and no SSR-disable flag are needed — Next.js inserts the client boundary at the import. See the installation guide for project setup.

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>
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}

Slide from any edge

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. Top and bottom drawers are full-width and h-[400px], translating on the Y axis instead. Because each side is just a class string in the file you own, you can change the width, height, or which edge a drawer uses per instance without touching the component logic. 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}

How open, close, and the slide animation work

The Content sub-component renders a real <dialog> element and drives it through one effect: when open becomes true it calls el.showModal() and sets document.body.style.overflow to hidden for scroll lock; when it becomes false it calls el.close() and restores overflow. Escape is wired through the native cancel event — the effect calls e.preventDefault() and setOpen(false) so React stays the source of truth. A backdrop click closes too, because the outer onClick checks e.target === e.currentTarget. The slide itself needs no JavaScript timers: the overlay class list combines transition-discrete with starting: variants, so the browser animates the translate transform through @starting-style when the <dialog> enters the top layer. The overlay slice below is verbatim from drawer.ts.

1overlay: cn(
2 'group fixed inset-0 m-0 p-0 border-none outline-none',
3 'max-w-none max-h-none w-screen h-dvh overflow-clip',
4 'bg-transparent backdrop-blur-none',
5 'open:bg-overlay open:backdrop-blur-sm',
6 'starting:open:bg-transparent starting:open:backdrop-blur-none',
7 'transition-[display,overlay,background-color,backdrop-filter]',
8 'duration-300 ease-out transition-discrete'
9),

Controlled open 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 state becomes the single source of truth. This is the pattern to reach for in App Router projects where a server action returns and you want to surface a details drawer, or where a parent island owns several drawers. Bind open to your useState value and update it inside onOpenChange. For live patterns see the Drawer examples.

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}
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

The Drawer itself is a client component — it is marked 'use client' because it tracks open state with React hooks and drives the native <dialog> element from an effect. Your surrounding page and layout can still render on the server. The standard 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 as the dialog enters the top layer. No framer-motion, no floating-ui, no portal library.

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.

Switch it to controlled mode by passing open and onOpenChange to the root. The source uses controlled ?? uncontrolled, so once you supply open your state drives the drawer and you can close it from anywhere — after a server action resolves, on a keyboard shortcut, or from a parent island. In uncontrolled mode the trigger and the Drawer.Close button manage state for you with no extra wiring.