Drivn vs shadcn/ui — Navigation Menu Compared
Compare Drivn NavigationMenu vs shadcn/ui — a CSS group-hover dropdown with zero runtime deps versus a Radix primitive with viewport and indicator.
Both Drivn's NavigationMenu and shadcn/ui's produce the same thing on screen: a horizontal <nav> of triggers, each revealing a panel of links. Where they part company is underneath. Drivn's version is a single navigation-menu.tsx file of roughly 150 lines — a styles object, five sub-components, and one event handler. shadcn/ui's is a wrapper around @radix-ui/react-navigation-menu, and installing it brings a state controller, a focus manager, a viewport positioner, and an indicator element into the bundle alongside the markup.
The open/close behavior is the clearest split. Drivn never tracks open state at all. Each Item carries relative group/item, and the Content panel toggles opacity and visibility through group-hover/item:* and group-focus-within/item:* selectors. No useState, no listener, no portal, no data-state attribute. The file carries "use client" for exactly one reason: the Trigger's onPointerDown handler blurs the button when it is already focused, so a second tap on a touch device dismisses the panel.
Radix takes the opposite route. It owns the active item in JavaScript, writes data-state="open" onto the trigger, mounts the panel into a shared Viewport, and slides an Indicator under whichever trigger is live. That machinery buys arrow-key navigation across triggers and a panel that morphs between sizes. This page compares the two across runtime weight, the open mechanism, the focus model, the viewport story, and the call site of a real product header.
Side-by-side comparison
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Underlying primitive | Native <nav> + CSS group-hover | @radix-ui/react-navigation-menu wrapper |
| Runtime UI dependencies | lucide-react (ChevronDown only) | @radix-ui/react-navigation-menu + lucide-react |
| Open/close mechanism | CSS group-hover + group-focus-within | JS state controller (data-state="open") |
| Keyboard arrow navigation | Native tab order | Arrow keys move between triggers |
| Viewport positioner | NavigationMenu.Viewport element | |
| Active indicator element | NavigationMenu.Indicator slides under trigger | |
| "use client" directive | ||
| Open transition | opacity + visibility, 150ms | data-[motion] keyframe animations |
| Trigger icon rotation | group-hover/item:rotate-180 | data-[state=open]:rotate-180 |
| Dot-notation API | NavigationMenu.List, .Item, .Trigger, .Content, .Link | NavigationMenuList, NavigationMenuItem, etc. |
| Total component lines | ~150 | ~130 wrapper + Radix runtime |
| License | MIT | MIT |
| Copy-paste install |
The runtime footprint
Count what actually lands in node_modules after each install. Drivn's NavigationMenu adds lucide-react for the single ChevronDown glyph on the Trigger, and nothing else. The file imports React and the cn helper, declares a styles object with seven keys — root, list, item, trigger, triggerIcon, content, link — and bolts five sub-components onto the root through Object.assign. That is the entire surface: class strings plus one onPointerDown handler. Running npx drivn add navigation-menu grows the dependency graph by nothing you were not already shipping.
shadcn/ui's version wraps @radix-ui/react-navigation-menu, which is roughly fifteen kilobytes minified before it pulls in its own shared internals — @radix-ui/react-context, @radix-ui/react-primitive, @radix-ui/react-collection. Each of those exists to support features Drivn deliberately does not have: cross-trigger arrow keys, a shared viewport, a sliding indicator. If the header never uses them, the runtime is weight with no payoff attached. The same accounting shows up in Drivn vs shadcn Dropdown Menu and Drivn vs shadcn Button, where Radix carries the state and Drivn hands it back to the browser.
1 // Drivn — group-hover CSS, no state controller (verbatim from registry) 2 const styles = { 3 root: 'relative', 4 list: 'flex items-center gap-1', 5 item: 'relative group/item', 6 trigger: cn( 7 'inline-flex items-center gap-1 px-3 py-2', 8 'text-sm font-medium text-foreground rounded-lg', 9 'hover:bg-accent transition-colors cursor-pointer', 10 'outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' 11 ), 12 triggerIcon: cn( 13 'w-3.5 h-3.5 transition-transform duration-200', 14 'group-hover/item:rotate-180', 15 'group-focus-within/item:rotate-180' 16 ), 17 content: cn( 18 'absolute left-0 top-full min-w-[220px] z-50', 19 'bg-card border border-border rounded-xl p-2', 20 'shadow-lg shadow-black/8', 21 'origin-[var(--origin)]', 22 'transition-[opacity,visibility]', 23 'duration-150 ease-in', 24 'opacity-0 invisible', 25 'group-hover/item:opacity-100', 26 'group-hover/item:visible', 27 'group-hover/item:ease-out', 28 'group-focus-within/item:opacity-100', 29 'group-focus-within/item:visible', 30 'group-focus-within/item:ease-out' 31 ), 32 link: cn( 33 'flex w-full gap-3 px-3 py-2.5', 34 'text-sm font-medium text-foreground rounded-lg', 35 'hover:bg-accent transition-colors cursor-pointer', 36 'no-underline outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' 37 ), 38 }
The open/close mechanism
Drivn's open/close is pure CSS. Each Item carries relative group/item, and the Content inside it carries opacity-0 invisible by default plus group-hover/item:opacity-100 group-hover/item:visible and the matching group-focus-within/item:* rules. Hover the trigger or tab into it, the parent item gets the hover/focus state, and the content panel becomes visible through a 150ms transition-[opacity,visibility]. Close happens the moment hover and focus leave the item. No React state, no controller, no data-state attribute — the browser handles it as native CSS state transitions, which means SSR works without a hydration boundary and the panel never "flashes" closed on first render.
shadcn/ui's open/close is JavaScript-driven through Radix. The primitive owns a controller that tracks the active item, sets data-state="open" on the open trigger, and mounts the matching content into the viewport. The model gives you a single open panel at a time even when the user hovers multiple triggers in quick succession, and it supports controlled mode via value and onValueChange props. The trade is a runtime that has to mount and reconcile state on every interaction, plus animation timing handled through data-[motion=from-start]:slide-in-from-left style attribute selectors rather than transition rules. Both reach the same end visual; the underlying execution model is where they diverge.
Viewport, indicator, and animation
shadcn/ui's NavigationMenu exposes two parts Drivn does not — NavigationMenu.Viewport and NavigationMenu.Indicator. The Viewport is a single shared container that the active panel renders into, positioned absolutely below the list, with its width and height animated as the user moves between triggers (Apple's top-bar style). The Indicator is a small element — typically a triangle or a bar — that slides horizontally to sit under the active trigger. Both effects require the Radix controller to know which trigger is active, which is part of why the runtime exists.
Drivn's NavigationMenu has neither. Each Content panel sits absolutely positioned at left-0 top-full directly under its own Item — so the panel appears under the trigger that hovered it, with no shared viewport, no width animation between triggers, and no sliding indicator. The trade-off is visual: a Drivn menu looks like a series of independent dropdowns, while a shadcn menu feels like a single morphing surface. If the morphing surface is the design goal — Apple, Vercel, Linear-style top bars — shadcn fits cleanly. If the independent dropdown is the goal, Drivn is forty fewer lines of code and zero Radix runtime to ship. See the NavigationMenu examples page for the call-site shapes both styles look like at typical product-header complexity.
When each wins
Pick shadcn/ui's NavigationMenu when the design calls for a morphing viewport (one shared panel that resizes between triggers), arrow-key navigation across triggers, a sliding indicator under the active trigger, or a controlled value API so the open state is driven by a route or analytics event. The Radix runtime earns its weight on application toolbars where the menu is a real command surface and the keyboard discoverability is a feature rather than a footnote. The library also slots in cleanly when the rest of the form stack already uses Radix primitives like Drivn vs shadcn Dropdown Menu territory.
Pick Drivn's NavigationMenu when the menu is a marketing or product header with hover-revealed dropdowns, the panels are independent and do not need to share a viewport, and the priority is shipping fewer kilobytes of JavaScript. The CSS-driven open/close runs without hydration cost, the markup is one local TypeScript file over a native <nav>, and the dot-notation API — NavigationMenu.List, .Item, .Trigger, .Content, .Link — composes through a single import. Pair it with a Drivn Button for the call-to-action on the right side of the header and a Drivn Dialog for the mobile menu, and the whole header ships without any Radix runtime in the bundle.
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
Yes. The Content panel is hidden by default through opacity-0 invisible and only revealed when the parent Item has the group-hover/item or group-focus-within/item state. When the mouse leaves the item or focus moves to a different item, the parent loses both states and the panel returns to hidden through a 150ms transition-[opacity,visibility]. There is no JavaScript timer, no React state, no controller — the close behavior is the absence of hover and focus on the item, which the browser tracks natively.
No. The keyboard model is native tab order — Tab moves to the next focusable element on the page, which is the next Trigger button if the user has not yet tabbed into the open Content. To match shadcn's arrow-key model, add a onKeyDown handler to the List that intercepts ArrowLeft and ArrowRight and shifts focus between sibling triggers. For most marketing and product headers, native tab order is the correct affordance because users are navigating to pages rather than selecting from a command menu, but the arrow-key pattern is reachable as a local edit to the installed source.
The file is marked "use client" because the Trigger runs an onPointerDown handler to dismiss focus when the user taps the trigger a second time on touch devices. If you remove the Trigger focus-dismiss handler, the rest of the component is server-renderable because the open/close mechanism is pure CSS — no useState, no useEffect, no client APIs. You can also keep the "use client" boundary on the NavigationMenu itself and render it inside a Server Component header layout; the boundary is local to the component, not viral up the tree.
Yes. The default transition is transition-[opacity,visibility] duration-150 plus an origin-[var(--origin)] value that the Content reads from a CSS custom property. To add a slide, extend the styles.content class string with translate-y-1 on the closed state and group-hover/item:translate-y-0 on the open state, and add translate to the transition shorthand. The edit lives in the local @/components/ui/navigation-menu.tsx file, so the change is a four-line tweak in one place and applies to every NavigationMenu panel in the app.
Yes — pair it with the Drivn Drawer at a responsive breakpoint. The standard pattern is to render the NavigationMenu inside a hidden md:flex wrapper so it only shows above the medium breakpoint, and render a hamburger button plus a Drawer for the small-screen menu. The Drawer hosts the same list of routes as a vertical stack of links, which mirrors the desktop information architecture without requiring a separate route tree or a duplicate constants file.