Skip to content
Drivn
5 min read

How to Add a Popover to a React App

Step-by-step guide to adding a popover to any React project with the Drivn CLI — a copy-paste client component with click-outside close and zero runtime deps.

A popover is a small floating panel anchored to a button — a menu of secondary actions, a field's help text, a share sheet. Building one by hand means tracking an open flag, closing the panel when the user clicks elsewhere, and positioning it around the trigger without it drifting off screen. Most teams reach for a headless library and inherit its API surface for what is really a useState and a click listener.

Drivn takes the lighter route: the CLI copies a popover.tsx file straight into your repository. The Popover is three functions joined with Object.assign — the root plus .Trigger and .Content. The root holds an open boolean in useState, keeps a useRef on its wrapper, and registers a mousedown listener in useEffect that closes the panel on an outside click. That listener is why the file opens with a 'use client' directive. The .Trigger is a Drivn Button that toggles the state, and the .Content reveals through an opacity-and-scale transition.

This guide adds the popover to any React app — install the CLI, render the panel with the dot-notation API, place it around the trigger, and restyle the one styles object it reads. It works the same in Vite + React or a Next.js project. For the App Router walkthrough see the Next.js Popover guide.

Prerequisites

Before installing Popover, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. The Popover imports the Drivn Button for its trigger and the cn utility for class merging — those are its only dependencies beyond React, so keep the Button installed or let the CLI add it for you during install.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Popover source file. The command prompts once for your install directory (defaulting to src/components/ui/), writes popover.tsx, and — because the trigger is built on the Drivn Button — also writes button.tsx if you have not installed it yet. No global config file is created; the result is TypeScript source you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install.

1# add the Popover source file
2npx drivn add popover

Step 2 — Render the popover

Import Popover from your UI directory and compose the panel with the dot-notation API. The root <Popover> provides the context and the outside-click wrapper, Popover.Trigger renders the button that toggles the panel open, and Popover.Content holds whatever markup you want to float — a title, a paragraph, a small list of links. One import covers all three because the sub-components are attached to the root with Object.assign. Because the root owns open in useState and registers a browser listener, the file carries a 'use client' directive; render it inside a client component, or in a Next.js Server Component wrap it in a small client boundary while the surrounding page stays on the server. Drop it into any page and it works immediately.

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}

Step 3 — Position the panel around the trigger

The panel's placement is a single position prop on the Popover root, accepting top, bottom, left, or right and defaulting to bottom. 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, and right renders left-full ml-2 top-1/2 -translate-y-1/2 to sit centered to its side. The Content merges the chosen position class on top of the base card, so switching sides is a one-word change with no layout math. Pick the side by where the trigger lives so the panel stays inside the viewport: a trigger near the top of the page opens bottom, one near the bottom opens top. 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},

Step 4 — Close on outside click and restyle

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, not an animation library: Content stays mounted 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 in styles.content. Everything visual lives in that one styles object — widen the panel by bumping min-w-[200px], soften the corners through rounded-[12px], or deepen the elevation with shadow-lg. Because the surface reads tokens like bg-card and border-border, changing them in theming re-themes every popover for dark and light mode at once. For copy-paste layouts and content patterns see the Popover examples.

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])
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add popover. The component has no dependency on Next.js or any router — its only imports beyond React and the cn utility are the Drivn Button for the trigger, so the CLI writes popover.tsx and adds button.tsx if needed. In a Vite + React app everything already runs on the client, so the 'use client' directive is simply ignored and the popover works the same way.

Because the root component holds the open flag in useState and registers a mousedown listener on document inside useEffect, both of which need the browser. Server Components cannot use state or effects, so the file must opt into the client with a top-of-file 'use client' directive. In a Next.js App Router page the boundary is local — only the popover and its trigger ship to the client, while the page around them stays a Server Component.

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, though the swap is a manual edit rather than an asChild prop. The Popover.Trigger ships as a wrapper over the Drivn Button, so to use a different element edit the Trigger function inside 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, so the new trigger keeps toggling the panel exactly as before.