Skip to content

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
Account preferences Notifications, billing and the rest.

The surface, drawn open. Every example on this page uses the in-flow path so the panel is visible without a click.

Account preferences Notifications, billing and the rest.
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.

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.

Notifications, billing and the rest.

onOpenChange: closed

placement
align
width
Generated code
TSX
<span className="wayra-popover-anchor">
  <Button popoverTarget="prefs">Account preferences</Button>
  <Popover id="prefs">
    Notifications, billing and the rest.
  </Popover>
</span>

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.

Under, from the start edge.
Centred.
Under, from the end edge.
Above.
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.

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.

Short.
Short.

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.

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.

Click outside, or press Escape. Neither close went through React.

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>

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.

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-radius is 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 with overscroll-behavior: contain and a hairline scrollbar — so give it something focusable, or set a max-block-size of your own on the surface’s className: unlayered CSS always beats the library’s layer.
  • --_wayra-overlay-transition animates opacity, translate and scale only, with display and overlay along for the discrete ride so the exit plays without a timer. @starting-style supplies 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.

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.

<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.

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.

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.

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.

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 and aria-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 preventDefault and stopPropagation on its own element, so a menu inside a popover never takes the popover down with it. A bare Popover handles 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 Canvas and its border with CanvasText, so the one edge it has stays visible.

Do

Account preferences Notifications, billing and the rest.

A dismissible panel: nothing is lost by clicking away from it, and nothing else on the page is blocked.

Don't

Delete this project? This cannot be undone.

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

Account preferences Notifications, billing and the rest.

Both classes. wayra-overlay carries the surface, wayra-popover carries the placement — neither is optional.

Don't

Account preferences Notifications, billing and the rest.

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.