- Layer
- Machine
- Package
@grassroot/ui-machines/popover- Ships behaviour
- Open binding, trigger toggle, no DOM.
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.
@grassroot/ui-machines/popover@grassroot/ui-headless-{react,vue,solid,svelte,angular}/popover@grassroot/ui-headless-core/popover (mountPopover)@grassroot/ui-{react,vue,solid,svelte,angular}- 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/defaultOpenshape every layered overlay uses. Controlled mode is a versioned addition over legacy;defaultOpenis the legacy-compatible uncontrolled seed. A post-start mode flip emits"Popover cannot change open binding mode after start"and preserves state. -
TOGGLEis the legacy trigger action: it opens a closed popover and closes an open one, always with reason"trigger".OPENandCLOSEare the programmatic/layer-driven events, and both are true no-ops in their already-target state. -
sideandalignare 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
activateMenuLayerpattern:pushLayer({ modal: false, ... })with Trigger and Content in its containment set. The layer sends the machine's ownCLOSEwithreason: "escape"orreason: "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
trapFocusand nolockScroll, 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
-
popover.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/popover.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
popover.spec.md, the reviewable anatomy/ARIA/keyboard/managers/forms/SSR contract; code must match it, and where they disagree the spec wins. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Popover, applied to a component that isn't one of the shipped machines. - HoverCard: reuses this exact non-modal layer/anatomy, adds hover-delay scheduling
- DropdownMenu: the closest sibling non-modal layer, applied to a menu instead of a plain panel
- Dialog: the modal sibling, with a focus trap and scroll lock Popover deliberately omits