How to Add a Navigation Menu to a React App
Step-by-step guide to adding a hover-and-focus navigation menu to any React project with the Drivn CLI — CSS-only disclosure and zero state.
Almost every React app grows a top navigation bar — the horizontal row of triggers that drop panels of links to your product, docs, and pricing. Rolling one by hand means wiring hover state, focus handling, click-outside logic, and keyboard access, and it usually ends as a tangle of useState and event listeners that breaks the first time someone tabs through it. Most teams reach for a headless menu library and inherit its runtime and its API surface along with the markup.
Drivn takes the opposite route: the CLI copies a navigation-menu.tsx file straight into your repository, and the disclosure runs on CSS rather than JavaScript state. The Navigation Menu is a small family of functions joined with Object.assign — the root <nav> plus .List, .Item, .Trigger, .Content, and .Link. Panels open under group-hover/item and group-focus-within/item selectors, so a pointer and a keyboard reach the same panel with no useState toggle to keep in sync. It carries a 'use client' directive only because the trigger runs one onPointerDown handler to close the panel on a second tap.
This guide adds the menu to any React app in a few minutes — install the CLI, render the bar, fill a dropdown with links, and restyle the one styles object it reads from. It works the same in Vite + React or a Next.js App Router project. For the framework-specific walkthrough see the Next.js Navigation Menu guide.
Prerequisites
Before installing the Navigation Menu, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, 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 Navigation Menu imports the ChevronDown glyph from lucide-react for the trigger caret, so keep that package installed — it is the component's only import beyond React and the cn utility.
Step 1 — Install Drivn via the CLI
Run the CLI from your project root to add the Navigation Menu source file. The command prompts once for your install directory (defaulting to src/components/ui/), writes navigation-menu.tsx, and finishes. No global config file is created; the result is one TypeScript file you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. For single-trigger menus that open on click rather than hover, add the Drivn Dropdown alongside it with the same command.
1 # add the Navigation Menu source file 2 npx drivn add navigation-menu
Step 3 — Fill a dropdown and mix in direct links
A real header mixes dropdown items with direct links. Give a dropdown item a NavigationMenu.Trigger plus a NavigationMenu.Content panel, and give a direct link a single NavigationMenu.Link with no trigger — the Item wrapper keeps both aligned in the row. Each NavigationMenu.Link renders a native <a href> styled with the styles.link class, a flex w-full gap-3 row, so drop a Lucide icon and a <span> label inside it, or stack a bold title over a muted <p> description by passing className="flex-col gap-0.5" on that link. The panel itself is a min-w-[220px] card, so fill it with a single column, a two-column grid, or a custom layout. For richer link cards with a thumbnail, pair the panel with the Drivn Card component.
1 import { NavigationMenu } from '@/components/ui/navigation-menu' 2 3 export function GettingStartedMenu() { 4 return ( 5 <NavigationMenu> 6 <NavigationMenu.List> 7 <NavigationMenu.Item> 8 <NavigationMenu.Trigger>Getting Started</NavigationMenu.Trigger> 9 <NavigationMenu.Content> 10 <NavigationMenu.Link 11 href="/docs/installation" 12 className="flex-col gap-0.5" 13 > 14 <span className="font-medium">Installation</span> 15 <p className="text-xs text-muted-foreground"> 16 Get up and running in minutes 17 </p> 18 </NavigationMenu.Link> 19 </NavigationMenu.Content> 20 </NavigationMenu.Item> 21 <NavigationMenu.Item> 22 <NavigationMenu.Link href="/pricing">Pricing</NavigationMenu.Link> 23 </NavigationMenu.Item> 24 </NavigationMenu.List> 25 </NavigationMenu> 26 ) 27 }
Step 4 — Understand the disclosure and restyle
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. That focus-within half is what keeps the menu accessible without a single event handler. Every class the menu renders lives in one styles object at the top of the file you own — root, list, item, trigger, triggerIcon, content, and link. Widen the panel by bumping min-w-[220px] in content, restyle the trigger hover state through trigger, or change the caret spin through triggerIcon. Because the colors read Tailwind tokens like bg-card and bg-accent, changing them in theming re-themes the whole menu for dark and light mode. The styles.content object below is copied verbatim from navigation-menu.ts.
1 // styles.content — CSS-only disclosure (verbatim from navigation-menu.tsx) 2 content: 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 ),
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
Frequently asked questions
Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add navigation-menu. The component has no dependency on Next.js or any router — the NavigationMenu.Link renders a native <a href>, so it works anywhere React and Tailwind reach the DOM. Its only import beyond React and the cn utility is the ChevronDown icon from lucide-react for the trigger caret, so the CLI writes a single navigation-menu.tsx file with nothing else to configure after install.
The source carries a 'use client' directive 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 panels themselves open through CSS, but the pointer handler needs the browser, so the file as a whole is a client component. If you do not need the touch-tap-to-close behavior, delete the onPointerDown handler from the Trigger and remove the 'use client' directive — the rest of the component is server-renderable because the disclosure is pure CSS.
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.
The default disclosure is group-hover/item plus group-focus-within/item, both pure CSS. To switch to click-only, lift the Content visibility into React state — wrap the Item in a small client component, track an open boolean with useState, and toggle it from an onClick on the Trigger — then replace the group-hover/item:opacity-100 rule in your local navigation-menu.tsx with a conditional class like open && 'opacity-100 visible'. For a menu that is click-driven out of the box, use the Drivn Dropdown component instead, which ships that behavior.
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.

