Popover
The anchored surface a click opens: a menu, a picker, a small form. The root element is the popover — there is no wrapper box of its own — so the class plus the data attributes paint the same surface on markup you wrote yourself. Anchoring is the caller's: put the trigger and the surface inside one container, and the component names it. Where the engine has the top layer and anchor positioning, the browser opens it, places it, flips it and dismisses it, and this component only mirrors what happened.
- Stable
- React 19
- Top layer + anchor positioning
- Zero-JS open path
The surface, drawn open. Every example on this page uses the in-flow path so the panel is visible without a click.
Code
<span className="wayra-popover-anchor"><Button popoverTarget="prefs">Account preferences</Button><Popover id="prefs"> <strong>Account preferences</strong> <span>Notifications, billing and the rest.</span></Popover></span><div style="padding-block: 6rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="filled" data-size="md">Account preferences</button> <div class="wayra-popover wayra-overlay" data-open="true"> <div style="display: grid; gap: 0.375rem; min-inline-size: 12rem"> <strong>Account preferences</strong> <span style="opacity: 0.7">Notifications, billing and the rest.</span> </div> </div> </span></div>Two elements and one container. The container is yours — the component never
renders it — and it is what both placement paths position against. The rest is
content: Popover takes children and adds no header, no close button and no
padding of its own beyond the surface’s.
Playground
Section titled “Playground”Change the props and copy the code. This one is controlled — React owns open
— which is the other half of the API from the example above.
onOpenChange: closed
Generated code
<span className="wayra-popover-anchor">
<Button popoverTarget="prefs">Account preferences</Button>
<Popover id="prefs">
Notifications, billing and the rest.
</Popover>
</span>The root is the popover
Section titled “The root is the popover”There is no wrapper. The element you get back is the surface: a <div>
carrying the popover attribute where the engine supports it, class,
data-*, your ref and every prop you spread.
<div class="wayra-popover wayra-overlay" popover="auto" data-open="true">…</div>wayra-overlay is a co-class, not a component class. The surface — the
opaque fill, the hairline border, the capped height, the entrance — is written
once in styles/families/overlay.css and worn by every popup in
the library: this one, the ComboBox listbox, the
Select picker. .wayra-popover adds only the three
axes that file has no opinion about — which side, which edge, and whether the
surface takes the anchor’s width.
That split is why unstyled drops both classes at once and leaves the
popover attribute and every data-* behind: the platform wiring is not paint.
Anchoring, and why the ident is per instance
Section titled “Anchoring, and why the ident is per instance”The container carries the anchor name; the surface reads it back. The component
writes it as an inherited custom property, straight onto the container it finds
with closest:
.wayra-popover-anchor { anchor-name: var(--_wayra-overlay-anchor, none); position: relative; display: inline-block;}<span class="wayra-popover-anchor" style="--_wayra-overlay-anchor: --my-panel">The ident is built from useId, one per instance, and that is not tidiness. An
anchor query resolves to the last element in the document carrying the name, not
to an ancestor. A shared literal would send every popover on the page to
whichever container came last. Hand-written markup sets the property itself, and
two containers sharing one name behave exactly that way.
Because the container is found by class and not by parent, an element is allowed
to sit between it and the surface. Dropdown is that
container for a menu: its root wears wayra-popover-anchor as a co-class of
its own.
Placement and alignment
Section titled “Placement and alignment”Code
<Popover>…</Popover><Popover align="center">…</Popover><Popover align="end">…</Popover><Popover placement="block-start">…</Popover><div style="padding-block: 4.5rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">block-end · start</button> <div class="wayra-popover wayra-overlay" data-open="true">Under, from the start edge.</div> </span></div><div style="padding-block: 4.5rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">block-end · center</button> <div class="wayra-popover wayra-overlay" data-open="true" data-align="center">Centred.</div> </span></div><div style="padding-block: 4.5rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">block-end · end</button> <div class="wayra-popover wayra-overlay" data-open="true" data-align="end">Under, from the end edge.</div> </span></div><div style="padding-block: 4.5rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">block-start · start</button> <div class="wayra-popover wayra-overlay" data-open="true" data-placement="block-start">Above.</div> </span></div>placement picks the side of the anchor, align the edge it lines up with on
the other axis. Both are logical, so the whole set mirrors under dir="rtl"
with no second rule, and center has no side to mirror. On the top-layer path
the browser centres the box on the anchor itself (justify-self: anchor-center); on the in-flow path it is left: 50% plus a -50% translate,
a physical pair on purpose, because centring is the same in either direction.
The entrance grows out of the corner the surface shares with its anchor: the
aligned edge, on the anchor’s side — the top-start corner for the defaults, the
bottom edge’s middle for block-start with center. transform-origin has no
logical keywords, so the start and end corners swap under dir="rtl" from a
[dir] rule that works below Chrome 120 too. A flip by position-try keeps
the corner it was given: an anchored container query could follow the flip,
but it styles only what is inside the surface, not the surface itself.
Both attributes are always written, defaults included: data-placement is
block-end, block-start or cover, data-align is start, center or end. The
stylesheet has no rule for either default, so hand-written markup may leave
them off and paints the same surface.
On the top-layer path the browser corrects the choice when there is no room:
position-try-fallbacks: flip-block, flip-inline puts the surface on the other
side rather than off the screen, and a centred surface next to a viewport edge
slides back inside it. That works because the browser does the centring: a
translate painted afterwards would be invisible to the fallbacks. The in-flow
path has no equivalent — it is a fallback, not a positioning engine.
cover — over the anchor, not beside it
Section titled “cover — over the anchor, not beside it”placement="cover" lays the surface on the anchor’s own box: the same inset and
the same size on both axes, so whatever the surface holds is centred on the
trigger. It has no side, so it neither flips nor rises from one — the entrance
is the fade and the scale alone, from the centre — and align has nothing
left to decide. Fab’s flower opens its menu this way and
fans the actions out from there.
Hugging its content, then never falling under the trigger.
Code
<Popover>Short.</Popover><Popover matchAnchorWidth>Short.</Popover><div style="padding-block: 3rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">A fairly wide trigger</button> <div class="wayra-popover wayra-overlay" data-open="true">Short.</div> </span></div><div style="padding-block: 3rem"> <span class="wayra-popover-anchor"> <button class="wayra-button" data-variant="outline" data-size="sm">A fairly wide trigger</button> <div class="wayra-popover wayra-overlay" data-open="true" data-match-anchor-width="true">Short.</div> </span></div>matchAnchorWidth is the library’s rule that a menu is as wide as the control it
belongs to — a minimum, never a cap. It is off here on purpose: a popover
holds whatever you put in it, and a form should not be as narrow as its button.
Dropdown turns it on, because a menu is exactly the
case the rule was written for.
The shared file sets min-inline-size: anchor-size(inline) for every overlay;
popover.css undoes it to auto and re-adds it behind the flag, rather than
repeating the value.
The zero-JavaScript path
Section titled “The zero-JavaScript path”Left uncontrolled, with a trigger carrying popovertarget, there is no
JavaScript in the open path at all. The browser opens the surface, closes it
on Escape, closes it on a click outside, and keeps one auto popover open at a
time. onOpenChange only reports what already happened, through the toggle
event.
onOpenChange: closed
Code
import { useState } from "react";import { Button, Popover } from "wayra-ui";
export function NativeDemo() { const [state, setState] = useState("closed");
return ( // No click handler and no `open` prop: `popoverTarget` is the whole // opener, the browser owns light dismiss and Escape, and `onOpenChange` // only reports what already happened. <div className="hu-demo-col"> <span className="wayra-popover-anchor"> <Button popoverTarget="docs-native-popover">Account preferences</Button> <Popover id="docs-native-popover" onOpenChange={(open) => { setState(open ? "open" : "closed"); }} > Click outside, or press Escape. Neither close went through React. </Popover> </span> <p className="hu-readout">onOpenChange: {state}</p> </div> );}The same thing with no React in it whatsoever — the button is an invoker, the surface is an element, and the ident is one inline custom property:
<span class="wayra-popover-anchor" style="--_wayra-overlay-anchor: --prefs"> <button class="wayra-button" data-variant="filled" popovertarget="prefs"> Account preferences </button> <div id="prefs" popover="auto" class="wayra-popover wayra-overlay"> Notifications, billing and the rest. </div></span>Two paths, gated in JavaScript
Section titled “Two paths, gated in JavaScript”position: absolute is not a fallback for an element that is already in the
top layer: there the containing block is the viewport, so an offset meant to sit
under a trigger resolves a viewport height below the top of the page. So the two
features are probed together and an engine that has one without the other takes
the in-flow path whole.
| Probe | Why |
|---|---|
showPopover on HTMLElement.prototype |
The only honest probe everywhere — jsdom answers true to every CSS.supports question. |
CSS.supports("anchor-name: --x") |
Without it the surface has nothing to position against. |
CSS.supports("position-try-fallbacks: flip-block") |
Without it there is no correction when the surface runs out of room. |
The gate starts false on the server and on the first client render, so
hydration matches. The upgrade lands in a layout effect, before the first
paint, so a surface that mounts open (defaultOpen, or open on the first
render) enters once, straight in the top layer. Only server-rendered HTML
paints in flow, until it hydrates. Visibility is keyed on :popover-open in CSS
and never on a JavaScript flag, so opening later paints no frame in normal flow
either.
The fallback path is position: absolute inside the anchor container, z-index
from one knob, and display: none until something opens it — which for this
component is data-open. The usual caveat applies there and only there: an
ancestor with overflow: hidden can clip the surface.
On the top-layer path data-open is still written, for consumer CSS, but it
follows the element, not the prop: it is read off :popover-open whenever
the toggle event reports a move, so it is never left on a surface the
platform did not open — one whose beforetoggle a listener cancelled, say.
Being an event, it lands a task after the change; a rule that must not lag a
frame, such as a display on a [popover], keys on :popover-open itself.
There is no light dismiss on that path either, so the component supplies the
pointer half of it: a press that starts and ends outside the anchor
container closes the surface, through onOpenChange(false) like any other
close. The edge is the container, not the surface, because the trigger lives
in it and closes the surface with its own click — one more reason the trigger
belongs inside it. A press that starts inside and is released outside (a text
selection dragged out) keeps it open, and so does scrolling, as in the top
layer. Escape is not part of it: a bare Popover on this path does not close
on Escape, while Dropdown and the pickers close on
their own.
Flat, not glass
Section titled “Flat, not glass”The surface is opaque and flat. What tells it apart from the page behind it is its own hairline border — never translucency or a backdrop blur, and, since 2026-08-29, no shadow either. All three were withdrawn in design reviews and none is coming back as a prop.
border: var(--wayra-border) solid var(--_wayra-overlay-border-color);background: var(--wayra-color-base-100);Every number is a private --_wayra-overlay-* value, shared with every other popup, so
a menu and a picker are never two different heights of paper. Three are worth
knowing:
--_wayra-overlay-radiusis concentric: the host control’s own corner plus the surface’s padding. A panel with no control falls back to the box radius.- The height cap is
min(18rem, 40vh). Content past it turns the surface into a scrolling region withoverscroll-behavior: containand a hairline scrollbar — so give it something focusable, or set amax-block-sizeof your own on the surface’sclassName: unlayered CSS always beats the library’s layer. --_wayra-overlay-transitionanimatesopacity,translateandscaleonly, withdisplayandoverlayalong for the discrete ride so the exit plays without a timer.@starting-stylesupplies the entrance. The exit runs on the entrance’s curve played backwards, so the surface leaves along the path it came in by.
The browser’s own [popover] sheet leaves three things behind that no design
system wants — a 3px currentColor border, color: CanvasText on the element,
and width: fit-content. All three are undone in the shared file, in every
engine.
Controlled and uncontrolled
Section titled “Controlled and uncontrolled”const [open, setOpen] = useState(false);
<span className="wayra-popover-anchor"> <Button onClick={() => setOpen(!open)}>Account preferences</Button> <Popover open={open} onOpenChange={setOpen}>…</Popover></span>open makes React the owner; defaultOpen seeds the uncontrolled state once.
Either way pair it with onOpenChange. Light dismiss, the browser’s own
Escape and a popovertarget invoker all move the surface without asking React
first, and that callback is the only thing keeping the two in step. The owner
answers it synchronously; if a controlled open stays where it was, the
prop wins and the surface is put straight back — a light dismiss is reported
and then undone, never left hiding behind a data-open="true". A surface that
should not light-dismiss at all wants popover="manual" instead.
Internally the effect reconciles against the DOM rather than replaying its own
intent: it checks :popover-open before calling showPopover or hidePopover,
because calling either twice throws InvalidStateError and light dismiss can
already have moved it.
Turning light dismiss off
Section titled “Turning light dismiss off”<Popover popover="manual" open={open} onOpenChange={setOpen}>…</Popover>popover is auto by default: a press outside and Escape close the surface,
the browser’s in the top layer and the component’s own press outside below it.
manual turns both off in both tiers, for a surface whose owner closes it
itself — on blur, on its own Escape — such as a picker whose input sits outside
the panel, where a click in the input would otherwise count as outside. The
attribute is the prop’s to write: it is taken out before the spread, so a raw
popover can never contradict it, and below the tier nothing is written at
all.
What’s not included
Section titled “What’s not included”It is not a dialog, and never mixes with one
Section titled “It is not a dialog, and never mixes with one”A popover gives no focus trap, no inert around it, no scrim and no focus
restoration — which is what a modal is made of. If the answer is required
before anything else can happen, that is a <dialog>, and
Modal and Drawer are built on one.
The two mechanisms are never combined on the same element.
No portal
Section titled “No portal”The surface stays where you put it in the React tree, which is what keeps a
[data-theme] on an ancestor resolving. A document.body portal is the classic
way to break token inheritance; the top layer makes it unnecessary.
No arrow, no submenus, no inline placement
Section titled “No arrow, no submenus, no inline placement”All three are reserved. An arrow needs a second anchored box and a story for every flip; a submenu needs its own surface, its own open timing and a second keyboard axis.
API reference
Section titled “API reference”PopoverProps extends ComponentProps<"div"> with its own popover in place
of the native one, so id, role, style, ref, className, every aria-*
and every data-* land on the surface itself. The spread runs before the component’s own attributes, so a
colliding data-align loses to the prop.
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
— | The contents of the surface. |
placement |
"block-end" | "block-start" | "cover" |
"block-end" |
Which side of the anchor it opens on, or cover to sit over the anchor itself. Travels as data-placement. |
align |
"start" | "center" | "end" |
"start" |
Which edge it lines up with on the other axis; center splits the difference and, in the top layer, stays inside the viewport. Travels as data-align. |
matchAnchorWidth |
boolean |
false |
Grow the surface to at least the anchor’s width. |
popover |
"auto" | "manual" |
"auto" |
manual turns light dismiss off in both tiers; the owner closes the surface. See Turning light dismiss off. |
open |
boolean |
— | Controlled open state. Pair it with onOpenChange; a value the owner keeps after the callback wins. |
defaultOpen |
boolean |
false |
Open state on the first render, uncontrolled. |
onOpenChange |
(open: boolean) => void |
— | Fires with the new state, including the closes React did not start. |
unstyled |
boolean |
false |
Drops wayra-popover and wayra-overlay; keeps popover and every data-*. |
The styling contract is the class pair plus the attributes —
.wayra-popover.wayra-overlay[data-align="end"] — and every static snippet on
this page is hand-written HTML using it, with no React involved.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Enter / Space on the invoker |
Opens the surface. Platform behaviour when popovertarget is used; yours when open is. |
Tab / Shift+Tab |
Walks into and through the surface in DOM order. The popover sits next to its trigger in the tree, so the order reads the way it looks. |
Escape |
Closes it on the top-layer path: the browser’s close, not a listener of ours; the component hears about it through toggle. Below the tier a bare Popover does not close on Escape, and with popover="manual" neither tier does. |
| Pointer outside | Light dismiss, again the browser’s. Below the tier, the component’s own press outside the anchor container. Scrolling never dismisses it, and popover="manual" turns it off. |
- Nothing is announced by default. The surface is a plain
<div>with no role and no name. Give it the semantics its content deserves —role="menu"is what Dropdown passes through, a small form usually wants a heading andaria-labelledby. - The component moves no focus. Nothing autofocuses on open and nothing is trapped; beyond what the platform does for an invoker, focus is yours to manage. That is the deliberate difference from a modal.
- Escape stops at the innermost open surface. A component that handles it
itself — Dropdown, ComboBox — calls
preventDefaultandstopPropagationon its own element, so a menu inside a popover never takes the popover down with it. A barePopoverhandles nothing and lets the browser’s own close run. - One auto popover at a time.
popover="auto"keeps a single light-dismiss stack, so opening a second closes the first. That is the contract, not a bug — it is also why the placement examples above use the in-flow path. - Reduced motion turns the entrance and the exit into a 160ms fade in place: the lift goes to zero and the scale to one, and a row highlight inside stays instant. The rescue sets variables, never properties, so nothing freezes mid-entrance.
- Windows high contrast repaints the surface with
Canvasand its border withCanvasText, so the one edge it has stays visible.
Do and don’t
Section titled “Do and don’t”Do
A dismissible panel: nothing is lost by clicking away from it, and nothing else on the page is blocked.
Don't
A question that must be answered, in a surface with no focus trap and no scrim — and one stray click dismisses it. That is a <dialog>.
Do
Both classes. wayra-overlay carries the surface, wayra-popover carries the placement — neither is optional.
Don't
The co-class dropped. No border, no surface, no capped height, no positioning — the panel falls back into normal flow and pushes the page around.