Docs Platform

Popover

A click-triggered, non-modal panel anchored to its own trigger. The trigger is the anchor, so there is no separate anchor prop or slot to wire up. The machine decides open or closed, the headless layer turns that into a plain dialog-shaped panel with no menu roles and no roving focus, and the styled layer adds tokens and classes. Popover does not trap focus and does not lock scroll, so opening one never grabs the page away from whatever the reader was doing.

Explore the layers

Machine (@grassroot/ui-machines/popover) is pure state: no DOM, no framework, no styling. Headless (@grassroot/ui-headless-<framework>/popover) adds a plain role="dialog" panel with accessible DOM/ARIA on top of the same machine, per framework. Styled (@grassroot/ui-<framework>) is Grassroot's own visual recipe built on the headless layer. Vanilla (mountPopover, from @grassroot/ui-headless-core/popover) is the framework-free mount helper.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/popover
Open binding, trigger toggle, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/popover
Plain dialog panel, non-modal layer dismissal, no visual recipe.
Vanilla
@grassroot/ui-headless-core/popover (mountPopover)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's own popover visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/popover
Ships behaviour
Open binding, trigger toggle, no DOM.
Layer
Headless
Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/popover
Ships behaviour
Plain dialog panel, non-modal layer dismissal, no visual recipe.
Layer
Vanilla
Package
@grassroot/ui-headless-core/popover (mountPopover)
Ships behaviour
The same headless contract with no framework runtime at all.
Layer
Styled
Package
@grassroot/ui-{react,vue,solid,svelte,angular}
Ships behaviour
Grassroot's own popover visual recipe, tokens and classes only.

Behaviour contract

This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.

PopoverInput: open? / defaultOpen?    (ONE state-backed binding)
              side?: "top" | "bottom"  (styling-only; the machine never branches on it)
              align?: "start" | "end"  (styling-only; the machine never branches on it)
StateValue:   "open" | "closed"
Context:      { mode: "controlled" | "uncontrolled" }
Events:       TOGGLE { reason: "trigger" }
              OPEN  { reason: "trigger" | "programmatic" }
              CLOSE { reason: "escape" | "outside-click" | "programmatic" }
Command:      OPEN_CHANGE_REQUEST { open, reason }
  • Open binding: the same open/defaultOpen shape every layered overlay uses. Controlled mode is a versioned addition over legacy; defaultOpen is the legacy-compatible uncontrolled seed. A post-start mode flip emits "Popover cannot change open binding mode after start" and preserves state.
  • TOGGLE is the legacy trigger action: it opens a closed popover and closes an open one, always with reason "trigger". OPEN and CLOSE are the programmatic/layer-driven events, and both are true no-ops in their already-target state.
  • side and align are accepted only so the connector can emit styling attributes. No machine transition ever reads either one.
  • No-ops preserve state and context references, emit no command, and never create a second request.
  • Forms: none.

Managers & dismissal

Popover is a non-modal overlay: no focus trap, no scroll lock. This is the same non-modal layer pattern DropdownMenu already proved out.

  • Non-modal layer, top-layer-only dismissal. On an open commit, the connector copies DropdownMenu's own activateMenuLayer pattern: pushLayer({ modal: false, ... }) with Trigger and Content in its containment set. The layer sends the machine's own CLOSE with reason: "escape" or reason: "outside-click"; adapters install no document Escape or outside-click listener of their own, and Content has no scrim handler.
  • Focus never moves. The connector installs no trapFocus and no lockScroll, so focus stays exactly where it was when the popover opened and where it is when it closes.
  • Mount-time only. The layer is released on close commit and on unmount; activation never happens during SSR.

Anatomy

The headless layer's parts, with identical part names across every framework adapter:

Popover.Root      context/service owner; renders nothing itself
Popover.Trigger   data-part="trigger"  (aria-haspopup="dialog", aria-expanded, aria-controls -> content id)
Popover.Content   data-part="content"  (role="dialog"; data-side and data-align are styling hooks)

Root is the service/context owner and has no DOM part of its own. The styled wrappers may add their legacy root wrapper around Trigger and Content, but the headless Root never renders one. Content renders only while open (legacy unmount-on-close). Content is plain dialog content: it has no menu, menuitem, roving-focus, or aria-activedescendant role anywhere, which is the concrete difference from ThemeMenu/DropdownMenu's menu-shaped anatomy. Every rendered part carries data-scope="popover", data-part, and data-state="open" | "closed". The trigger is the only legacy trigger and anchor element, so there is no separate anchor part.

Keyboard and pointer

Clicking Trigger sends TOGGLE { reason: "trigger" }. Escape and outside pointerdown are handled entirely by the active non-modal layer, described above. Content itself has no keyboard navigation contract: no arrow-key movement, no typeahead. Pointer events inside Trigger or Content never dismiss; a pointerdown outside both registered elements closes the popover.

Forms

None.

Accessibility

Trigger carries aria-haspopup="dialog", aria-expanded, and aria-controls pointing at Content's deterministic id. Content is a plain role="dialog" panel: not modal, not a menu, no aria-activedescendant. connectPopover and every prop getter are DOM-free and SSR-safe; IDs are derived from the adapter's scope seed, and the layer is activated only once Trigger and Content are mounted in a live document, so framework adapters render the closed state without a hydration mismatch. Vanilla mounting itself requires a live document and is not an SSR mount path.

Evidence & further reading