Skip to content
Drivn
8 min read

Drivn vs shadcn/ui — Popover Compared

Compare Drivn Popover vs shadcn/ui — a React context + click-outside primitive versus a Radix Popover wrapper. Both copy-paste, one ships zero runtime deps.

Both Drivn's Popover and shadcn/ui's Popover give you the same thing on screen: a button that opens a floating panel anchored to one of four sides. The difference is what sits underneath. shadcn/ui re-exports PopoverTrigger, PopoverContent, and PopoverAnchor from @radix-ui/react-popover, so the behaviour you get is Radix's behaviour and the code you own is a styling layer. Drivn ships the primitive itself — ninety lines at @/components/ui/popover.tsx built from one React.useState, a React.createContext, and a mousedown listener on document. Its only imports are react, the local cn() helper, and the Drivn Button.

That choice buys and costs specific things, and both are worth naming up front. You get a dependency tree with no Radix in it and a file you can open and edit without learning someone else's API surface. You give up the portal — Drivn's panel renders inline, so the nearest overflow: hidden ancestor clips it — along with the focus trap, the Escape-key dismiss, and collision-aware flipping.

For share menus, info panels, and confirmation popovers that trade lands fine. For date pickers, command palettes, and anything a keyboard user tabs through, it does not. The sections below trace the split through the dependency graph, the state machinery, the positioning math, the accessibility contract, and the call site.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveReact.useState + click-outside listener@radix-ui/react-popover
Runtime UI dependenciesDrivn Button + cn() utility@radix-ui/react-popover + cn() utility
Renders through a portalYes — Radix PopoverPortal
Positioning modelCSS absolute + 4 fixed sides (top/bottom/left/right)@floating-ui via Radix — collision-aware
Click-outside handlingdocument mousedown listenerRadix internal — pointerdown outside
Focus trap inside contentYes — Radix focus scope
Escape key dismisses
"use client" directive
Dot-notation APIPopover.Trigger, Popover.ContentPopoverTrigger, PopoverContent, PopoverAnchor
Total component lines~90~25 (plus Radix package)
LicenseMITMIT
Copy-paste install

The runtime footprint

shadcn/ui's Popover is a thin re-export layer over @radix-ui/react-popover. The component file is roughly twenty-five lines: it imports Popover, PopoverTrigger, PopoverContent, and PopoverAnchor from the Radix package, wraps PopoverContent to set default sideOffset and the project's bg-popover text-popover-foreground className, and re-exports the four pieces. The Radix package itself pulls in @floating-ui/react-dom for collision-aware positioning, @radix-ui/react-portal for the portal escape hatch, @radix-ui/react-focus-scope for the focus trap, and several smaller utilities — a real dependency graph with code-split chunks that load on the route that uses Popover.

Drivn's Popover ships ninety lines that import nothing past react, the cn() utility, and the local Button component. The state machine is a single React.useState<boolean> for the open flag. The click-outside handler is a React.useEffect that attaches mousedown to document and closes the panel when the event target is not contained by the component's root ref. There is no @floating-ui, no portal, no focus scope, no Radix bundle. The Drivn registry source for the styles object is reproduced below verbatim — what the registry ships is what installs into the project. The same dependency-shape trade-off appears in Drivn vs shadcn Dialog — Drivn keeps the primitive local, shadcn delegates to Radix.

1// Drivn — styles object, no Radix (verbatim from registry)
2const styles = {
3 base: 'relative inline-flex',
4 content: cn(
5 'absolute z-50 min-w-[200px] p-4',
6 'bg-card border border-border rounded-[12px]',
7 'shadow-lg',
8 'transition-[opacity,scale] duration-150 ease-out'
9 ),
10 positions: {
11 top: 'bottom-full mb-2 left-1/2 -translate-x-1/2',
12 bottom: 'top-full mt-2 left-1/2 -translate-x-1/2',
13 left: 'right-full mr-2 top-1/2 -translate-y-1/2',
14 right: 'left-full ml-2 top-1/2 -translate-y-1/2',
15 },
16}

How the open state is owned

Drivn owns the open state inside the root PopoverRoot component through a React.useState<boolean> initialized to false. The state is shared with the trigger and the content through a React.createContext value that exposes open, setOpen, and the position prop. The trigger calls setOpen(!open) on click, the content reads open and toggles its className between opacity-100 scale-100 and opacity-0 scale-95 pointer-events-none, and a mousedown listener on document calls setOpen(false) when the click lands outside the ref. The whole state cycle is one boolean and three reads of it.

shadcn/ui delegates the same problem to Radix. The Popover root accepts an open and onOpenChange prop for controlled mode and falls back to internal state in uncontrolled mode. Radix manages the open flag plus a small state machine for the open and closed transitions, runs Floating UI to compute the panel position and to flip the side when the panel collides with the viewport, traps focus inside the content while open, and restores focus to the trigger on close. The trade-off is feature density: Radix handles edge cases that Drivn does not (collision flipping, focus trap, escape-key dismiss), at the cost of an extra dependency and an API surface that is harder to edit locally because the source lives inside node_modules.

1// Drivn — context + useState (verbatim from registry)
2function PopoverRoot({
3 children,
4 position = 'bottom',
5 className,
6}: {
7 children: React.ReactNode
8 position?: keyof typeof styles.positions
9 className?: string
10}) {
11 const [open, setOpen] = React.useState(false)
12 const ref = React.useRef<HTMLDivElement>(null)
13
14 const close = React.useCallback(() => setOpen(false), [])
15
16 React.useEffect(() => {
17 const onClick = (e: MouseEvent) => {
18 if (!ref.current?.contains(e.target as Node)) close()
19 }
20 document.addEventListener('mousedown', onClick)
21 return () => document.removeEventListener('mousedown', onClick)
22 }, [close])
23
24 return (
25 <Ctx.Provider value={{ open, setOpen, position }}>
26 <div ref={ref} className={cn(styles.base, className)}>
27 {children}
28 </div>
29 </Ctx.Provider>
30 )
31}

Positioning model and clipping behaviour

Drivn positions the panel with CSS and nothing else. The root carries relative inline-flex, the content carries absolute z-50, and the position prop picks one of four entries in styles.positions — top is bottom-full mb-2 left-1/2 -translate-x-1/2, bottom is top-full mt-2 left-1/2 -translate-x-1/2, and the left and right placements mirror the same math along the other axis. No measurement runs at any point. If the panel would overflow the right edge of the viewport, it overflows the right edge of the viewport, and it stays on the side you asked for.

shadcn/ui gets Floating UI through Radix, which measures the panel against the viewport while open, flips a bottom placement to top when the panel would run off the bottom, and shifts along the cross axis to keep the panel in view. That matters for triggers sitting near a viewport edge and for panels whose content grows after the open transition — two cases where a fixed side produces a panel the user cannot fully read.

The second consequence of rendering inline is clipping. Drivn's panel is an ordinary child of the trigger's container, so it obeys the closest overflow: hidden ancestor — put a Popover inside a card with overflow-hidden and the panel cuts off at the card edge. Two fixes work: move the popover outside the clipping ancestor, or drop the clip on that ancestor for as long as the popover is open. Drivn Dropdown and Tooltip render the same way and inherit the same constraint.

Accessibility — what Drivn does not do

This is where the trade-off is sharpest, and it is worth stating plainly. Drivn's Popover does not trap focus inside the content while open, does not return focus to the trigger on close, does not dismiss on the Escape key, and does not wire aria-expanded or aria-controls between the trigger and the content. Radix-backed Popover does all four. For a popover that hosts a form, a date picker, or anything keyboard-interactive, the shadcn flavor is the right choice because the focus contract is part of the WAI-ARIA Authoring Practices for menu and dialog patterns.

For a popover that hosts a brief info panel, a share menu, or any content the user clicks rather than tabs through, Drivn's lighter handling is acceptable and easy to extend. Adding Escape-key dismiss is a four-line addition inside the existing useEffect — listen for keydown, check e.key === 'Escape', call close(). Adding aria-expanded is a one-line spread on the Trigger button. Adding a focus trap is a larger lift and is the point where most projects switch to the Radix flavor instead. Adding the four-line keyboard patch is the right minimum upgrade for any form-hosting case; the docs walk through it on the Popover examples page.

Call-site shape and dot notation

Drivn uses dot notation. The full markup reads <Popover position="bottom"><Popover.Trigger>Open</Popover.Trigger><Popover.Content>…</Popover.Content></Popover>. One import — import { Popover } from "@/components/ui/popover" — covers the root and both sub-components, which are attached to the root through Object.assign(PopoverRoot, { Trigger, Content }). The TypeScript signatures pass through React.ButtonHTMLAttributes<HTMLButtonElement> on the trigger so every native button prop comes through with autocomplete.

shadcn/ui uses flat named exports. The same markup reads <Popover><PopoverTrigger asChild><Button variant="outline">Open</Button></PopoverTrigger><PopoverContent>…</PopoverContent></Popover>, and the import line lists every sub-component: import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover". The asChild prop on PopoverTrigger is a Radix pattern that passes the trigger props onto the immediate child element rather than rendering a wrapper button, which is useful when the project wants the trigger to be a Button, a Link, or a custom element. Drivn does not ship asChild because the Popover.Trigger is already implemented as a thin wrapper over the Drivn Button component — the same dependency-light philosophy seen on Drivn vs shadcn Dropdown.

1// Drivn — one import, dot notation
2import { Popover } from "@/components/ui/popover"
3
4export default function ShareMenu() {
5 return (
6 <Popover position="bottom">
7 <Popover.Trigger>Share</Popover.Trigger>
8 <Popover.Content>
9 <p className="font-semibold mb-1">Share this page</p>
10 <p className="text-sm text-muted-foreground">
11 Copy the URL or post to X.
12 </p>
13 </Popover.Content>
14 </Popover>
15 )
16}

When each wins

Pick shadcn/ui's Popover when the project already standardizes on Radix elsewhere (Dialog, Tooltip, NavigationMenu) and the popover content needs collision-aware positioning, focus trapping, or escape-key dismiss out of the box. Form popovers, date pickers, color pickers, and command palettes all live in this column because the keyboard contract matters and the Radix focus scope handles it without per-popover wiring. The cost is one Radix package and its transitive dependencies in the bundle, which most projects already pay for through another component.

Pick Drivn's Popover when the priority is keeping the dependency tree small and the call site editable. Share menus, info popovers, brief help tips, and confirmation panels all live in this column because the focus contract is light and the inline render fits the layout. The styles object lives in the component file, the open-state mechanism is one useState, and edits stay scoped to ninety lines. Pair the Popover with the Drivn Button for the trigger styling and the Drivn Dropdown when the panel content is a list of selectable items. See the Popover examples page for the call-site shapes that match each placement.

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

No. The content panel renders inline next to the trigger inside the <div className="relative inline-flex"> root, and positions itself with absolute z-50 plus one of four fixed CSS placement classes. That means the panel is subject to the closest overflow: hidden ancestor — if the popover lives inside a card with clipped overflow, the panel clips at the card boundary. The fix is either to move the popover outside the clipping ancestor or to switch the ancestor's overflow rule for the duration the popover is open. shadcn/ui's Popover uses a Radix portal, so it escapes any clipping ancestor automatically.

A React.useEffect attached at mount adds a mousedown listener to document. When the listener fires, it checks whether the event target is contained inside the component's root ref, and if not, it calls setOpen(false). The listener is cleaned up on unmount via the effect return function. The pattern is small enough to read in five lines of TypeScript and lives directly in the component file, which makes it easy to swap for pointerdown or to add additional close conditions (escape key, blur on a specific child) by editing the same effect.

Yes — but the swap is a manual edit rather than an asChild prop. The Popover.Trigger ships as a wrapper over the Drivn Button component, so to use a different element, edit the Trigger function inside @/components/ui/popover.tsx and replace the <Button> JSX with the element you want. Reading open and setOpen from the usePopover() hook still works because the context lives at the component file level. Common swaps are a plain <button> with custom styling, a Drivn Avatar for a profile menu trigger, or an icon-only button for a brief info popover.

No — Escape-key dismiss is not in the default registry source. The component ships with click-outside and trigger-click toggling as the only dismiss paths. Adding Escape support is a small addition: inside the existing useEffect, add const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } plus a matching addEventListener and cleanup. The four-line patch keeps the file small and ships the missing keyboard affordance. The shadcn/ui flavor wires Escape automatically through Radix, which is why the focus-heavy popovers usually live on the shadcn flavor instead.

Drivn keeps the public API surface narrow and avoids the Radix asChild slot pattern because the pattern requires passing refs and event handlers onto an arbitrary child, which adds API complexity for the small set of cases that need it. Wrapping the Drivn Button as the default trigger covers the common case (an outline-button-styled trigger) with one import. When the call site needs a different element, the swap happens at the component file level rather than at the call site, which keeps each call site short and reserves the customization moment for where it is actually needed.