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.
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.
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-surfaceat 72% over a10pxblur — 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-pxhairline in--silver-500,--radius-mdcorners. 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-basewith--ease-out-soft. A quick rise + fade — snappy, friendly, done. Reduced motion removes the entrance entirely. - plane
- Body portal,
--shadow-lgelevation. 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
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><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.
Related
- /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).