Skip to content
Drivn
6 min read

Next.js Navigation Menu with Dropdowns

Add a Next.js navigation menu with dropdown panels that open on hover and keyboard focus. Drivn ships one client component, CSS-only disclosure, zero state.

A top navigation menu is the spine of a marketing site or a docs app — the horizontal bar of triggers that drop panels of links to your product, pricing, and documentation. In the Next.js App Router the interesting question is where the client boundary falls: a menu that opens on hover and keyboard focus needs to run in the browser, yet the routes it points at are App Router pages you want to reach without a full reload. Drivn's Navigation Menu draws that line cleanly.

After install it lives in src/components/ui/navigation-menu.tsx as a small family of functions wired together with Object.assign — the root plus .List, .Item, .Trigger, .Content, and .Link. It carries a 'use client' directive because the trigger runs an onPointerDown handler that toggles focus, but it holds no useState: the panels open and close entirely through CSS, using group-hover/item and group-focus-within/item selectors on the styles.content class. The disclosure logic ships as Tailwind classes, not JavaScript state, and it keeps working for keyboard and pointer users alike.

This guide installs Drivn in a Next.js 16 project, renders the menu bar, fills a dropdown panel with links to App Router routes, explains the hover-and-focus disclosure, and restyles the styles object you own. Every snippet comes from the component's real API. For the full reference see the Navigation Menu docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Navigation Menu.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add navigation-menu. The component imports the ChevronDown glyph from lucide-react for the trigger caret, so keep that package installed; the CLI writes a single navigation-menu.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it. Commit it for a clean baseline before wiring the menu into your layout.

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

Render the menu bar in a Client Component

Because the trigger runs an onPointerDown handler that manually toggles focus — so a tap opens the panel and a second tap closes it on touch devices — the component source carries a 'use client' directive. Import it into your header or layout as you would any client component; Next.js draws the boundary at the file that renders it. The root renders a <nav>, NavigationMenu.List renders the <ul> row, and each NavigationMenu.Item holds either a Trigger/Content pair for a dropdown or a bare Link for a flat top-level entry. The Trigger appends a ChevronDown that rotates 180 degrees when the panel opens. Render the bar once in your root layout so it persists across App Router navigations, and pair it with the Drivn Dropdown when you need a click-driven menu instead.

1import { NavigationMenu } from '@/components/ui/navigation-menu'
2import { LayoutGrid, MessageSquare } from 'lucide-react'
3
4export function SiteNav() {
5 return (
6 <NavigationMenu>
7 <NavigationMenu.List>
8 <NavigationMenu.Item>
9 <NavigationMenu.Trigger>
10 Components
11 </NavigationMenu.Trigger>
12 <NavigationMenu.Content>
13 <NavigationMenu.Link href="/docs/components/card">
14 <LayoutGrid />
15 <span className="font-medium">Card</span>
16 </NavigationMenu.Link>
17 <NavigationMenu.Link href="/docs/components/dialog">
18 <MessageSquare />
19 <span className="font-medium">Dialog</span>
20 </NavigationMenu.Link>
21 </NavigationMenu.Content>
22 </NavigationMenu.Item>
23 <NavigationMenu.Item>
24 <NavigationMenu.Link href="/docs">Docs</NavigationMenu.Link>
25 </NavigationMenu.Item>
26 </NavigationMenu.List>
27 </NavigationMenu>
28 )
29}

Hover-and-focus disclosure, no state

The panels open with no JavaScript state. Each NavigationMenu.Item carries group/item, and the styles.content class starts at opacity-0 invisible and flips to opacity-100 visible under both group-hover/item: and group-focus-within/item: — so the same panel appears whether the user hovers the trigger with a pointer or tabs into it with the keyboard. The focus-within half is what keeps the menu accessible: a keyboard user tabs to the trigger, the panel becomes visible because focus is inside the item, and the links inside become reachable in order. The transition runs on opacity and visibility with ease-in on close and ease-out on open, so the panel fades rather than snapping. Because the logic is Tailwind classes rather than a useState boolean, there is no hydration cost for the open and close behavior and nothing to fall out of sync between the server and client render.

1// styles.content — CSS-only disclosure (verbatim from navigation-menu.tsx)
2content: cn(
3 'absolute left-0 top-full min-w-[220px] z-50',
4 'bg-card border border-border rounded-xl p-2',
5 'shadow-lg shadow-black/8',
6 'origin-[var(--origin)]',
7 'transition-[opacity,visibility]',
8 'duration-150 ease-in',
9 'opacity-0 invisible',
10 'group-hover/item:opacity-100',
11 'group-hover/item:visible',
12 'group-hover/item:ease-out',
13 'group-focus-within/item:opacity-100',
14 'group-focus-within/item:visible',
15 'group-focus-within/item:ease-out'
16),

Customize the styles object

Every class the menu renders lives in one styles object at the top of the file you own after install — root, list, item, trigger, triggerIcon, content, and link. Restyle the trigger's hover state by editing trigger, widen the panel by bumping min-w-[220px] in content, or change the caret spin through triggerIcon, whose group-hover/item:rotate-180 and group-focus-within/item:rotate-180 rules turn the ChevronDown when the panel opens. Because the colors read from Tailwind tokens — bg-card, border-border, bg-accent — changing them in theming re-themes the whole menu for dark and light mode at once. Edit the object in place and every menu in your app updates together. For copy-paste panel layouts see the Navigation Menu examples.

1// styles.triggerIcon — caret rotation (verbatim)
2triggerIcon: cn(
3 'w-3.5 h-3.5 transition-transform duration-200',
4 'group-hover/item:rotate-180',
5 'group-focus-within/item:rotate-180'
6),
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 — the Navigation Menu source carries a 'use client' directive because the trigger runs an onPointerDown handler that manually toggles focus so a tap opens and closes the panel on touch devices. Render it from a client component or place it in a layout that Next.js can treat as a client boundary. The panels themselves open through CSS, but the pointer handler needs the browser, so the component as a whole is a client component. The App Router routes it links to stay server-rendered as normal.

The built-in NavigationMenu.Link renders a native <a href>, which performs a full-document navigation. To get the App Router's soft, client-side transitions, render Next's <Link> inside the NavigationMenu.Item or NavigationMenu.Content and give it the same styles.link utility classes so it matches visually. Reserve the native NavigationMenu.Link for external URLs like a status page or a docs site on another domain, where a hard navigation is what you actually want. You can mix both kinds of link inside a single panel.

Yes. The styles.content panel is hidden with opacity-0 invisible and revealed under two selectors — group-hover/item: for pointer users and group-focus-within/item: for keyboard users. When someone tabs to the trigger, focus lands inside the item, focus-within matches, and the panel becomes visible so its links enter the tab order. That means the same markup serves mouse and keyboard navigation without a separate handler, and there is no useState toggle to keep in sync between the two input modes.

Open navigation-menu.tsx after install and edit the styles.content string — bump min-w-[220px] for a wider panel, swap bg-card or border-border for different surface tokens, or adjust rounded-xl and p-2 for a different shape. Because the panel reads its colors from Tailwind tokens, changing those tokens in theming re-themes every menu at once for dark and light mode. The whole visual surface lives in one styles object, so a width or color change is a single edit in the file you own.

Yes. Every NavigationMenu.Link accepts children, so drop a Lucide icon and a <span> label inside it, or stack a bold title over a muted <p> description for a richer panel. The link's base class is flex w-full gap-3, which lays icon and text out in a row; add a className like flex-col gap-0.5 on an individual link for a stacked title-and-description layout. The panel is a plain card, so any custom grid or multi-column layout you build inside NavigationMenu.Content works.