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

Drivn's Popover and shadcn/ui's Popover both render the same affordance — a trigger button that opens a floating panel anchored to one of four sides — but they reach that result through very different machinery. shadcn/ui wraps @radix-ui/react-popover and re-exports PopoverTrigger, PopoverContent, and PopoverAnchor from the Radix primitive. Drivn ships a self-contained compound component built on React.useState, React.useContext, and a mousedown listener for click-outside. The Drivn file lives at @/components/ui/popover.tsx, runs about ninety lines of TypeScript, and imports nothing past react, the local cn() utility, and the Drivn Button component.

The practical consequence of dropping Radix is a thinner dependency tree and a Popover that the call site can edit directly without learning a Radix API surface. The cost is that Drivn's panel renders inline rather than through a portal, so the closest overflow: hidden ancestor will clip the panel if the content is wider than the container. For most popovers — menus, info tooltips, color pickers, share menus — the inline render is fine because the trigger usually sits inside a region with enough breathing room. For popovers that must escape modal stacks or virtualized scrollers, Radix's portal-based render in the shadcn flavor is the right tool.

This page walks through every difference at the implementation level: the runtime dependency graph, the state machinery, the click-outside handling, the positioning model, the focus and accessibility story, and the call-site shape. Read it before you pick which flavor to copy into a new project, because the trade-off is real and depends on whether the project already standardizes on Radix elsewhere.

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 content panel through plain CSS — the root carries relative inline-flex and the content carries absolute z-50 plus one of four fixed position classes. The top placement is bottom-full mb-2 left-1/2 -translate-x-1/2, the bottom placement is top-full mt-2 left-1/2 -translate-x-1/2, and the left and right placements mirror the same math along the X axis. No JavaScript runs to measure viewport collisions; if the panel would overflow the right edge of the viewport, the panel overflows the right edge of the viewport. The trade-off is intentional — zero positioning code means zero positioning bugs, and the four hardcoded sides cover the call sites that ship in practice.

shadcn/ui inherits Floating UI through Radix, which measures the panel and the viewport on every frame while open, flips the side when the panel collides (a bottom placement at the bottom of the viewport flips to top), and shifts the panel along the cross axis to keep it inside the viewport. The behaviour is the right call for popovers that open from a trigger near the viewport edge or for popovers whose content grows after the open transition. Drivn's inline placement also means the panel obeys the closest overflow: hidden ancestor — drop a Drivn Popover inside a card with overflow-hidden and the panel clips at the card boundary. The fix is either to move the popover outside the clipping ancestor or to switch the ancestor to overflow-visible for the duration the popover is open. The same constraint applies to Drivn Dropdown and Tooltip.

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.