Popover
A click / hover / focus triggered popup panel that can hold a title, body text and arbitrary custom content. Supports 12-way placement, dual-axis offset, open/close animation, portal, modal mode and nested popovers.
Basic usage
Trigger on click
Placement
placement supports 12 directions: the four bases top / bottom / left / right each with -start / -end cross-axis alignment (bottom-start aligns the panel's left edge with the trigger's left edge, the most common form). When space is insufficient the panel flips along the main axis and keeps the alignment suffix (bottom-start → top-start); after alignment it is still clamped to the viewport.
Four directions
12-way placement (-start / -end)
Trigger methods
trigger controls the trigger method: click (default) / hover / focus / contextmenu / manual, space separated for multiple (e.g. "click hover"). For hover triggering, hover-delay / hover-hide-delay control the open/close debounce (default 150 / 100ms; without debounce hover flickers open/closed); the hover area is the trigger plus the panel (moving across the gap does not close it). manual mode binds no host events at all — visibility is fully controlled by the host open attribute.
Hover trigger
Custom open/close delays
Generic delays (open-delay / close-delay)
Touchscreen degradation (automatic on coarse pointers)
Touch devices (pointer: coarse, e.g. phones and tablets) have no stable hover semantics: a tap synthesizes both hover and click events, which would open-then-immediately-close the panel with the desktop behavior. When trigger includes hover, the hover channel is automatically disabled on coarse pointers and degrades to tap toggling — tap the trigger to open, tap again to close, or tap outside the panel to dismiss (outside dismiss replaces hover-out). Fine-pointer (desktop mouse) behavior is unchanged. Compatible with controlled open and the long-press-delay touch long-press: lifting the finger after a long-press open does not close the panel (800ms gesture-tail guard); the next tap toggles it.
Tap toggling on touch (automatic on touch devices)
Disabled
disabled disables the whole popover: click / hover / focus / right-click / key triggers are all ignored; the host is desaturated (opacity .6) and aria-disabled is synced. Disabled trigger elements (such as native disabled buttons) do not dispatch mouse events — wrap them in a span before attaching a popover.
Whole popover disabled
Width
width controls the panel width: a number (px), "trigger" (same width as the trigger) or any CSS value (e.g. 50%). width="trigger" fits the "panel as wide as the control" dropdown-select shape.
Width (width)
The boolean attribute same-width is a shortcut for "panel exactly as wide as the trigger" (equivalent to width="trigger", but it also inlines min-width: 0 — strict equality holds even when the trigger is narrower than the panel default min-width of 200px). An explicit width attribute takes precedence; removing same-width hands the width back to CSS.
Offset and collision tuning
offset is a dual-axis offset: "main distance" or "main distance, cross offset" (default 8, 0). Collision tuning: collision-padding viewport clamp gap (default 4px); fallback-placements custom fallback sequence (when the requested placement does not fit, each candidate is tried in order); hide-when-detached hides the panel when the anchor is fully outside the viewport.
Dual-axis offset (offset)
Collision tuning (collision-padding / fallback-placements / hide-when-detached)
Initial focus and keyboard
focus-on-open moves focus into the first focusable element of the panel when opened; initial-focus precisely focuses the element matched by a selector (higher priority, falls back to focus-on-open when unresolvable). trigger-keys toggles open when the listed keys are pressed while the trigger is focused (space separated).
Specified initial focus (initial-focus)
On open, focus goes straight into the input below:
Open with keys (trigger-keys)
Portal
append-to moves the panel outside the host container (to body or a selector), avoiding clipping by the host's overflow: hidden / clip; positioning is viewport-based and unaffected by the move. After the panel leaves the shadow, clicks inside the panel still do not trigger outside-click close.
Portal (append-to)
Arrow and viewport auto adjust
By default an arrow pointing at the trigger element's center is shown (the projection of the anchor center onto the panel's pointing edge; when the panel is shifted by viewport-edge avoidance, the arrow still tracks the anchor center); arrow="false" hides the arrow. By default the panel automatically flips along the main axis and avoids the viewport edges when space is insufficient; auto-adjust-overflow="false" disables the auto adjust so the panel keeps the declared placement (it may overflow the viewport).
Arrow visibility and pointing
Merged arrow (arrow-merge)
Auto adjust disabled
Custom content
Custom content (slot=content)
slot="content". Close button and declarative close
closable shows a close button at the panel's top-right (part="close"); clicking it closes and restores focus. Any element inside the content with data-popover="close" becomes a declarative close control — clicking it closes the popover (great for "Done" / "Got it" style action buttons).
Close button and declarative close
Color variants
color semantic variants: primary / success / warning / danger — a tinted panel background plus a semantic border (arrow included), all derived from tokens (auto-adapted to the dark theme).
Color variants (color)
Open/close animation
The panel plays a fade + scale animation on open and close; transform-origin is direction-aware (expanding from the edge that faces the trigger, hugging the aligned edge for -start/-end). Animations are disabled under prefers-reduced-motion.
Direction-aware animation
Live content and auto close
By default (without fresh) the content is frozen while closed and the latest value is written when opened; with fresh the closed state keeps syncing content (avoids flicker for controlled content). auto-close auto closes after the given duration (onboarding / guided-tour scenarios).
fresh: content keeps updating while closed
auto-close: closes after a timeout
Nested popovers
A floating layer can open a child popover / tooltip from its own content: the child layer anchors correctly and stacks above the parent; closing the parent closes the child as well; Esc closes one layer at a time and restores focus each time.
Nested popovers (pop from a card)
Open a child layer from within the parent panel:
Virtual triggering
In virtual mode there is no real anchor (same as tooltip): the host positions the popover with virtual-x / virtual-y viewport coordinates or a virtual-anchor element selector, ideal for chart / canvas coordinate hints; open controls visibility. In virtual mode neither clicking the trigger nor clicking outside changes the state — the lifecycle is fully host-controlled.
Virtual triggering (canvas cursor tracking)
Move the mouse to see coordinate hints
Virtual triggering (anchor coordinates)
Virtual triggering (anchor element)
Modal mode
modal turns the popover into a modal floating layer: full-screen backdrop + focus trap (Tab / Shift+Tab cycle inside the panel, escaped focus is pulled back, only the topmost modal traps) + scroll lock (intercepts wheel / scroll keys while keeping the scrollbar visible) + aria-modal. Clicking the backdrop closes and restores focus.
Modal mode (backdrop + focus lock + scroll lock)
Focus is trapped inside the panel:
Accessibility wiring & keyboard
The trigger element gets ARIA wiring automatically: aria-haspopup="dialog" + aria-expanded (follows open state) + aria-controls (panel id), so screen readers can perceive the floating layer. trigger-keys defaults to Enter Space — focus the trigger and press Enter / Space to toggle (idempotent with the synthesized click on native buttons, no flicker); override with other keys via the attribute.
Keyboard toggle (default Enter / Space)
trap-focus focus trap
trap-focus decouples the focus trap from modal: no backdrop, no scroll lock, but Tab / Shift+Tab cycle inside the panel without escaping — ideal for form popovers (the background stays interactive while focus stays put). modal + trap-focus stack idempotently (the trap is mounted once).
trap-focus (form popover, focus stays inside)
Close behavior switches
close-on-outside / close-on-escape (both default true, preserving current behavior) control "close on outside click" and "close on Esc" respectively; oas-before-close is a cancelable close event (preventDefault() blocks the close). Every close entry point (trigger toggle / outside click / Esc / close button / declarative close / backdrop / auto-close / in-panel select) passes through it first — detail.source labels the origin.
Close switches + cancelable close (oas-before-close)
Size & style variables
size tiers: small / medium (default) / large — padding, min-width and font-size scale with the tier (token based). The panel look is customizable via component-level CSS variables: --oas-popover-bg / --oas-popover-border / --oas-popover-shadow / --oas-popover-radius / --oas-popover-padding / --oas-popover-min-width (defaults fall back to global tokens; dark theme follows automatically).
Size tiers (size)
Panel CSS variables (--oas-popover-*)
Structured slots & description
slot="header" takes over the whole head region (the title area steps aside); slot="footer" renders a footer action area (separated from the body by a divider); slot="description" renders a supplementary note and wires aria-describedby automatically (screen readers can announce the panel description).
header / footer / description slots
Changes apply immediately on close — no save needed.
Height constraint & in-panel scrolling
available-height constrains the panel max-height to the remaining viewport space along the main axis; scrollable enables in-panel scrolling (head / footer fixed, body area scrolls) so long content no longer overflows the viewport. The two are often combined for long lists.
scrollable + available-height (long list scrolls)
Panel height stays within the remaining viewport; the body scrolls internally:
Focus return & empty state
final-focus sets where focus goes after closing (selector; or pass an element via the finalFocusEl property, which takes precedence); the default returns to the trigger. hide-empty keeps the panel hidden when it has no content at all (no title / content / slot content) — no blank panels popping up.
final-focus (focus moves to a chosen element after close)
hide-empty (no blank panel)
Scroll behavior (sticky / close-on-scroll)
sticky tiers: partial (default, repositions with the anchor on scroll) / always (once the anchor scrolls out, the panel pins to the viewport edge and stays visible) / off (does not follow scroll). close-on-scroll instead closes on scroll — dropdown-style popovers collapsing on scroll often feels right.
sticky=always (panel pins to the edge after the anchor leaves)
↓ Scroll here ↓
close-on-scroll (closes on scroll)
Cursor-anchored context menu & touch long-press
With trigger="contextmenu", right-clicking opens the panel at the cursor position (no longer anchored to the trigger center); on touch devices, holding for 500ms opens at the touch point (no right-click on mobile). After opening, scrolling snaps the panel back to the trigger side (a cursor point has no scroll semantics).
Contextmenu capability ships with the main entry: cursor anchoring, touch long-press and the placement/size breakpoint shorthand below live in a separate capability package (subpath
@oas-ui/ui/feedback/popover/contextmenu, registered on import); the main entry@oas-ui/ui/feedback/popoveralready ships it, so no explicit import is needed (demos below use the full entry, which also includes it). If you import the pure-core entry@oas-ui/ui/feedback/popover/coreinstead, these enhancements are absent —trigger="contextmenu"still opens the panel on right-click (anchored to the trigger element, core behavior), but without cursor anchoring, long-press or breakpoint response (a dev hint points to the explicit import or back to the main entry).
contextmenu cursor anchoring (right-click / touch long-press)
The panel appears at the cursor:
Auto-close after in-panel select
With dismiss-on-select, any click inside the panel (including slot content) counts as a completed selection and closes the panel — no need for data-popover="close" on option panels. The close still passes through oas-before-close (interceptable).
dismiss-on-select (select to close)
Content destroy & lazy mount
destroy-on-hide unmounts the panel content presentation on close (slot nodes detached from assignment, attribute text cleared) and re-mounts on reopen — heavy-content popovers (charts / big lists) cost zero rendering while closed, equivalent to lazy mounting. Host light DOM nodes are never removed.
destroy-on-hide (destroy on close, rebuild on reopen)
This content is unmounted on close (remounted on reopen):
Breakpoints & trigger fine-tuning
placement / size support breakpoint shorthand (same protocol as space/grid): "bottom md:right" = base value + space-separated breakpoint:value pairs (sm=640 / md=768 / lg=1024 / xl=1280, mobile-first min-width) — below the panel on the bottom, at ≥768px on the right. Breakpoint response is part of the contextmenu capability package (shipped by default via the main entry, see the capability note above; the pure-core core entry falls back to the base value). trigger="mousedown" opens on press (no release needed, one beat faster than click).
placement breakpoint shorthand (resize the window to see it switch)
trigger=mousedown (opens on press)
render-panel pure panel rendering
render-panel targets host-assembled scenarios: the popover binds no trigger at all (treated as manual, no trigger ARIA wiring); combined with virtual-x / virtual-y coordinates or append-to, the host fully owns placement and visibility — e.g. custom triggers or canvas-embedded panels.
render-panel (host-assembled trigger)
Controlled display
The open attribute is controlled: an external button can set/remove open to control visibility (clicking outside / pressing Esc still closes it).
Controlled display (open attribute)
API
oas-popover
Attributes
| Attribute | Description | Type | Default |
|---|---|---|---|
append-to | Portal mount target: the panel moves into the target container (body or a CSS selector) when opened and moves back into the host shadow when closed; for panels clipped by host containers (overflow) | string | — |
arrow | Whether to show the arrow (default true; arrow="false" hides it, the element and ::part(arrow) are kept) | string | true |
arrow-merge | Merge the arrow with the panel corner (C1): a right triangle merges flush with the panel corner — legs collinear with the panel edges (their strokes continue the panel border band), tip pointing orthogonally toward the anchor; the matching corner radius is zeroed; only takes effect for -start/-end placements, center placements are unaffected | boolean | — |
auto-adjust-overflow | Viewport-edge auto flip and avoidance (default true; "false" disables it, keeping the declared placement, which may overflow the viewport) | string | true |
auto-close | Auto close after opening for the given duration in ms, e.g. auto-close="3000"; not set means no auto close | string | — |
available-height | Max content height constrained by remaining viewport space | boolean | — |
closable | Show a close button at the panel's top-right (part="close"); clicking it closes and restores focus to the trigger | boolean | — |
close-delay | Generic close delay in ms (default 0; used by non-hover trigger paths, hover paths prefer hover-hide-delay) | string | — |
close-on-escape | Close on Escape (default true) | — | — |
close-on-outside | Close on outside click (default true) | string | true |
close-on-scroll | Close on scroll | boolean | — |
collision-padding | Viewport-edge clamping padding in px (default 4), the gap kept when the panel avoids viewport edges | string | — |
color | Color variant: primary / success / warning / danger (tinted panel background + semantic border, derived from tokens including dark variants); unset or invalid keeps the neutral panel | string | — |
content | Body text | string | — |
destroy-on-hide | Destroy the panel content presentation on close (host light DOM nodes are kept: their slot names are rewritten to detach them and they are hidden; attribute text is cleared); remounted instantly on reopen | boolean | — |
disabled | Disable the whole popover: click / hover / focus / contextmenu / trigger-keys triggers are all ignored; the host is desaturated and aria-disabled is synced | boolean | — |
dismiss-on-select | Clicks inside the panel (named slot content / panel internals including the close button) count as a completed selection and close it (interceptable via before-close); clicks on the trigger element do not dismiss (guards against open-then-close) | boolean | — |
fallback-placements | Custom fallback sequence (comma or space separated, e.g. "left, right"): when the requested placement does not fit, each candidate in the sequence is tried for fit; the first fit wins, if none fit the last one is clamped; unset uses the default main-axis flip | string | — |
final-focus | Focus restore target after close (selector) | string | — |
focus-on-open | Moves focus into the first focusable element of the panel when opened | boolean | — |
fresh | Keep updating the content while closed (by default the content is frozen while closed and the latest value is written when opened; with fresh the closed state keeps writing) | boolean | — |
hide-empty | Hide the panel when it has no content | boolean | — |
hide-when-detached | Hide the panel when the anchor is fully detached from the viewport (the open state is kept, avoiding a panel floating off-screen) | boolean | — |
hover-delay | Hover-trigger open debounce in ms (default 150; falls back to open-delay when unset) | string | — |
hover-hide-delay | Hover-trigger close debounce in ms (default 100; falls back to close-delay when unset) | string | — |
initial-focus | Focus the element matched by the selector when opened (host light DOM first, including slot content; falls back to focus-on-open when unresolvable), higher priority than focus-on-open | string | — |
long-press-delay | Touch long-press delay in ms (default 500; contextmenu trigger only, part of the contextmenu capability package — see the capability note above) | — | — |
modal | Modal mode: full-screen backdrop + focus trap (Tab cycles inside the panel) + scroll lock + aria-modal; clicking the backdrop closes | boolean | — |
offset | Dual-axis offset: "main distance" or "main distance, cross offset" in px (default 8, 0), e.g. offset="12, 20" | — | — |
open | Controlled display (boolean attribute; shows when present) | boolean | — |
open-delay | Generic open delay in ms (default 0; used by non-hover trigger paths, hover paths prefer hover-delay) | string | — |
placement | Popup placement (12 directions: four bases top/bottom/left/right each with -start/-end cross-axis alignment) | string | top |
render-panel | Pure panel rendering mode: no trigger semantics (always treated as manual), positioning falls back to virtual coordinates/anchor; position it yourself via append-to | boolean | — |
same-width | Panel width equals the trigger width (strict equality; explicit width takes precedence) | boolean | — |
scrollable | Scrollable panel when content overflows | boolean | — |
size | Size preset | string | medium |
sticky | Sticky against viewport edge: partial (default) / always / off (disables edge-following) | string | partial |
title | Title text (rendered into the visible title region; absorbed from the host on read so no native hover tooltip remains; pass an empty string to clear); use slot="title" for rich content | string | — |
trap-focus | Standalone focus-trap toggle (decoupled from modal mask; Tab stays inside form popovers) | boolean | — |
trigger | Trigger method: click (default) / hover / focus / contextmenu / manual, space separated for multiple (e.g. "click hover"). With hover, touch devices (coarse pointer) automatically degrade to tap toggling: tap to open, tap again or tap outside to close; desktop hover behavior unchanged | string | click |
trigger-keys | Toggle open when the listed keys are pressed while the trigger is focused (space separated, e.g. "Enter Space"; bound to Enter Space by default) | string | Enter Space |
virtual | Virtual trigger mode (same as tooltip; no anchor element) | boolean | — |
virtual-anchor | Virtual anchor element selector (used when virtual-x/virtual-y are unset) | — | — |
virtual-x | Virtual anchor x (viewport coordinate, px) | — | — |
virtual-y | Virtual anchor y (viewport coordinate, px) | — | — |
width | Panel width: a number (px) / "trigger" (same width as the trigger) / any CSS value (e.g. 50%, 240px); unset keeps the default | string | — |
Events
| Event | Description |
|---|---|
oas-before-close | Emitted before a close request (all close entries converge here: trigger/outside/escape/close-btn/declarative/backdrop/auto/dismiss/scroll), cancelable, detail: { source }; preventDefault blocks the close |
oas-open-change | open state changed, detail: { open } |
Slots
| Name | Description |
|---|---|
| default | Panel body content |
content | Panel content (equivalent to the default slot; named for explicit use) |
description | Rich description slot (wired via aria-describedby) |
footer | Structured footer slot |
header | Structured header slot |
title | Rich title content slot, overrides the title attribute text when present |
CSS Variables
| CSS Variable | Default |
|---|---|
--oas-glass-blur | none |
--oas-glass-ring | transparent |
--oas-origin-x | center |
--oas-origin-y | center |
--oas-popover-arrow-height | 12px |
--oas-popover-arrow-radius | 0 |
--oas-popover-arrow-width | 12px |
--oas-popover-bg | var(--oas-color-bg) |
--oas-popover-border | var(--oas-color-border) |
--oas-popover-font-size | var(--oas-font-size-md) |
--oas-popover-min-width | 200px |
--oas-popover-padding | var(--oas-space-4) |
--oas-popover-radius | var(--oas-radius-md) |
--oas-popover-shadow | var(--oas-shadow-md) |
Clicking the trigger toggles visibility; clicking outside or pressing Esc closes it; role="dialog". Nested popovers: closing the parent cascades to children; Esc closes one layer at a time and restores focus to the trigger.