Skip to content
Drivn
5 min read

Next.js Dropdown for the App Router

Add a Next.js dropdown menu with grouped items, icons, and outside-click dismiss. Drivn ships one client component, zero runtime deps, no portal.

A dropdown menu is a small panel that opens under a trigger button — an account menu, a row of row actions, a sort selector. In the Next.js App Router the trigger and panel need client interactivity: track open state, position the panel under the button, dismiss when you click anywhere else, and pick items with a keyboard or mouse. The usual stack layers a floating-position library and a portal on top of a menu primitive to get there, and every one of those adds runtime JavaScript to a route that is otherwise server-rendered.

Drivn's Dropdown does it with plain React. After install it lives in src/components/ui/dropdown.tsx, marks itself 'use client', and needs nothing but React, the cn utility, and Drivn's own Button for the trigger. There is no floating-ui, no portal, and no menu library. The panel is absolute top-full under the trigger, a React context shares open state between the parts, and a single mousedown listener on the document closes the menu when you click outside. The open and close transition is pure CSS — transition-[opacity,scale] swaps two class sets.

This guide installs Drivn in a Next.js 16 project, renders the Dropdown as a client island, builds a grouped menu with icons and a destructive item, and aligns the panel to either edge. Every snippet comes from the component's real API. For the full reference see the Dropdown docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Dropdown.

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 dropdown. Because the Dropdown 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 — you use it for the icons on menu items, not the component itself. 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 you start customizing the menu.

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

Render as a client island

An App Router page is a server component by default, so it cannot hold the dropdown's open state. The Dropdown is marked 'use client' at the top of its source because it tracks open with React state and listens for outside clicks in an effect. The clean pattern is to keep the Dropdown and its trigger inside a small client component, then render that island in an otherwise server-rendered page. The compound API is Dropdown, Dropdown.Trigger, Dropdown.Content, Dropdown.Item, Dropdown.Group, Dropdown.Label, and Dropdown.Separator; the trigger toggles the menu for you, so the simplest form needs no useState in your code. No dynamic() import and no SSR-disable flag are required — Next.js inserts the client boundary at the import. See the installation guide for project setup.

1'use client'
2import { Dropdown } from '@/components/ui/dropdown'
3import { Edit, Trash2 } from 'lucide-react'
4
5export function RowActions() {
6 return (
7 <Dropdown>
8 <Dropdown.Trigger>Menu</Dropdown.Trigger>
9 <Dropdown.Content>
10 <Dropdown.Item icon={Edit}>
11 Edit
12 </Dropdown.Item>
13 <Dropdown.Item icon={Trash2} destructive>
14 Delete
15 </Dropdown.Item>
16 </Dropdown.Content>
17 </Dropdown>
18 )
19}

Group items, add labels and separators

A real menu is more than a flat list. Dropdown.Group wraps a related set, Dropdown.Label renders a small muted heading above a group, and Dropdown.Separator draws a one-pixel divider between sections. Each Dropdown.Item takes an icon prop — pass the lucide-react component reference like icon={User}, not a rendered element — and an optional destructive boolean that switches it to the destructive text color and a red-tinted hover. Every item accepts an onClick, and the source calls setOpen(false) right after your handler runs, so the menu closes on selection without extra wiring. The structure below composes an account menu with a label, two grouped items, a separator, and a destructive sign-out. For live variations see the Dropdown examples.

1<Dropdown.Content>
2 <Dropdown.Group>
3 <Dropdown.Label>Account</Dropdown.Label>
4 <Dropdown.Item icon={User}>Profile</Dropdown.Item>
5 <Dropdown.Item icon={Settings}>Settings</Dropdown.Item>
6 </Dropdown.Group>
7 <Dropdown.Separator />
8 <Dropdown.Item icon={LogOut} destructive>
9 Sign out
10 </Dropdown.Item>
11</Dropdown.Content>

How open, close, and outside-click work

The root component holds open in useState and a ref on the wrapping <div>. An effect registers one mousedown listener on document: if the click target is not contained by the ref, it closes the menu. That is the entire dismiss mechanism — no portal, no overlay element, no focus-trap dependency. Dropdown.Content reads open from context and toggles between two class sets, opacity-100 scale-100 when open and opacity-0 scale-95 pointer-events-none when closed, so the panel fades and scales through a CSS transition and stops intercepting clicks while hidden. Because the listener lives in an effect with a cleanup return, it attaches on mount and detaches on unmount cleanly across App Router navigations. The effect below is copied verbatim from dropdown.tsx.

1React.useEffect(() => {
2 const onClick = (e: MouseEvent) => {
3 if (!ref.current?.contains(e.target as Node))
4 close()
5 }
6 document.addEventListener('mousedown', onClick)
7 return () =>
8 document.removeEventListener('mousedown', onClick)
9}, [close])

Align the menu and customize styles

The root takes an align prop — 'left' (default) or 'right' — that indexes a tiny styles.align map setting left-0 or right-0 on the panel. Use align="right" when the trigger sits near the right edge of the viewport so the menu does not overflow. Everything else is a class string in the styles object at the top of the file you own: content sets the panel surface (bg-card border border-border rounded-[10px] p-1 with min-w-[180px] and shadow-lg), item sets the row, and destructive sets the red variant. Edit any of them once and every dropdown in your app updates, and because the colors read from your Tailwind tokens, changing them in theming re-themes the menu for dark and light mode. The content and align slices below are verbatim from dropdown.ts.

1content: cn(
2 'absolute top-full mt-1 min-w-[180px] z-50',
3 'bg-card border border-border rounded-[10px] p-1',
4 'shadow-lg',
5 'transition-[opacity,scale] duration-150 ease-out'
6),
7align: {
8 left: 'left-0',
9 right: 'right-0',
10},
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 Dropdown itself is a client component — it is marked 'use client' because it tracks open state with React hooks and listens for outside clicks in an effect. Your surrounding page and layout can still render on the server. The standard pattern is a small client component holding the Dropdown 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 panel is a plain absolute top-full element positioned directly under the trigger inside the same wrapper <div>, so there is no portal and no floating-ui or popper dependency. Dismissal is a single mousedown listener on the document that closes the menu when you click outside the wrapper. The whole behavior lives in the one file the CLI writes, plus Drivn's own Button for the trigger.

You do not have to — the Item source calls setOpen(false) immediately after running your onClick handler, so selecting any item closes the menu automatically. Clicking the trigger again or clicking anywhere outside the wrapper also closes it. If you need an item that keeps the menu open, that is a small edit to the Item function in the source file you own after install.

Pass align="right" to the Dropdown root. It indexes a styles.align map that sets right-0 on the panel instead of the default left-0, so the menu aligns its right edge to the trigger and grows leftward. Use it for triggers near the right side of the viewport or in a table's trailing actions column. You can also edit min-w-[180px] in styles.content to change the panel width.