Popover
A positioned overlay for supplementary content — a menu, a filter panel, a rich hint — tethered to a
trigger. It runs entirely on the platform: the trigger opens the panel through the Popover API
(top-layer, light-dismiss, Esc — zero JS), and the panel is placed with CSS Anchor Positioning
(anchor-name / position-anchor + position-area, with a collision flip via
position-try-fallbacks) — zero positioning JS, no scroll/resize listeners. Baseline 2026, with
graceful degradation: an older engine still opens the panel and places it, just without the
collision-aware flip.
The examples are organised by layer: the class reference is the standalone
@oriui/css layer, and the Framework API is the @oriui/vue component. Every
example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte /
plain-HTML usage), Vue; HTML is the default.
Classes
A popover is the .ori-popover surface class plus the shared .ori-anchored placement primitive (and
one placement modifier). There is no variant or color axis — the panel takes its surface from the shared
surface tokens.
| Class | Type | Description |
|---|
Basic
Open the popover and observe it track the trigger with no JS positioning — the panel is a [popover]
element placed via anchor-name / position-anchor. Click outside, or press Esc, to dismiss (both
native to the Popover API).
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
<!-- The trigger's popovertarget links it to the panel id; anchor-name / position-anchor tether them. -->
<button popovertarget="pop-1" style="anchor-name: --pop-1" aria-haspopup="dialog" aria-controls="pop-1">
Open popover
</button>
<div
id="pop-1"
popover
role="dialog"
class="ori-popover ori-anchored ori-anchored_bottom-start"
style="--ori-anchor: --pop-1"
aria-labelledby="popover-title"
>
<strong id="popover-title">Weave a popover</strong>
<p>Positioned with CSS Anchor Positioning, toggled by the Popover API.</p>
</div>
Placements
Twelve placements — bottom-start is the default. A bare side (top / bottom / left / right)
centers on the cross axis; -start / -end align to the trigger's start / end edge (logical,
RTL-aware). Each sets a position-area on the trigger's anchor grid plus the gap margin on the facing
side; position-try-fallbacks flips it on overflow.
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
<!-- swap the modifier: ori-anchored_top / _top-start / _top-end / _bottom / _bottom-start / _bottom-end / _left / _left-start / _left-end / _right / _right-start / _right-end -->
<div id="pop-2" popover role="dialog" class="ori-popover ori-anchored ori-anchored_top" style="--ori-anchor: --pop-2">
<p>Above the trigger.</p>
</div>
Common patterns
A filter panel opened from a toolbar button — a typical non-modal popover use case.
Positioned with CSS Anchor Positioning and toggled by the Popover API — zero positioning JS.
<button
popovertarget="pop-filters"
style="anchor-name: --pop-filters"
aria-haspopup="dialog"
aria-controls="pop-filters"
>
Filters
</button>
<div
id="pop-filters"
popover
role="dialog"
class="ori-popover ori-anchored ori-anchored_bottom-end"
style="--ori-anchor: --pop-filters"
aria-labelledby="filters-title"
>
<strong id="filters-title">Filters</strong>
<!-- filter controls -->
</div>
Accessibility
The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes. The interactive behaviour, however, comes from the platform: the Popover API and CSS Anchor Positioning drive open/close and placement with no state to keep in sync.
- The trigger MUST be a real
<button>(or a component that renders one, e.g.OriButton) — the Popover API's declarativepopovertargetassociation only works from a button. - Give the panel an accessible name with
aria-labeloraria-labelledby— undeclared attributes fall through to the panel (inheritAttrs: false+v-bind="$attrs"), so pass either directly on<OriPopover>. roledefaults to'dialog'(a non-modal popup); pass'menu','listbox', or another role that matches the panel's actual content.- The expanded state is unmanaged. The Popover API opens and closes the panel entirely in the
browser with no JS state to reflect, so there is no live
aria-expandedto toggle. The#triggerslot'spropsbag instead conveys the popup relationship statically:aria-haspopupmirrors the panel'srole, andaria-controlspoints at the panel's id. Escand outside-click dismissal, and returning focus to the trigger, come from the platform via the Popover API — no focus-trap or dismiss handler to wire.
| Key | Action |
|---|---|
Enter | Activates the trigger and opens the panel (native <button>). |
Space | Activates the trigger and opens the panel (native <button>). |
Escape | Closes the panel and returns focus to the trigger (Popover API). |
Framework API
The props, events, and slots of the Vue component. The standalone CSS layer has no component
API — its surface is the classes above, and you wire popovertarget / anchor-name
yourself. (Svelte bindings are planned.)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
placement | 'top' | 'bottom' | 'left' | 'right' (each also -start / -end) | 'bottom-start' | Placement relative to the trigger. Drives the ori-anchored_<placement> modifier. |
role | string | 'dialog' | ARIA role for the panel — 'dialog' (default), 'menu', 'listbox', … per the content it holds. |
Events & attributes
OriPopover declares no custom emits — open/close is owned by the platform (the Popover API), not
component state. It sets inheritAttrs: false; attributes placed on <OriPopover> (aria-label,
aria-labelledby, id, …) fall through to the panel <div popover>, not the trigger.
Slots
| Slot | Scope | Description |
|---|---|---|
trigger | { props: object } | The control that opens the panel. Spread v-bind="props" on your own <button> (or OriButton) — it must be a button. |
default | — | Panel content, rendered inside the [popover] element. |