Skip to content
Domphy

Popover

Apply the popover patch to any trigger element (typically a button). It attaches a floating content panel anchored to the trigger, positioned via @domphy/floating. The content is rendered into a fixed overlay layer and dismissed when the trigger loses focus (blur).

The patch wires accessibility automatically: aria-haspopup="dialog", aria-expanded, aria-controls, and focus/blur dismissal.

When openOn is "hover", the popover also opens on focus and closes on blur. When openOn is "click", focus has no effect.

The panel surface carries a "border-strong" outline plus a medium elevation() box-shadow, so it reads as a raised layer above the page.

Props

PropTypeDefaultDescription
openOn"click" | "hover""click"Interaction that opens the popover. Optional — defaults to "click" when omitted.
contentDomphyElementThe floating content element to display. Required.
openValueOrState<boolean>falseControlled open state.
placementValueOrState<Placement>"bottom"Floating placement (e.g. "top-start", "right").

Example

import { button, popover } from "@domphy/ui";

const content = {
  div: "Popover content",
};

const App = {
  button: "Open popover",
  $: [button(), popover({ openOn: "click", content })],
};
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import {
  type DomphyElement,
  type Listener,
  merge,
  type PartialElement,
  toState,
  type ValueOrState,
} from "@domphy/core";
import type { Placement } from "@domphy/floating";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { createFloating, floatingPanelId } from "../utils/floating.js";

/**
 * Floating popover primitive. Attaches to its host as the anchor/trigger and
 * shows a floating `content` element (with `role="dialog"`) on click or hover,
 * positioned via `@domphy/floating`. Returns the anchor partial, which merges
 * trigger wiring (haspopup/expanded, focus/blur dismissal). Apply to the
 * trigger element you want the popover anchored to.
 *
 * @param props - Configuration.
 * @param props.openOn - Interaction that opens the popover: `"click"` or `"hover"`. Defaults to `"click"`.
 * @param props.open - Open state, accepts a value or `State`. Defaults to `false`.
 * @param props.placement - Floating placement (e.g. `"bottom"`, `"top-start"`), value or `State`. Defaults to `"bottom"`.
 * @param props.content - The floating content element to display.
 * @example { button: "Open", $: [popover({ openOn: "click", content: { div: "Hi" } })] }
 */
function popover(props: {
  openOn?: "click" | "hover";
  open?: ValueOrState<boolean>;
  placement?: ValueOrState<Placement>;
  content: DomphyElement;
}): PartialElement {
  const { open = false, placement = "bottom", openOn = "click" } = props;

  const openState = toState(open);
  const placeState = toState(placement);

  const { show, hide, anchorPartial } = createFloating({
    kind: "popover",
    open: openState,
    placement: placeState,
    content: props.content,
    // Hovering the panel itself (not just the trigger) must keep it open —
    // handled generically inside floating.ts's behavior instance.
    keepOpenOnContentHover: openOn === "hover",
  });

  // The panel id is derived from the ANCHOR's nodeId and stamped by the
  // shared floating behavior when the panel mounts (see floating.ts) — no
  // factory-scope id variable, which a re-rendered generation would lose.
  const popoverPartial: PartialElement = {
    role: "dialog",
    dataTone: "shift-14",
    style: {
      backgroundColor: (l: Listener) => themeColor(l, "inherit"),
      borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 2),
      outline: (l: Listener) => `1px solid ${themeColor(l, "border-strong")}`,
      outlineOffset: "-1px",
      boxShadow: elevation("medium"),
    },
  };

  props.content.$ ||= [];
  props.content.$.push(popoverPartial);

  const triggerPartial: PartialElement = {
    ariaHaspopup: "dialog",
    ariaExpanded: (listener) => openState.get(listener),
    // Declared as a reactive attribute (listener.elementNode is the anchor) so
    // it is present from first render — before the panel's first show() — and
    // is re-declared on every patch: attributes set imperatively (the old
    // _onMount attributes.set) are stripped by patch() on ancestor re-render.
    ariaControls: (listener) =>
      listener?.elementNode
        ? floatingPanelId("popover", listener.elementNode)
        : undefined,
    onMouseEnter: (_e, node) => openOn === "hover" && show(node),
    onMouseLeave: (_e, node) => openOn === "hover" && hide(node),
    onClick: (_e, node) => {
      if (openOn === "click") {
        if (openState.get()) {
          hide(node);
        } else {
          show(node);
        }
      }
    },
    onKeyDown: (e, node) => {
      if ((e as KeyboardEvent).key === "Escape" && openState.get()) hide(node);
    },
    onFocus: (_e, node) => openOn === "hover" && show(node),
    onBlur: (e, node) => {
      const related = (e as FocusEvent).relatedTarget as Node | null;
      const root = node.getRoot().domElement as Element;
      // Tabbing from the trigger INTO the panel must not close the popover.
      // The id is deterministic and selector-safe ([a-z0-9-] only — nodeId is
      // a letter + hex hash), so this lookup works for every generation —
      // the old factory-scope `popoverId` was null in any generation whose
      // content _onInsert had never run, letting the guard fall through.
      const floatingEl = root.querySelector(
        `#${floatingPanelId("popover", node)}`,
      );
      if (related && floatingEl?.contains(related)) return;
      hide(node);
    },
  };
  merge(anchorPartial, triggerPartial);

  return anchorPartial;
}

export { popover };