Skip to content
Drivn
8 min read

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.

Drivn's NavigationMenu and shadcn/ui's NavigationMenu both render a horizontal <nav> with a list of triggers, each opening a panel of links. The shape on screen — a top bar with hover-revealed dropdowns — matches between the two libraries, but the runtime story behind that shape is very different. One ships as a forty-line .tsx file with no client-side state; the other wraps Radix UI's @radix-ui/react-navigation-menu primitive and brings a focus controller, a viewport positioner, and an indicator element along with it.

Drivn's NavigationMenu opens its Content panel through pure CSS — a group-hover/item and group-focus-within/item selector pair flips opacity and visibility on the absolutely positioned content. There is no JavaScript state machine, no useState, no event listener, no portal. The component file uses "use client" only because the Trigger runs an onPointerDown handler to dismiss focus on a second tap. Render the whole tree on the server, hydrate once, and the open/close behavior runs as native browser CSS state transitions thereafter.

shadcn/ui's NavigationMenu wraps Radix's primitive, which adds a runtime state controller for the active item, a Viewport element that mounts the active panel into a positioned container, and an Indicator element that slides under the active trigger. The Radix runtime tracks data-state="open" and data-state="closed" on each item, handles keyboard arrow navigation, and animates the viewport size between panels. This page walks through every difference — the runtime footprint, the open/close mechanism, the focus model, the viewport positioning, and the call-site shape when you build a typical product header.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveNative <nav> + CSS group-hover@radix-ui/react-navigation-menu wrapper
Runtime UI dependencieslucide-react (ChevronDown only)@radix-ui/react-navigation-menu + lucide-react
Open/close mechanismCSS group-hover + group-focus-withinJS state controller (data-state="open")
Keyboard arrow navigationNative tab orderArrow keys move between triggers
Viewport positionerNavigationMenu.Viewport element
Active indicator elementNavigationMenu.Indicator slides under trigger
"use client" directive
Open transitionopacity + visibility, 150msdata-[motion] keyframe animations
Trigger icon rotationgroup-hover/item:rotate-180data-[state=open]:rotate-180
Dot-notation APINavigationMenu.List, .Item, .Trigger, .Content, .LinkNavigationMenuList, NavigationMenuItem, etc.
Total component lines~95~130 wrapper + Radix runtime
LicenseMITMIT
Copy-paste install

The runtime footprint

Drivn's NavigationMenu ships one runtime UI dependency — lucide-react for the ChevronDown icon on the Trigger. The component file imports React and the cn utility, defines a styles object with class strings for root, list, item, trigger, triggerIcon, content, and link, and exports five sub-components attached via Object.assign. There is no Radix primitive under the hood, no focus library, no animation runtime — just CSS classes and a single onPointerDown handler on the Trigger to dismiss focus on a second tap. After npx drivn add navigation-menu your dependency graph picks up nothing new beyond the icon library you probably already use.

shadcn/ui's NavigationMenu wraps @radix-ui/react-navigation-menu, which adds a state controller, a focus manager, and a viewport positioner to your runtime. The Radix package is roughly fifteen kilobytes minified, and it pulls in shared Radix utilities like @radix-ui/react-context, @radix-ui/react-primitive, and @radix-ui/react-collection. For a navigation menu, the Radix dependency reads as overhead if the menu does not need keyboard arrow navigation across triggers or a sliding indicator under the active item. The same trade reappears in Drivn vs shadcn Dropdown Menu and Drivn vs shadcn Button, where Radix carries the state and Drivn keeps the markup native.

1// Drivn — group-hover CSS, no state controller (verbatim from registry)
2const 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'
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}

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.

Focus model and keyboard navigation

Drivn's focus model is native tab order plus the group-focus-within/item selector. Tab into a Trigger button, the panel opens because focus is inside the item; tab again, focus moves to the first focusable element inside Content (the first Link); tab through the links and out the bottom of the panel, focus moves to the next item's trigger and the previous panel closes because focus left the item. The keyboard path is the same as visiting links in any HTML page — there is no arrow-key handler, no aria-orientation, no roving tabindex. For most product headers this is the correct model because the user is navigating to a page, not selecting from a menu of commands.

shadcn/ui's focus model is Radix's arrow-key menu pattern. Tab into the list, then press Left/Right arrows to move between triggers without closing the open panel. Press Down to descend into the open panel, Up to leave. The pattern matches the WAI-ARIA Authoring Practices "menubar" pattern, which is appropriate for application command menus where the user is choosing an action rather than navigating. Pick shadcn when the menu is part of an application toolbar and the keyboard discoverability matters; pick Drivn when the menu is a marketing-site or product-site header and the native tab order is what users already know. The pattern lines up with the Drivn vs shadcn Dropdown split when the menu is a single-trigger surface rather than a multi-panel header.

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 forty lines of TypeScript 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.

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

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.