hono-preact

Popover

A non-modal, anchored overlay for interactive content: menus of actions, small forms, or detail panels. Positioning runs on Floating UI; the overlay renders in place and promotes to the browser top layer using the Popover API. It ships unstyled: style it through the data-state, data-side, and data-align contract.

Demo

Usage

import { Popover } from '@hono-preact/ui';

export function Settings() {
  return (
    <Popover.Root>
      <Popover.Trigger>Open popover</Popover.Trigger>
      <Popover.Positioner>
        <Popover.Popup>
          <Popover.Arrow />
          <Popover.Title>Settings</Popover.Title>
          <Popover.Description>Adjust your preferences.</Popover.Description>
          <Popover.Close>Done</Popover.Close>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Root>
  );
}

Styling

Parts expose data-state="open" | "closed"; the Positioner and Arrow also expose data-side and data-align. The Positioner is the fixed-positioned wrapper, so size, z-index, and entry animation go on the Popup inside it. The demo above uses the styles below; copy a starting point in either flavor:

.docs-popover-positioner {
  z-index: 50;
}
.docs-popover {
  box-sizing: border-box;
  width: max(16rem, 12rem);
  padding: 1rem;
  border: 1px solid #e4e4e7;
  border-radius: 0.625rem;
  background: #fff;
  color: #18181b;
  box-shadow:
    0 10px 15px -3px rgb(0 0 0 / 0.25),
    0 4px 6px -4px rgb(0 0 0 / 0.25);
  opacity: 1;
  transform: translateY(0);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}
/* Compact popup typography so the title/description do not inherit page heading
   and paragraph margins. */
.docs-popover h2 {
  font-size: 0.95rem;
  font-weight: 600;
  margin: 0 0 0.25rem;
}
.docs-popover p {
  margin: 0 0 0.75rem;
  font-size: 0.875rem;
  color: #71717a;
}
@starting-style {
  .docs-popover[data-state='open'] {
    opacity: 0;
    transform: translateY(-4px);
  }
}
.docs-popover[data-state='closed'] {
  animation: docs-popover-out 120ms ease-in forwards;
}
@keyframes docs-popover-out {
  to {
    opacity: 0;
    transform: translateY(-4px);
  }
}
/* The arrow is a rotated square; the component sets position:absolute and the
   cross-axis offset, the per-side rules place it on the edge facing the anchor.
   The Positioner already clears the UA top-layer styles, so nothing else is
   needed for the shadow to show. */
.docs-popover__arrow {
  width: 9px;
  height: 9px;
  rotate: 45deg;
  background: #fff;
  border: 1px solid #e4e4e7;
}
.docs-popover__arrow[data-side='bottom'] {
  top: -5px;
  border-right: 0;
  border-bottom: 0;
}
.docs-popover__arrow[data-side='top'] {
  bottom: -5px;
  border-left: 0;
  border-top: 0;
}
.docs-popover__arrow[data-side='right'] {
  left: -5px;
  border-top: 0;
  border-right: 0;
}
.docs-popover__arrow[data-side='left'] {
  right: -5px;
  border-bottom: 0;
  border-left: 0;
}
@media (prefers-color-scheme: dark) {
  .docs-popover {
    border-color: #27272a;
    background: #18181b;
    color: #fafafa;
  }
  .docs-popover p {
    color: #a1a1aa;
  }
  .docs-popover__arrow {
    background: #18181b;
    border-color: #27272a;
  }
}
@media (prefers-reduced-motion: reduce) {
  .docs-popover {
    transition: none;
  }
  .docs-popover[data-state='closed'] {
    animation: none;
  }
}

API reference

Every part accepts a render prop for composition (see useRender) and forwards unknown props to the element it renders. The Trigger, Positioner, Popup, Arrow, and Close also expose data-state="open" | "closed" and pass state to a render function.

Popover.Root

Provides open state, ids, refs, and placement config to the parts. Renders only its children.

PropTypeDefaultDescription
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the popover requests an open or close.
side'top'|'right'|'bottom'|'left''bottom'Preferred side of the anchor to place the popup.
align'start'|'center'|'end''center'Alignment along that side.
offsetnumber8Gap in pixels between the anchor and the popup.
childrenComponentChildren-The trigger, optional anchor, and positioner.

Popover.Trigger

Toggles the popover on click and anchors it (unless a Popover.Anchor is present). Default element <button type="button">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Trigger label.
...propsJSX.HTMLAttributes<HTMLButtonElement>-Forwarded to the element; a passed onClick runs before the popover toggles.

Sets aria-haspopup="dialog", aria-expanded, id, data-state, and aria-controls (only while open, since the popup is mounted on open).

Popover.Anchor

Optional. Positions the popover relative to this element instead of the trigger, for example to anchor a popover to a region while a separate button opens it. Default element <span>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-The anchored content.
...propsJSX.HTMLAttributes<HTMLSpanElement>-Forwarded to the element.

Popover.Positioner

The fixed-positioned wrapper that Floating UI drives. Renders nothing until the popover is open (mount on open). Default element <div>.

PropTypeDefaultDescription
renderRenderProp<{ side: Side; align: Align }>-Compose or replace the element.
childrenComponentChildren-The popup (and optional arrow).
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element. Style positioning via class.

Sets position: fixed and the resolved data-side / data-align. The element is promoted to the top layer via the Popover API, so it escapes ancestor clipping.

Popover.Popup

The surface and focus target. Default element <div> with role="dialog".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name when there is no Popover.Title.
childrenComponentChildren-Arrow, title, description, body, and controls.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets role="dialog", id, tabindex="-1", data-state, aria-labelledby (the Title) or aria-label, and aria-describedby (only when a Popover.Description is present). Registers the Escape / outside-press dismissal and the focus move-in / return.

Popover.Arrow

Optional pointer positioned from the Floating UI arrow data. Default element <div>. Place it on the correct edge with CSS keyed on data-side.

PropTypeDefaultDescription
renderRenderProp<{ side: Side }>-Compose or replace the element.
childrenComponentChildren-Optional arrow content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets data-side and position: absolute with the computed offset.

Popover.Title

The popover's accessible name, wired to the popup's aria-labelledby. Default element <h2>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-Title text.
...propsJSX.HTMLAttributes<HTMLHeadingElement>-Forwarded to the element.

Popover.Description

Optional supporting text, wired to the popup's aria-describedby while it is rendered. Default element <p>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-Description text.
...propsJSX.HTMLAttributes<HTMLParagraphElement>-Forwarded to the element.

Popover.Close

Closes the popover on click. Default element <button type="button">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Button label.
...propsJSX.HTMLAttributes<HTMLButtonElement>-Forwarded to the element; a passed onClick runs before the popover closes.

Primitives

@hono-preact/ui exports the building blocks the parts use, for composing your own components. Several have their own page with examples: usePosition, useDismiss, useFocusReturn, useRender, useControllableState, and mergeRefs.

Accessibility

The popover is a non-modal disclosure: the trigger carries aria-haspopup, aria-expanded, and (while open) aria-controls. Give the popup an accessible name with a Popover.Title (wired through aria-labelledby) or by passing aria-label to Popover.Popup; a Popover.Description is wired through aria-describedby when present.

  • Focus moves into the popup when it opens (the first focusable element, or the popup itself) and returns to the trigger when it closes.
  • Focus is not trapped: the popover is non-modal, so tabbing past the last control moves into the rest of the page, and the page stays interactive.
  • Escape closes the popover; an outside pointer press closes it; nested overlays close innermost-first.
  • The popup renders in the top layer via the Popover API, so an ancestor transform, filter, contain, or will-change does not clip it.