Popover
A floating panel surfaced on click of a Trigger. Composed as a Radix-style compound — Root, Trigger, Content. The Content positions itself on the side of the Trigger that Root specifies, with an 8 px gap and a directional slide-in. Dismisses on Escape, outside click, or a second click of the Trigger.
Preview
Installation
npx shadcn@latest add https://craftbits.dev/r/popover.jsonUsage
Popover is a compound — Root owns the open-state machine, the stable ARIA ids, and the side prop; Trigger is the clickable element; Content is the floating panel that mounts only while open.
import { Popover } from "@craft-bits/core";
<Popover.Root side="bottom">
<Popover.Trigger>Share</Popover.Trigger>
<Popover.Content>
<p>Floating content goes here.</p>
</Popover.Content>
</Popover.Root>Take control of the open state with open + onOpenChange:
"use client";
import { useState } from "react";
import { Popover } from "@craft-bits/core";
const [open, setOpen] = useState(false);
<Popover.Root open={open} onOpenChange={setOpen}>
{/* ... */}
</Popover.Root>Understanding the component
- Compound parts. Root owns the open state, stable ids, and the
sideprop; Trigger renders a real<button type="button">so keyboard users get Enter / Space activation for free; Content mounts only while open and floats relative to the Trigger via absolute positioning. The compound mirrors Radix conventions so the API feels familiar. - Click-to-toggle. The Trigger's
onClicktoggles the open state — first click opens, second click closes. Outside clicks dismiss too; the Content's outside-click listener checks whether the click landed on the Trigger so the two handlers don't fight over the toggle. - Side-aware positioning.
sideaccepts"top","right","bottom", or"left"(default"bottom"). Content is absolute-positioned inside the Root container with a translate + offset tuned to clear the Trigger and leave an 8 px gap. The Content also slides in from the Trigger — bottom-side slides down from above, top-side slides up from below, etc. - Controlled and uncontrolled. Pass
openfor fully controlled state, ordefaultOpenfor uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition forvalue/defaultValuepairs.onOpenChangefires in either mode. - Dismissal. Escape closes from anywhere on the page. Outside-click (any pointerdown outside the Content and outside the Trigger) closes too. Both listeners bind only while the popover is open so they don't fire for every popover in the tree.
- ARIA correctness. The Trigger carries
aria-expanded,aria-controls, andaria-haspopup="dialog"; the Content carriesrole="dialog"andaria-labelledbyback at the Trigger. Assistive tech announces "expanded" / "collapsed" and reads the surfaced content on focus. - Animated reveal. The Content uses Framer Motion
AnimatePresenceso it unmounts cleanly after its exit transition. Opacity + scale + a 4 px directional slide run throughSPRINGS.smoothso timing matches the rest of the library.
Props
Popover.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Initial open state — uncontrolled mode only. |
onOpenChange | (open: boolean) => void | — | Fires whenever the open state flips, in either mode. |
side | "top" | "right" | "bottom" | "left" | "bottom" | Which side of the Trigger the Content appears on. |
className | string | — | Merged onto the rendered <div> container. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Popover.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <button>. |
onClick | event handler | — | Runs before the toggle; call event.preventDefault() to suppress. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
Popover.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered floating <div>. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- The Trigger is a real
<button type="button">so keyboard users get Enter / Space activation for free and screen readers announce it as "button". aria-expandedtracks the open state on the Trigger;aria-controlspoints at the Content's id;aria-haspopup="dialog"announces the kind of surface that opens.- The Content carries
role="dialog"andaria-labelledbyback at the Trigger so the dialog announces with the trigger's text on focus. - Escape closes from anywhere on the page — a hard requirement for any modal-ish surface.
- Focus ring uses
--cb-accentand an inset offset so keyboard focus is visible even on a borderless trigger. - Motion is short and uses opacity + scale + a small directional slide, all compositor-friendly. Users with
prefers-reduced-motionstill get the open / closed states — only the spring is muted, never the affordance.
Credits
- Extracted from:
algoflashcards(src/platform/ui/popover.tsx). The original was a thin wrapper overradix-ui's Popover primitive. The craft-bits version drops the runtime dependency on radix-popover and rebuilds the open-state machine, side-aware positioning, and dismissal listeners in plain React so the component ships without an extra peer.