Skip to content
Drivn
6 min read

Next.js Popover with Client Components

Add a popover to a Next.js App Router app. Drivn ships a client component with CSS positioning and click-outside close — install and render in minutes.

A popover is a small floating panel anchored to a button — a menu of secondary actions, a form field's help text, a share sheet. In a Next.js App Router project the interesting question is not what it looks like but where it runs: because a popover tracks open state and listens for outside clicks, it belongs in a client component, and knowing exactly where that boundary sits keeps the rest of your tree on the server. Drivn's Popover makes that boundary explicit and small.

After install the component sits in src/components/ui/popover.tsx as three functions joined with Object.assign — the root plus .Trigger and .Content. The root holds an open boolean in useState, a useRef on the wrapper, and a useEffect that closes the panel on an outside mousedown. That is why the file opens with a 'use client' directive: the outside-click listener needs the browser. The .Trigger is a Drivn Button that toggles the state, and the .Content reveals through an opacity-and-scale transition.

This guide installs Drivn in a Next.js 16 project, renders the popover inside a client boundary, positions the panel with the position prop, and restyles the one styles object it reads from. Every snippet comes from the component's real API. For the full reference see the Popover docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Popover.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add popover. The component imports the Drivn Button for its trigger and the cn utility for class merging, and it has no other dependencies, so the CLI writes a single popover.tsx file — adding button.tsx too if you have not installed it yet — and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a page.

1# from the root of your Next.js 16 project
2npx drivn add popover

Render inside a client boundary

Because the root holds open in useState and registers an outside-click listener in useEffect, popover.tsx opens with a 'use client' directive — it cannot render as a Server Component. In the App Router that means importing it into a component that is itself a client component, or rendering it inside a small client wrapper while the surrounding page stays on the server. The boundary is local: only the popover and its trigger ship to the browser, not the page around them. Compose the panel with the dot-notation API — Popover.Trigger for the button and Popover.Content for the floating panel — and pass whatever markup you want as the content children. Drop it into any page and it works immediately; reach for the Drivn Dialog instead when the action needs a full modal rather than an inline panel.

1'use client'
2import { Popover } from '@/components/ui/popover'
3
4export function HelpPopover() {
5 return (
6 <Popover>
7 <Popover.Trigger>Open Popover</Popover.Trigger>
8 <Popover.Content>
9 <p className="font-semibold">Popover Title</p>
10 <p>This is the popover content.</p>
11 </Popover.Content>
12 </Popover>
13 )
14}

Position the panel around the trigger

The panel's placement is a single position prop on the Popover root, and it accepts top, bottom, left, or right, defaulting to bottom. Internally each value maps to a class string in the styles.positions object — bottom renders top-full mt-2 left-1/2 -translate-x-1/2 so the panel sits centered below the trigger, while right renders left-full ml-2 top-1/2 -translate-y-1/2 to sit centered to its right. The Content merges the chosen position class on top of the base styles.content card, so switching sides is a one-word change at the call site with no layout math. Choose the side by where the trigger lives: a popover near the top of a page opens bottom, one near the bottom opens top, and a trigger in a right-hand rail opens left to stay inside the viewport. For a hover-only tip rather than a click panel, use the Drivn Tooltip instead.

1// position is a prop on the Popover root
2<Popover position="right">
3 <Popover.Trigger>Share</Popover.Trigger>
4 <Popover.Content>
5 <p className="font-semibold">Share this page</p>
6 </Popover.Content>
7</Popover>
8
9// styles.positions — verbatim from popover.tsx
10positions: {
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},

Close on outside click and animate the panel

The popover closes itself when you click anywhere outside it. The root keeps a useRef on its wrapper <div> and registers a mousedown listener in useEffect; when the event target is not contained by the wrapper, it calls setOpen(false). That listener is exactly why the file is a client component — it needs a live document. The reveal is a CSS transition rather than an animation library: Content is always in the DOM and toggles between opacity-0 scale-95 pointer-events-none when closed and opacity-100 scale-100 when open, over the transition-[opacity,scale] duration-150 ease-out declared in styles.content. Because the panel stays mounted, there is no enter or exit unmount to coordinate and no flash of open content on first paint. Keeping the state and the listener inside the one component means nothing leaks into the page around it.

1// outside-click close — verbatim from popover.tsx
2React.useEffect(() => {
3 const onClick = (e: MouseEvent) => {
4 if (!ref.current?.contains(e.target as Node)) close()
5 }
6 document.addEventListener('mousedown', onClick)
7 return () => document.removeEventListener('mousedown', onClick)
8}, [close])

Restyle the one styles object

Every class the popover renders lives in one styles object at the top of the file you own. styles.base is the relative inline-flex wrapper that anchors the panel; styles.content is the floating card — absolute z-50 min-w-[200px] p-4, bg-card border border-border rounded-[12px], shadow-lg, and the opacity-and-scale transition; and styles.positions holds the four placement strings. Widen the panel by bumping min-w-[200px], soften the corners through rounded-[12px], or deepen the elevation by swapping shadow-lg. Because the surface reads Tailwind tokens like bg-card and border-border, changing them in theming re-themes every popover for dark and light mode at once. The whole visual surface is one object, so a width, radius, or color change is a single edit. For copy-paste layouts and content patterns see the Popover examples.

1// styles.content — the floating card (verbatim from popover.tsx)
2content: cn(
3 'absolute z-50 min-w-[200px] p-4',
4 'bg-card border border-border rounded-[12px]',
5 'shadow-lg',
6 'transition-[opacity,scale] duration-150 ease-out'
7),
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 Popover source opens with a 'use client' directive because the root holds the open state in useState and registers an outside-click mousedown listener in useEffect, both of which need the browser. In the App Router, import it into a client component or wrap it in a small client boundary while the surrounding page stays on the server. The boundary is local — only the popover and its trigger ship to the client, not the page around them.

Pass the position prop to the Popover root. It accepts top, bottom, left, or right and defaults to bottom. Each value maps to a class string in the styles.positions object that offsets and centers the panel relative to the trigger — for example right renders left-full ml-2 top-1/2 -translate-y-1/2. Switching sides is a one-word change at the call site, so pick the side that keeps the panel inside the viewport for where the trigger sits.

The root keeps a useRef on its wrapper element and registers a mousedown listener in useEffect. When you click anywhere the wrapper does not contain, the handler calls setOpen(false) and the panel hides. Clicking the trigger again toggles it closed too, since the trigger runs setOpen(!open). There is no timer and no overlay element — the close is simply the absence of a click inside the popover, tracked by the outside-click listener.

Yes. The component has no dependency on Next.js or any router — the trigger is a Drivn Button and the panel is a plain <div>, so it works anywhere React and Tailwind reach the DOM. In a Vite + React app everything already runs on the client, so there is no boundary to think about; the 'use client' directive is simply ignored outside the App Router. Run npx drivn add popover and render it the same way.

Open popover.tsx after install and edit the styles.content string — bump min-w-[200px] for a wider panel, adjust rounded-[12px] and p-4 for a different shape, or swap shadow-lg for a different elevation. Because the panel reads its colors from Tailwind tokens like bg-card and border-border, changing those tokens in theming re-themes every popover for dark and light mode. The whole visual surface lives in one styles object, so a size or color change is a single edit.