Components

Popover

A trigger plus a little floating panel — the friendly way to tuck a detail behind a click. Non-modal, so it asks for a glance, not a commitment: click outside to dismiss, Escape brings focus home, no focus trap. It also covers the ADR-0015 duty — when a slot truncates something a user named, the popover brings the full text back, required, not optional. Beta: the click-disclosure mode ships; the hover + focus trigger mode ADR-0015 also calls for has not shipped yet.

Live

Click the trigger to open the panel. Click anywhere outside to dismiss, or press Escape — focus returns to the trigger. The panel content receives a close function, so content can dismiss itself.

Basic popover
Open it, then press Tab — focus moves through the panel content. Press Escape from anywhere to close and land back on the trigger.

Placement

The placement prop takes any Floating UI placement — a side (top, bottom, left, right) optionally suffixed with -start or -end. The preferred side is a preference, not a promise: the panel flips to the opposite side and shifts along its axis when the viewport would clip it.

placement: top / bottom / left / right
Resize the window or scroll the trigger near an edge to see flip and shift take over.

The ADR-0015 affordance

ADR-0015 is a proposed-system rule about engraved slots, but the duty it creates is universal: when a slot caps and truncates something a user named — a custom tag, a saved-view name — the full text has to stay one click away. This component is that click, because losing information without recourse is unacceptable. Per-slot caps and popover specifics are documented on each component’s page.

Named gap: ADR-0015’s truncation slots open on hover and keyboard focus and announce via aria-describedby. This component currently ships click-disclosure only — the hover/focus trigger mode is on the Beta gap list, and truncation slots should not adopt Popover for that duty until it lands.

Opening and dismissing

  • Click toggles. The trigger opens and closes the panel; aria-expanded tracks it.
  • Light dismiss. Pointer-down outside the trigger and panel closes it — no scrim, no click shield. The page stays live underneath.
  • Escape closes and returns focus. Keyboard users land back on the trigger they came from, never dropped at the document root.
  • No focus trap. Focus moves into the panel on open so Tab reaches its content, but Tab is free to leave; leaving does not close it. A surface that must hold focus until resolved is a Modal.

Materiality

The spec values below document the proposed system’s glass register. The official panel plays it straight: flat and fully opaque on the surface color, framed in a warm hairline, with a soft shadow doing the lifting. Overlays cover what they cover — nothing peeks through.

surface
--bg-surface at 72% over a 10px blur — the chrome-popover glass. Fully opaque — what the panel covers stays covered. The translucent blur described in the value column is the proposed system’s read; officially the panel is solid surface color.
frame
--space-px hairline in --silver-500, --radius-md corners. Officially the frame is the warm hairline every container wears — gold is for actions and focus, never a container frame.
motion
A rise + fade over --dur-base with --ease-out-soft. A quick rise + fade — snappy, friendly, done. Reduced motion removes the entrance entirely.
plane
Body portal, --shadow-lg elevation. The overlay plane: above the content well, below toasts. Position is owned by Floating UI; the stylesheet owns surface, size, and motion.

Accessibility

  • Trigger wiring. The render prop hands the trigger aria-haspopup="dialog", aria-expanded, aria-controls (while open), the click handler, and the ref — spread it and the contract is complete.
  • Labelled dialog. The panel is role="dialog" with a required aria-label — a nameless popover is an unnavigable landmark for screen readers.
  • Focus path. The panel takes focus on open (it portals to the body, so without this, Tab would traverse the rest of the page first); Escape returns focus to the trigger.
  • Keyboard parity. Everything the mouse can do — open, dismiss, reach the content — the keyboard can do. Enter or Space on the trigger toggles; Escape dismisses from anywhere inside.
  • Reduced motion. The entry rise honors prefers-reduced-motion and the explicit in-app motion preference — the panel simply appears.

Props

trigger: (props, { open }) => ReactNode
Render prop for the trigger affordance. Spread the provided props onto a Button (or any focusable button-like element); open is provided for pressed/active styling. Required.
label: string
Accessible name for the panel dialog. Required — there is no nameless mode.
placement?: Placement = 'bottom'
Preferred Floating UI placement. Flips and shifts automatically near viewport edges.
children: ReactNode | ({ close }) => ReactNode
Panel content. The function form receives close, for content that dismisses the popover itself.
className?: string
Extra class on the panel — e.g. to override the default width cap.
onOpenChange?: (open: boolean) => void
Observer for open/close transitions — analytics, lazy content. Not a controlled mode.

Usage

Basic — trigger render prop + contenttsx
import { Button, Popover } from '@halo-compliance/ui'

<Popover
  label="Full tag name"
  placement="bottom"
  trigger={(props) => (
    <Button {...props} variant="ghost" tone="secondary">View full name</Button>
  )}
>
  <Text variant="body-sm" as="p">
    Unfair, Deceptive, or Abusive Acts or Practices
  </Text>
</Popover>
Self-dismissing content — the close functiontsx
<Popover
  label="Quick filters"
  trigger={(props, { open }) => (
    <Button {...props} variant="ghost" tone="secondary" aria-pressed={open}>
      Filters
    </Button>
  )}
>
  {({ close }) => (
    <>
      <FilterControls />
      <Button variant="ghost" tone="secondary" size="sm" onClick={close}>
        Done
      </Button>
    </>
  )}
</Popover>

When to use

  • Restoring truncated user-authored content — the ADR-0015 case: a capped tag, saved-view name, or custom label whose full text must stay reachable.
  • A glanceable detail card — a definition, a quick summary, the metadata behind a label — anything you read right there and move on from.
  • Light, optional controls anchored to a trigger — a small filter or option set that applies immediately and never demands completion.

Antipatterns

  • A workflow in a popover. Anything with required fields, validation, or a submit belongs in a Modal — a panel you can dismiss with a stray click is no place to fill out a form.
  • A hover tooltip stand-in. This popover is click-disclosure. The hover + focus mode ADR-0015 needs for truncation slots is a named gap — do not fake it with mouse events around this component.
  • Nesting popovers. A popover that opens another popover is a menu system wearing a costume. One level; if the content branches, it wants a page or a modal.
  • A nameless panel. The label prop is required for a reason — never pass a decorative or empty string to satisfy the type.
  • /modal — the modal sibling: holds focus, dims the page, for work that has to finish.
  • /selector — a popover-shaped control with list semantics; pick it when the content is options, not prose.
  • /button — the canonical trigger to spread the render-prop wiring onto.
  • /engraving — the engraving rule whose truncation affordance this component exists to serve (ADR-0015).