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.json

Usage

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

  1. Compound parts. Root owns the open state, stable ids, and the side prop; 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.
  2. Click-to-toggle. The Trigger's onClick toggles 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.
  3. Side-aware positioning. side accepts "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.
  4. Controlled and uncontrolled. Pass open for fully controlled state, or defaultOpen for uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition for value / defaultValue pairs. onOpenChange fires in either mode.
  5. 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.
  6. ARIA correctness. The Trigger carries aria-expanded, aria-controls, and aria-haspopup="dialog"; the Content carries role="dialog" and aria-labelledby back at the Trigger. Assistive tech announces "expanded" / "collapsed" and reads the surfaced content on focus.
  7. Animated reveal. The Content uses Framer Motion AnimatePresence so it unmounts cleanly after its exit transition. Opacity + scale + a 4 px directional slide run through SPRINGS.smooth so timing matches the rest of the library.

Props

Popover.Root

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state — uncontrolled mode only.
onOpenChange(open: boolean) => voidFires whenever the open state flips, in either mode.
side"top" | "right" | "bottom" | "left""bottom"Which side of the Trigger the Content appears on.
classNamestringMerged onto the rendered <div> container.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Popover.Trigger

PropTypeDefaultDescription
classNamestringMerged onto the rendered <button>.
onClickevent handlerRuns before the toggle; call event.preventDefault() to suppress.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

Popover.Content

PropTypeDefaultDescription
classNamestringMerged onto the rendered floating <div>.
...restHTMLAttributes<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-expanded tracks the open state on the Trigger; aria-controls points at the Content's id; aria-haspopup="dialog" announces the kind of surface that opens.
  • The Content carries role="dialog" and aria-labelledby back 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-accent and 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-motion still 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 over radix-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.