Skip to content
Drivn
7 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.

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

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~150~130 wrapper + Radix runtime
LicenseMITMIT
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)
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 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.

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

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.