Popover
A popover is a dialog anchored to the element that opens it, such as a picker
panel, a filter or a share box. It sits over the page without blocking it.
UniAria shows it in the top layer with the Popover API, as a manual popover
(popover="manual"), places it next to its trigger, and adds what the browser
leaves out: an open state you can control, Escape and outside dismissal, focus
on open and close, nesting, and a closing phase for exit animations. Browsers
without the Popover API get it in place instead.
The APG has no popover pattern. The trigger follows
Disclosure
(aria-expanded), and the popover follows
Dialog (Modal)
(role="dialog", a name, Escape, focus in and back). Overlays
covers what Popover shares with Dialog and Tooltip: the layer stack, dismissal
and the two positioning modes.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The popovers' look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
justify-items: center;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
.row {
display: flex;
gap: 0.5rem;
}
.fields {
display: flex;
gap: 0.75rem;
}
.fields > uni-number-field {
inline-size: 6rem;
}
</style><output id="log">Open one, then press Escape or click elsewhere.</output>
<div class="row">
<uni-popover non-modal>
<button data-uni-part="popover-trigger">Share</button>
<div data-uni="popover">
<div data-element="Arrow"></div>
<p data-element="Description">Anyone with the link can view.</p>
<uni-button id="copy">
<button data-uni="button" data-variant="primary">Copy link</button>
</uni-button>
</div>
</uni-popover>
<uni-popover position-area="bottom span-right">
<button data-uni-part="popover-trigger">Size</button>
<div data-uni="popover">
<div data-element="Arrow"></div>
<h3 data-element="Title">Image size</h3>
<div class="fields">
<uni-number-field default-value="1280" min-value="1">
<div data-uni="number-field">
<label data-element="Label">Width</label>
<div data-element="Wrapper"><input data-element="Input" /></div>
</div>
</uni-number-field>
<uni-number-field default-value="720" min-value="1">
<div data-uni="number-field">
<label data-element="Label">Height</label>
<div data-element="Wrapper"><input data-element="Input" /></div>
</div>
</uni-number-field>
</div>
<button data-element="Close">Apply</button>
</div>
</uni-popover>
</div>
<script src="/demo/popover.js"></script><script>const log = document.getElementById('log');
const nameOf = (event) =>
event.target.querySelector('[data-uni-part="popover-trigger"]').textContent;
document.addEventListener('openchange', (event) => {
if (event.detail) log.textContent = `${nameOf(event)}: open`;
});
document.addEventListener('dismiss', (event) => {
log.textContent = `${nameOf(event)}: dismiss, ${event.detail.reason}`;
});
document.getElementById('copy').addEventListener('press', () => {
log.textContent = 'Share: link copied';
});
</script>React
import { popover } from '@uniaria/core/popover';
import { useBehavior } from '@uniaria/react';
export function ImageSize() {
const size = useBehavior(popover, { positionArea: 'bottom span-right' });
return (
<>
<button {...size.parts.trigger}>Size</button>
<div {...size.parts.root}>
<div {...size.parts.arrow} />
<h3 {...size.parts.title}>Image size</h3>
<label>
Width <input type="number" defaultValue={1280} />
</label>
<button {...size.parts.close}>Apply</button>
</div>
</>
);
}Vue
<script setup lang="ts">
import { popover } from '@uniaria/core/popover';
import { useBehavior } from '@uniaria/vue';
const size = useBehavior(popover, { positionArea: 'bottom span-right' });
</script>
<template>
<button v-bind="size.parts.trigger">Size</button>
<div v-bind="size.parts.root">
<div v-bind="size.parts.arrow" />
<h3 v-bind="size.parts.title">Image size</h3>
<label>Width <input type="number" value="1280" /></label>
<button v-bind="size.parts.close">Apply</button>
</div>
</template>HTML
<uni-popover position-area="bottom span-right">
<button data-uni-part="popover-trigger">Size</button>
<div data-uni="popover">
<div data-element="Arrow"></div>
<h3 data-element="Title">Image size</h3>
<label>Width <input type="number" value="1280" /></label>
<button data-element="Close">Apply</button>
</div>
</uni-popover>
<script type="module">
import '@uniaria/elements/popover';
</script>The trigger and the root must both be inside <uni-popover>.
Render the root always: it stays hidden while closed, and opening it renders
nothing. Keep it right after the trigger in the DOM when you can, or after
the trigger’s frame when it has one (see
The trigger’s label and extras). UniAria
passes the trigger to showPopover() as its source, which puts the popover
next to it in the Tab order, but browsers that ignore source follow the DOM.
Give the root a margin: it is the gap to the trigger (see
Positioning).
By default a popover is modal for the keyboard and assistive technology: Tab
wraps inside it, and the rest of the page gets aria-hidden until it closes.
It is never modal for the pointer: the page still takes clicks, and its scroll
isn’t locked. isNonModal gives the native behavior instead. Tab continues
past the popover as if it sat right after its trigger, the page stays exposed,
and focus moving elsewhere closes it with reason 'blur'. Use it for content
that goes with the page, such as help or a share box, and for lists that keep
focus in a field.
React
const share = useBehavior(popover, { isNonModal: true });Vue
<script setup lang="ts">
const share = useBehavior(popover, { isNonModal: true });
</script>HTML
<uni-popover non-modal>…</uni-popover>A press outside closes the popover. To keep it open for some targets, such as
a toast or a toolbar that edits what the popover shows, return false from
shouldCloseOnInteractOutside. On the element it is a property only.
React
const size = useBehavior(popover, {
shouldCloseOnInteractOutside: (target) =>
!target.closest('[data-popover-keep]'),
});Vue
<script setup lang="ts">
const size = useBehavior(popover, {
shouldCloseOnInteractOutside: (target) =>
!target.closest('[data-popover-keep]'),
});
</script>HTML
document.querySelector('#size').shouldCloseOnInteractOutside = (target) =>
!target.closest('[data-popover-keep]');Pass isOpen and onOpenChange to open the popover from your own state, or
to close it from an action inside, such as a design system’s button that
closes its popover after it runs. A dismissal, the trigger and the close part
only report the change; the popover stays open until you set isOpen to
false. On the element, setting the isOpen property makes it controlled:
update it from openchange.
const [isOpen, setOpen] = useState(false);
const filters = useBehavior(popover, { isOpen, onOpenChange: setOpen });The trigger is a Button, and it takes Button’s label, description, frame and extras as four parts of its own. All four sit outside the popover, as the trigger does:
triggerLabelandtriggerDescriptiongo inside the trigger. While rendered, the label alone names the trigger and the description describes it, so an icon or a hint inside stays out of the name. The label also names the popover in the trigger’s place.triggerFramegoes around the trigger and its extras, andtriggerExtrasbeside the trigger, never inside it: your buttons, badges and counts. Both carry the trigger’s state attributes, and the pointer anywhere over the frame hovers the trigger.
React
import { button } from '@uniaria/core/button';
import { popover } from '@uniaria/core/popover';
import { useBehavior } from '@uniaria/react';
export function FruitPicker(props: { fruit: string; onClear(): void }) {
const picker = useBehavior(popover, {});
const clear = useBehavior(button, { onPress: props.onClear });
return (
<>
<div {...picker.parts.triggerFrame}>
<button {...picker.parts.trigger}>
<span {...picker.parts.triggerLabel}>Fruit</span>
<span {...picker.parts.triggerDescription}>{props.fruit}</span>
</button>
<span {...picker.parts.triggerExtras}>
<button
{...clear.parts.root}
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden />
</button>
</span>
</div>
<div {...picker.parts.root}>…</div>
</>
);
}Vue
<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { popover } from '@uniaria/core/popover';
import { useBehavior } from '@uniaria/vue';
defineProps<{ fruit: string }>();
const emit = defineEmits<{ clear: [] }>();
const picker = useBehavior(popover);
const clear = useBehavior(button, { onPress: () => emit('clear') });
</script>
<template>
<div v-bind="picker.parts.triggerFrame">
<button v-bind="picker.parts.trigger">
<span v-bind="picker.parts.triggerLabel">Fruit</span>
<span v-bind="picker.parts.triggerDescription">{{ fruit }}</span>
</button>
<span v-bind="picker.parts.triggerExtras">
<button
v-bind="clear.parts.root"
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden="true" />
</button>
</span>
</div>
<div v-bind="picker.parts.root">…</div>
</template>HTML
<uni-popover>
<div data-uni-part="popover-trigger-frame">
<button data-uni-part="popover-trigger">
<span data-uni-part="popover-trigger-label">Fruit</span>
<span data-uni-part="popover-trigger-description">Apple</span>
</button>
<span data-uni-part="popover-trigger-extras">
<uni-button>
<button data-uni="button" data-variant="quiet" aria-label="Clear Fruit">
<svg aria-hidden="true">…</svg>
</button>
</uni-button>
</span>
</div>
<div data-uni="popover">…</div>
</uni-popover>
<script type="module">
import '@uniaria/elements/button';
import '@uniaria/elements/popover';
</script>Without an anchor part, the popover is placed against the frame, so
--anchor-width includes the extras. Each extra is a control of its own:
name it in its context (“Clear Fruit”, not “Clear”), and it is a Tab stop
after the trigger. A press on an extra while the popover is open is a press
outside it: the popover closes with reason 'outside' and the extra acts, in
one press, and focus stays on the extra.
UniAria places the root against the anchor part, else the triggerFrame,
else the trigger. Its options are CSS Anchor Positioning’s own, and the page
picks one of two modes.
Script mode, the default, places the popover in every browser from two props:
positionAreais a CSSposition-areavalue that puts the popover on one side of the anchor, optionally with the other axis’scenteror aspan-*keyword. Corners and areas over the anchor aren’t supported: the arrow needs a side.positionTryFallbacksis a CSSposition-try-fallbacksvalue:none, or a list of try tactics (flip-block,flip-inline,flip-start, combined as in CSS) andposition-areavalues. The default,flip-block, flip-inline, tries the opposite side on either axis.
positionArea |
Places the popover |
|---|---|
'bottom' (default) |
Below the anchor, centered. |
'bottom span-right' |
Below, left edges aligned. |
'bottom span-left' |
Below, right edges aligned. |
'top', 'left', 'right' |
On that side, centered. |
'right span-bottom' |
To the right, top edges aligned. |
'block-end span-inline-end' |
Below, aligned with the anchor’s start edge in the page’s writing direction. |
Plain logical keywords follow the page’s writing mode, self- ones the
popover’s own, as in CSS.
The gaps are the root’s margins, from your CSS. The margin facing the anchor
is the gap, the others keep the popover off the viewport’s edges, and a
fallback flips them with the side, as CSS does: margin: 8px 0 keeps the
popover 8px from its trigger above or below. On a span-* area a side margin
also moves it off the anchor’s edge, as in CSS. UniAria reads the computed
margins each time it places the popover, so margins that change with state
work. It keeps the last option that fit while it still fits, shifts the
popover into the viewport, and checks every frame while it is open, so it
follows its trigger as the page scrolls.
CSS mode leaves placing the popover to your CSS anchor positioning. Turn it on once for the page, before overlays open. It applies to every popover and tooltip, and to the lists of Select and Combobox, from the next time one opens:
import { configure } from '@uniaria/core/dom';
configure({ positioning: 'css' });[data-uni='popover'] {
inset: auto; /* a popover's own is 0 */
position-area: bottom span-right;
position-try-fallbacks: flip-block;
margin: 8px 0;
}UniAria then links the root to its anchor (an inline position-anchor, with a
generated name added to the anchor’s anchor-name list), reads back the side
the browser applied for data-placement and placement, writes the CSS
variables and places the arrow. It ignores positionArea and
positionTryFallbacks. A browser without CSS anchor positioning leaves the
popover where your CSS puts it, and development builds warn.
In both modes the popover closes with reason 'scroll' once its trigger has
been scrolled out of view.
A popover opened from inside another stacks above it:
- Escape closes only the top one.
- A press inside the outer popover closes only the inner one. A press outside both closes both, top down.
- Closing a popover closes the popovers opened from inside it, with reason
'parent'. - A press on another popover’s trigger closes this popover and opens that one, in one click.
A modal Dialog makes everything outside it inert, popovers in
the top layer included. A popover used in a dialog must be inside the
<dialog> element; development builds warn when one opens outside. A dialog
opened from a popover stacks above it and leaves it open, and closing the
dialog returns focus into the popover.
In browsers without the Popover API (Chrome before 114, Firefox before 125,
Safari before 17), the popover opens in place: hidden while closed,
position: fixed while open, placed by script mode. Give the root a
z-index. An ancestor with a transform, a filter or contain clips and
offsets it, as it does any fixed element. Server HTML shows the root in those
browsers until UniAria starts.
| Part | Element | Notes |
|---|---|---|
trigger |
button |
Optional. A press toggles the popover. A Button: another element gets Button’s role and keys. UniAria writes its id (unless it has one), aria-expanded, and aria-controls while open. |
triggerLabel |
any, inside the trigger; usually span |
Optional. While rendered, it alone names the trigger, and the popover in the trigger’s place. UniAria writes its id (unless it has one). |
triggerDescription |
any, inside the trigger; usually span |
Optional. Describes the trigger while rendered. UniAria writes its id (unless it has one). |
triggerFrame |
any; usually div |
Optional. Around the trigger and its extras. It carries the trigger’s state attributes, and the pointer over it hovers the trigger. Without an anchor part, the popover is placed against it. |
triggerExtras |
any, inside the frame; usually span |
Optional. Beside the trigger, never inside it: your buttons, badges and counts. It carries the trigger’s state attributes. |
anchor |
any | Optional. The popover is placed against it instead of the frame or the trigger. |
root |
any; usually div |
Hidden while closed. UniAria writes its id (unless it has one), popover="manual", role="dialog", tabindex="-1", aria-labelledby, aria-describedby, data-placement and its position. Its content is yours. |
arrow |
any | Optional. UniAria writes aria-hidden="true" and its offset along the edge; your CSS puts it on the side data-placement names. |
title |
any; usually a heading | Optional. Names the popover through aria-labelledby. |
description |
any | Optional. Linked through aria-describedby while rendered. |
close |
button |
Optional. A press closes the popover, even when it isn’t dismissable. A Button too. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
The popover’s name comes from labelledBy and a rendered title, else an
aria-label on the root, else the trigger, or its triggerLabel while
rendered: a popover opened by “Size” is read as “dialog, Size”. Development
builds warn when it has none of these. The custom element has no role of its
own and never takes focus.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
isOpen |
— (property: setting it makes it controlled) | boolean |
— | The open state, controlled. Pair it with onOpenChange. |
defaultOpen |
default-open |
boolean |
false |
The starting state, uncontrolled. With server rendering, it opens after hydration. |
isNonModal |
non-modal |
boolean |
false |
Tab continues past it, the page stays exposed to assistive technology, and focus leaving closes it. |
isDismissable |
dismissable |
boolean |
true |
Escape, a press outside and, when non-modal, focus leaving close it. dismissable="false" turns all three off; the trigger, the close part and your code still close it. |
isKeyboardDismissDisabled |
keyboard-dismiss-disabled |
boolean |
false |
Escape alone doesn’t close it. |
shouldCloseOnInteractOutside |
— (property only) | (target: Element) => boolean |
— | Return false to keep it open for a press on target. |
isDisabled |
disabled |
boolean |
false |
The trigger can’t open it: Button’s isDisabled on the trigger. |
shouldRestoreFocus |
restore-focus |
boolean |
true |
Focus goes back when it closes (see Accessibility). restore-focus="false" turns it off. |
shouldFocusOnOpen |
focus-on-open |
boolean |
true |
Focus moves into the popover when it opens. Off, focus stays where it is. focus-on-open="false" turns it off. |
positionArea |
position-area |
PositionArea |
'bottom' |
A CSS position-area on one side of the anchor. Script mode only. |
positionTryFallbacks |
position-try-fallbacks |
string |
'flip-block, flip-inline' |
A CSS position-try-fallbacks. Script mode only. |
labelledBy |
labelled-by |
string |
— | More ids for aria-labelledby. |
describedBy |
described-by |
string |
— | More ids for aria-describedby. |
tooltip |
tooltip (the <uni-tooltip>’s id) |
a tooltip’s instance |
— | A Tooltip for the trigger. On the element, a <uni-tooltip> that <uni-popover> sits in directly links too. With shouldOpenOnlyWhenTruncated, it measures triggerLabel and triggerDescription, else the trigger. |
PositionArea is exported from @uniaria/core/popover as a type.
| Event | Callback prop | Detail | When |
|---|---|---|---|
openchange |
onOpenChange |
boolean |
The trigger, a dismissal or the close part asks to open or close it. Never when you set isOpen. |
dismiss |
onDismiss |
PopoverDismissEvent |
The user, or a popover it was opened from, asked to close it, just before openchange with false. |
interface PopoverDismissEvent {
reason:
| 'escape' // Escape in the popover or on its trigger
| 'outside' // a press outside
| 'trigger' // a press on its own trigger
| 'close' // the close part
| 'blur' // focus left a non-modal popover
| 'scroll' // its trigger was scrolled out of view
| 'parent' // the popover it was opened from closed
| 'browser'; // it was removed from the page while open
}A close for 'scroll' leaves focus alone, since moving it would scroll the
page back. The custom element dispatches both events on itself, bubbling, with
the value in detail.
| Action | What it does |
|---|---|
open() |
Opens the popover, as its trigger does, and moves focus inside unless shouldFocusOnOpen is off. isDisabled only disables the trigger, so open() still works. |
close() |
Closes it after its exit animations and returns focus. Reports openChange, but no dismiss: no user asked. |
In React and Vue they are on the handle (size.actions.open()); on the
custom element they are methods
(document.querySelector('uni-popover').open()).
While isOpen is controlled, each only reports openChange and leaves the
change to you.
| Field | Type | Meaning |
|---|---|---|
isOpen |
boolean |
The popover is open. |
isEntering |
boolean |
The first frames after opening, for enter transitions. |
isExiting |
boolean |
Closed, but still shown until its exit animations end. |
placement |
'top' | 'bottom' | 'left' | 'right' | undefined |
The side it was placed on, after fallbacks; undefined while it isn’t shown. |
const placement = useBehaviorState(size, (state) => state.placement);On the custom element, read element.instance.getState(). See
State.
| Attribute | On | When |
|---|---|---|
data-uni="popover" |
root | Always. |
data-uni-part="popover-trigger", data-uni-part="popover-anchor" |
trigger, anchor | Always. |
data-uni-part="popover-trigger-label", …-trigger-description, …-trigger-frame, …-trigger-extras |
the trigger’s label, description, frame and extras | Always. |
data-element |
every other part | Always. |
data-open |
every part but the anchor and the trigger’s label and description | The popover is open. |
data-entering |
every part but the anchor and the trigger’s label and description | The first frames after opening. |
data-exiting |
every part but the anchor and the trigger’s label and description | It is closing, until its exit animations end. |
data-placement |
root, arrow | While shown: the side it was placed on, top, bottom, left or right. |
data-hovered, data-pressed, data-focused, data-focus-visible, data-disabled |
trigger, its frame and extras, close | As on a Button. The frame and the extras carry the trigger’s, and data-hovered is set while the pointer is anywhere over the frame. |
| CSS variable | On | Value |
|---|---|---|
--anchor-width |
root | The anchor’s width, in pixels: min-width: var(--anchor-width) makes the popover at least as wide as its trigger, or its frame with the extras. |
--available-width, --available-height |
root | The most room the popover could take in any of its options, in pixels, for max-width and max-height, so a cap never stops a fallback. |
Nothing caps the popover’s size unless you use them. A popover’s default
overflow: auto clips an arrow outside its box: give the root
overflow: visible, and scroll an element inside it instead.
[data-uni='popover'] {
min-width: var(--anchor-width);
max-height: var(--available-height);
margin: 8px 0;
overflow: visible;
}
[data-uni='popover'] [data-element='Arrow'] {
position: absolute;
width: 14px;
height: 14px;
}
/* Rotate a child of the arrow: UniAria measures the arrow itself. */
[data-uni='popover'] [data-element='Arrow']::before {
content: '';
position: absolute;
inset: 2px;
background: Canvas;
rotate: 45deg;
}
[data-element='Arrow'][data-placement='bottom'] {
top: -8px;
}
[data-element='Arrow'][data-placement='top'] {
bottom: -8px;
}UniAria measures the arrow’s box, transforms included, to center it on the anchor. Rotating the arrow part itself would move it off center by a few pixels, so rotate a pseudo-element or a child, as here.
Exit animations work as in Dialog: style the hidden
look on [data-entering] and [data-exiting], and UniAria calls
hidePopover() once the animations running on the root and inside it end.
Inline styles UniAria writes, and puts back when the popover closes: in script
mode, position: fixed and the top, right, bottom and left longhands
on the root; in CSS mode, position-anchor on the root and an anchor-name
entry on the anchor; in both, the CSS variables and the arrow’s left or
top. The trigger and close parts get Button’s touch-action.
The optional @uniaria/styles package styles
every part, as in the demo above, whose CSS tab holds only the page’s layout.
The popover is the surface Select’s popup uses,
with its parts in a column 8 pixels apart and 12 pixels in from its edge. Its
8-pixel margin is the gap from the trigger and keeps it off the viewport’s
edges, and it is no larger than the room UniAria measures. Without an arrow it
scrolls inside; with one it keeps its overflow for the arrow, so scroll an
element inside it. The arrow points at the trigger from whichever side the
popover opened on. The trigger and the close part look like
Button, data-variant included. A trigger
with a triggerLabel is laid out as an item: its label and description take
one line each and end in an ellipsis, and in a frame the extras sit over the
trigger’s end (see
Labels, frames and extras).
Opening, the popover fades in and grows from 90% from the trigger’s side, in
120 milliseconds.
| Key | Behavior |
|---|---|
| Enter, Space | On the trigger, toggles the popover. On the close part, closes it. |
| Escape | With focus in the open popover or on its trigger, closes the top popover when it is dismissable and keyboard dismissal isn’t disabled. A widget inside that handles Escape and prevents its default keeps it open. |
| Tab, Shift+Tab | In a modal popover, move through its tabbable elements and wrap at both ends. |
| Tab | In a non-modal popover, continues as if the popover sat right after its trigger: from its last element, focus goes to what follows the trigger, and the popover closes. |
| Shift+Tab | In a non-modal popover, from its first element, focus goes to the element before the trigger, and the popover closes. |
Each of the trigger’s extras is a Tab stop of its own after the trigger, in DOM order. A modal popover keeps Tab inside it, so the extras are reached once it closes. From a non-modal one, Tab from its last element goes on to the first extra, which follows the trigger, and the popover closes.
- Focus on open. Focus goes to the first element marked
autofocusordata-autofocus, else to the popover itself, so a screen reader reads its name first. WithshouldFocusOnOpenoff, focus stays where it is. - While a modal popover is open, Tab wraps inside it. A press outside that closes the popover lets focus go where the press puts it, and the press acts on what it hit; focus that leaves any other way is brought back.
- Focus on close, after the exit animation: the trigger, else the element focused before it opened. Focus stays put when it has already moved to an element outside the popover and its trigger: after a click on a field outside, it stays on the field. Natively it would land on the body.
- The page behind a modal popover gets
aria-hidden="true", neverinert, so the pointer still works. Live regions stay exposed. - Spoken output. “button, Size, not expanded” on the closed trigger;
“dialog, Size” when it opens, named by the trigger, or “dialog, Image size”
with a title. With a
triggerLabel“Fruit” and atriggerDescription“Apple”, the trigger reads “button, Fruit, Apple, not expanded” and the popover “dialog, Fruit”. - Extras aren’t part of the trigger’s name or description: each reads on
its own, such as “button, Clear Fruit”. Text that should describe the
trigger, such as a badge, goes in
triggerDescription. - Trigger ARIA.
aria-expandedalways andaria-controlswhile open, but noaria-haspopup: screen readers read most of its values as “menu”. UniAria doesn’t usepopovertarget, which would toggle the popover behind the open state. - Touch screen readers can’t press outside a modal popover they’re in. Render a close part, visually hidden if it shouldn’t show.
- Tooltips inside. A Tooltip’s Escape closes the tooltip first; the next Escape closes the popover.
- One click outside closes it and acts on what it hit, as the native popover does. React Aria swallows that click, so a second click acts. One click on another popover’s trigger switches popovers.
- Modal for the keyboard and screen readers only. React Aria’s popover
makes the page inert, locks scroll and adds an underlay; Zag’s and the
native popover are non-modal, which
isNonModalgives. - The top layer, no portal, with
popover="manual": UniAria owns every close, so a controlled popover can refuse one, and an unrelated popover opening doesn’t close it, as it would withpopover="auto". - CSS Anchor Positioning’s options and the root’s margins instead of
placement,offset,crossOffsetandshouldFlip. React Aria’s'bottom start'is'bottom span-right'. - Scrolling moves it with its trigger and closes it once the trigger leaves view. React Aria closes a non-modal popover on any scroll.
- A press outside nested popovers closes every popover it is outside of, as the native popover does. React Aria closes only the top one.
- Extras beside the trigger are outside it: a press on one closes the popover and acts. Cube UI Kit keeps its popup open for its trigger’s actions.