Skip to content

Popover — the cross-framework headless floating primitive

Popover is a headless floating primitive for tooltips and popovers. It wraps @floating-ui/dom, the de-facto vanilla-JS positioning engine behind Radix Popover, Headless UI, MUI, Mantine, Floating Vue, Tippy, and shadcn/ui, and ships for React, Vue, Svelte, Angular, Solid, and Lit.

You bring the anchor (the anchor slot, or a trigger element) and the floating content (the default slot); Popover owns everything else: collision-aware placement (offset → flip → shift → arrow middleware), live autoUpdate tracking on scroll / resize / layout shift, the open/close gesture (trigger: click, hover, or focus), dismissal (Escape + click-outside), the WAI-ARIA wiring (role="tooltip" for hover/focus; a click popover is role-neutral by default, or role="dialog" + aria-modal when you opt into modal; plus aria-expanded / aria-describedby), and a two-way open model.

Unlike DOM-creating engines (Cropper.js, flatpickr), Floating UI creates no DOM of its own — it only writes left / top position styles onto your floating element. So there is no engine-created-node styling problem: the scoped <style> reaches everything, every visual value is a --rozie-popover-* CSS custom property, and there is no :root {} escape hatch.

The @rozie-ui/popover packages

Popover ships as six pre-compiled, per-framework packages. Install the one for your framework plus the @floating-ui/dom engine peer; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/popover-reactnpm i @rozie-ui/popover-react @floating-ui/domreact/README
@rozie-ui/popover-vuenpm i @rozie-ui/popover-vue @floating-ui/domvue/README
@rozie-ui/popover-sveltenpm i @rozie-ui/popover-svelte @floating-ui/domsvelte/README
@rozie-ui/popover-angularnpm i @rozie-ui/popover-angular @floating-ui/domangular/README
@rozie-ui/popover-solidnpm i @rozie-ui/popover-solid @floating-ui/domsolid/README
@rozie-ui/popover-litnpm i @rozie-ui/popover-lit @floating-ui/domlit/README

Each package carries its framework peer plus the shared @floating-ui/dom engine peer.

Quick start

Two-way bind open, project a trigger into the anchor slot and the content into the default slot. Popover positions the content, tracks it, and toggles open on the chosen gesture:

rozie
<components>
{
  Popover: './Popover.rozie',
}
</components>

<data>
{
  open: false,
}
</data>

<template>
  <Popover r-model:open="$data.open" trigger="click" placement="bottom" :offset="8" arrow @change="onChange">
    <template #anchor="{ toggle }">
      <button @click="toggle">Menu</button>
    </template>
    <div class="menu">Floating content</div>
  </Popover>
</template>

r-model:open is Rozie's two-way bind: the consumer hands Popover a boolean, and Popover writes the new state back whenever the trigger or a dismissal toggles it, with no onChange → setState wiring. The anchor slot exposes { open, toggle, show, hide } so you can build any trigger element.

API

Props

NameTypeDefaultRuntime-updatable?Description
openBooleanfalseyes (via r-model)Whether the floating content is open — the sole model: true prop. Two-way bind it; Popover writes the new state back on every trigger/dismissal/programmatic toggle.
placementString"bottom"yesFloating UI placement (top/right/bottom/left, optionally -start/-end). May flip to the opposite side on overflow unless disableFlip is set.
triggerString"click"noOpen gesture: 'click' (toggle, popover dialog), 'hover' or 'focus' (tooltip). Also drives the floating role.
offsetNumber8yesGap in pixels between anchor and content (the offset middleware).
disableFlipBooleanfalseyesDisable the flip middleware (keep the content pinned to placement).
disableShiftBooleanfalseyesDisable the shift middleware (keep the content strictly aligned to the anchor).
arrowBooleanfalseyesOpt in to a positioned arrow element + the arrow middleware.
disabledBooleanfalseyesDisable the control entirely: the trigger no longer opens, and open content is suppressed.
modalBooleanfalseyesOpt in to modal dialog semantics for a click popover. Off by default: a click popover is a non-modal, click-outside-dismissable layer, rendered role-neutral (the slot content owns its ARIA role) with no aria-modal. Set modal for a true modal dialog (role="dialog" + aria-modal="true") — Popover ships no focus trap, so supply your own focus containment. Ignored for hover/focus (always tooltip).
strategyString"absolute"yesFloating UI positioning strategy — 'absolute' (default) or 'fixed'. Use 'fixed' to escape a scrollable/overflow-clipping ancestor (e.g. a sticky table header).

Events

EventDescription
changeFired whenever the open state changes — a trigger gesture, an Escape / click-outside dismissal, or a programmatic show/hide/toggle. Payload is the new open boolean. (Named change, not open, to avoid the model-prop==emit-name collapse.)

Imperative handle

Declared once in the source via $expose; obtained through each framework's native ref mechanism.

MethodDescription
showOpen the floating content (no-op when disabled). Emits change.
hideClose the floating content. Emits change.
toggleFlip the open state (no-op when disabled). Emits change.
repositionRecompute the floating position immediately (computePosition). Named reposition, not update, because update is a reserved Lit ReactiveElement lifecycle method.

Theming

Every value the component renders is a --rozie-popover-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope (:root, .dark, a wrapper, or the .rozie-popover element — custom properties inherit through display:contents):

css
.rozie-popover {
  --rozie-popover-bg: #0b1220;
  --rozie-popover-color: #e5e7eb;
  --rozie-popover-border: 1px solid rgba(255, 255, 255, 0.12);
  --rozie-popover-radius: 10px;
  --rozie-popover-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

The complete token table and the design-system bridges live on the dedicated theming page.

Accessibility

The floating element carries role="tooltip" when trigger is hover/focus. A click popover is non-modal and role-neutral by default — it advertises no role and no aria-modal, so the slot content owns its own ARIA role (e.g. a role="menu"); this keeps a dismissable, non-modal layer from falsely telling assistive tech that sibling content is inert. Opt into modal to make it a real modal dialog (role="dialog" + aria-modal="true") — Popover ships no focus trap (it stays a minimal, headless primitive), so when you set modal you must supply your own focus containment for the claim to hold. The anchor carries aria-haspopup="dialog" and aria-expanded (stringified, never dropped on false); in tooltip mode it also gains aria-describedby pointing at the open content. Project an interactive, focusable element (e.g. a <button>) into the anchor slot so the keyboard story works; Escape dismisses while open.

Pre-1.0 — APIs may change between minor versions.