Skip to content
Drivn
6 min read

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
2npx drivn add navigation-menu

Step 2 — Render the menu bar

Import NavigationMenu from your UI directory and render the bar. 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 NavigationMenu.Link for a flat top-level entry like "Docs". The Trigger renders a button and appends a ChevronDown that rotates 180 degrees when the panel opens. Because the trigger runs an onPointerDown handler to dismiss focus on a second tap, the source carries a 'use client' directive — import it into a client component, or in the App Router render it inside a layout Next.js treats as a client boundary. In a Vite + React app there is no boundary to think about: everything runs on the client already.

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}

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)
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),
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 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.