Built on popover="auto", a <dialog> element and Invoker Commands. The platform supplies the top layer, light dismiss, Escape, one-at-a-time stacking with nesting, placement next to the trigger, a declarative trigger that works before hydration, and focus return. The library adds the Async React half: opening a popover whose content loads is a transition, so the trigger shows data-pending and the popover opens once its content is ready; and the closes the user asks a part for run through the library too, so <ViewTransition> can animate the exit, in Firefox as well.
// No "use client": this file is a Server Component. The Popover.Root has no `open`, `defaultOpen`
// or `openChangeAction`, so it runs in JS-free mode: the Trigger is a native
// `command="toggle-popover"` invoker and Done is a `command="hide-popover"` button. The browser
// opens the popup in the top layer below the button, focuses the input (the first focusable
// element, through the dialog focusing steps), and closes it on Escape, a click outside or Done,
// before hydration and with JavaScript off. No `autoFocus`: React writes it only into
// server-rendered HTML, not after a client navigation; `initialFocus` covers both.
import { Popover } from "@funstack/ui/popover";
export function PopoverShare() {
return (
<Popover.Root>
<Popover.Trigger className="demo-button">Share</Popover.Trigger>
<Popover.Popup className="demo-popover" align="start">
<Popover.Title>Share this page</Popover.Title>
<input
className="demo-popover-input"
readOnly
value="https://ui.funstack.work/components/popover"
/>
<div className="demo-popover-actions">
<Popover.Close className="demo-button">Done</Popover.Close>
</div>
</Popover.Popup>
</Popover.Root>
);
}/* Popover: the Popup is the <dialog popover> itself. Open state is `:popover-open` on it (exact
in both modes); the Trigger carries `aria-expanded` in both modes and `data-open` /
`data-pending` in library mode only. */
.demo-popover {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
inset: auto;
min-inline-size: 14rem;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
}
/* The gap on the requested side; `data-side` is absent with `side={null}`. */
.demo-popover[data-side="bottom"] {
margin-block-start: 6px;
}
.demo-popover[data-side="top"] {
margin-block-end: 6px;
}
.demo-popover h2 {
margin: 0 0 8px;
padding: 0;
border: 0;
font-size: 1rem;
}
/* Enter and exit with CSS, both modes. Enter works everywhere; the exit animates in Chromium
(with `overlay`) and Safari and is instant in Firefox. On iOS, `position-try-fallbacks`
together with `@starting-style` can hang (WebKit bug 317926). */
.demo-popover {
opacity: 0;
translate: 0 -4px;
transition:
opacity 120ms,
translate 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-popover:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
.demo-popover:popover-open {
opacity: 0;
translate: 0 -4px;
}
}
/* With `transition`, the view transition animates library-driven opens and closes (Firefox
included); keep only a CSS fade for dismissals and drop the CSS enter. */
.demo-popover-vt,
.demo-popover-vt:popover-open {
translate: 0 0;
}
@starting-style {
.demo-popover-vt:popover-open {
opacity: 1;
}
}
::view-transition-new(.pop) {
animation: 160ms ease-out both demo-pop-in;
}
::view-transition-old(.pop) {
animation: 120ms ease-in both demo-pop-out;
}
@keyframes demo-pop-in {
from {
opacity: 0;
scale: 0.96;
}
}
@keyframes demo-pop-out {
to {
opacity: 0;
scale: 0.96;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-popover {
transition: none;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Dim the popup while a Close action runs (the Close button carries data-pending). */
.demo-popover:popover-open:has([data-pending]) {
opacity: 0.8;
}
.demo-popover-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.demo-popover-input {
box-sizing: border-box;
inline-size: 100%;
padding: 0.35em 0.6em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
/* The Trigger: `aria-expanded` in both modes, pending in library mode. */
.demo-button[aria-expanded="true"] {
border-color: var(--color-primary);
color: var(--color-primary);
}
.demo-button[data-pending] {
cursor: progress;
}
ul.demo-list {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
ul.demo-list .demo-row {
justify-content: space-between;
max-inline-size: 20rem;
padding-block: 4px;
}
ul.demo-list .demo-row[data-archived] > span {
color: var(--color-text-secondary);
text-decoration: line-through;
}This share popover is a Server Component in JS-free mode. The Trigger and Done are native invokers, so it works before hydration and with JavaScript disabled; the platform's dialog focusing steps put focus on the input, the first focusable element.
import { Popover } from "@funstack/ui/popover";
<Popover.Root> {/* no DOM: ids, mode, state */}
<Popover.Trigger>Filters</Popover.Trigger> {/* button[commandfor][command=toggle-popover] */}
<Popover.Popup side="bottom" align="start"> {/* dialog[popover=auto][aria-modal=false] */}
<Popover.Title>Filters</Popover.Title> {/* h2, wired to aria-labelledby */}
<Popover.Description>…</Popover.Description> {/* p, wired to aria-describedby */}
<FilterForm />
<Popover.Close>Done</Popover.Close> {/* button[commandfor][command=hide-popover] */}
</Popover.Popup>
</Popover.Root>commandfor. Render the Popup right after the Trigger where the layout allows: Safari 27.0 does not put a far-away popover next in the Tab order after its trigger.<dialog popover> itself. There is no Portal, Positioner, Arrow or Backdrop part: the top layer and CSS Anchor Positioning make them unnecessary, and a portalled popover would be inert inside a modal Dialog. A Popover nested inside a Popup is a nested popover; a Dialog opened from a Popup keeps it open.Form part: a popover-shown <dialog> ignores <form method="dialog">. "Submit, then close" is a <form action> plus a controlled open (recipe below), or a Popover.Close action.render: Popup renders a <dialog> (default) or a <div>; Trigger and Close render a <button> for the command path, and another element works only through the library's click handler (library mode, or a Close with action). Wrong elements are development warnings.| JS-free mode | Library mode | |
|---|---|---|
| Selected when | Root has none of open, defaultOpen, openChangeAction | Root has any of them |
| Open | the browser runs command="toggle-popover" | the click is intercepted and dispatched in the Root's transition; showPopover({ source: trigger }) runs in the commit that mounts the content. Any other open (a foreign invoker, a script) is cancelled at beforetoggle and routed the same way |
| Close by a part (Trigger, Close) | the browser | the library: openChangeAction(false) is awaited, then hidePopover() runs in the commit |
| Close by a dismissal (Escape, click outside, another popover) | the browser | the browser; openChangeAction(false) is a notification |
| Popup content | always mounted (in the server HTML) | rendered from committed open; unmounted while closed unless keepMounted |
Trigger aria-expanded | kept current on the element from the popup's toggle event | rendered from optimistic state |
Trigger data-open, data-pending | not emitted | emitted |
| Before hydration | everything works | the native open happens; the content arrives on hydration |
transition on Popup | no effect (development warning) | animates opens, content swaps and every library-driven close |
In library mode the line is drawn between parts and dismissals. A close the user asks a part for is library-owned: it waits for your action, a controlled parent can refuse it, and it can animate with a view transition. Escape and a click outside are the browser's: the popover goes away at once and the Root hears about it afterwards. See Browser-owned state. The mode is decided on the first render; switching later is not supported (development warning).
Popover.Root| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | – | Controlled open state. undefined means uncontrolled. Selects library mode. |
defaultOpen | boolean | – | Initial open state in uncontrolled library mode. A popover that starts open is shown after hydration (there is no attribute for an open popover in HTML); its content and the Trigger's open state (aria-expanded="true", data-open) are already in the server HTML. defaultOpen={false} is the usual way to select library mode without an action. |
openChangeAction | ChangeAction<boolean, PopoverOpenChangeDetails> | – | Awaited for true (the popover opens when it resolves and the content has rendered) and for a false the user asked a part for (Trigger, Close: the popover hides when it resolves). A notification for a dismissal: the popover is already hidden when it runs. Runs in the Root's transition; may be a Server Function; rejections reach the nearest Error Boundary above the Root. details.reason: "activate" (Trigger, Close, a foreign invoker), "dismiss" (Escape, a click outside, script, another popover or modal dialog) or "platform" (found open on hydration; event is undefined). Selects library mode. |
children | ReactNode | ((state: PopoverRootState) => ReactNode) | – | The parts. The function form receives the optimistic state and must not suspend. |
PopoverRootState: { open, pending }, where open is optimistic and both are false in JS-free mode. The Root has no ref, className or style, and no disabled (the Trigger has it).
Popover.Trigger| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Native disabled: a disabled button is not an invoker and never dispatches. Owned, so a render element cannot re-enable it. |
className | string | ((state: PopoverTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: PopoverTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: PopoverTriggerState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: PopoverTriggerState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
PopoverTriggerState: { open, pending, disabled }. pending is true while the Root is pending: an open, a part-initiated close or a dismissal notification. A click while open is a library-owned close; a second click during a pending open cancels it (the latest intent wins). The Trigger owns type, disabled, commandfor, command, aria-expanded and, in library mode, onClick. aria-haspopup and aria-controls pass through.
Popover.Popup| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | null | "bottom" | Placement relative to the Trigger through inline position-area, with position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline flipping it when it does not fit. null emits no inline placement and no data-side / data-align: your stylesheet owns placement. |
align | "start" | "center" | "end" | "center" | Alignment along the side. Ignored when side is null. |
keepMounted | boolean | false | Library mode. false: children are not rendered while closed, so content that suspends holds the open until it is ready. true: children stay in <Activity mode="hidden"> (state kept, effects stopped, pre-rendered at low priority; not in the server HTML). Unmounting waits for the popover's CSS exit animation, or happens in the close commit when transition is set. Ignored in JS-free mode (always mounted). |
initialFocus | RefObject<HTMLElement | null> | string | – | The element to focus on open: a ref, or a selector resolved inside the popup. The library writes autofocus on it right before showPopover() and lets the platform focus it. Default: the platform's choice for a <dialog> (the first focusable element, else the popup); with render={<div />} focus stays on the Trigger. In JS-free mode the attribute is written after mount; prefer it to autoFocus, which React writes only into server-rendered HTML (not when the Popup renders on the client, for example after a client navigation to a Server Component page). |
transition | ViewTransitionClass | – | Library mode only. Forwarded to a <ViewTransition> around the popover element itself: animates the opening commit, content swaps, and every library-driven close (Trigger, Close, a controlled open={false}) in all engines. Dismissals are the browser's and animate with CSS. Default: no boundary. Keep it static; any value makes a Root inserted by a transition run the page cross-fade. |
className | string | ((state: PopoverPopupState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: PopoverPopupState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: PopoverPopupState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: PopoverPopupState) => ReactNode) | – | Replace the default <dialog> (element form) or take over rendering (function form). |
PopoverPopupState: { open, side, align }, with open committed (false in JS-free mode; :popover-open is the DOM truth). The Popup owns id, popover ("auto"), open (never rendered), aria-modal ("false": Chromium exposes a <dialog popover> as modal otherwise) and data-side / data-align; its aria-labelledby and aria-describedby are merged with yours. role="dialog" is a default you can override ("region" or "group" for a non-interactive card). onToggle and onBeforeToggle are not accepted; use a ref for the platform events. Your style wins over the inline placement.
Popover.Close| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[details: ActionDetails]> | – | Runs in this button's own transition (data-pending on it), then closes the popover: in library mode through the Root (openChangeAction(false) awaited, view transition if set), in JS-free mode with hidePopover(). A rejection reaches the nearest Error Boundary above the button and leaves the popover open. Without action in JS-free mode the button is a native command="hide-popover" invoker. |
disabled | boolean | false | Native disabled; owned, so a render element cannot re-enable it. |
className | string | ((state: PopoverCloseState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: PopoverCloseState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: PopoverCloseState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: PopoverCloseState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
PopoverCloseState: { pending, disabled }. In library mode the click is always intercepted, so every Close goes through the Root. Repeated clicks while pending re-run the action; the first close wins. If the user dismisses the popover and opens it again while the action runs, the action's close is dropped: it belonged to the earlier open. Popovers have no return value.
Popover.Title, Popover.Description| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: PopoverTitleState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: PopoverTitleState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: PopoverTitleState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: PopoverTitleState) => ReactNode) | – | Replace the default <h2> (Title) or <p> (Description). render={<h3 />} changes the heading level. |
Both own their id. Their state is the Popup's (PopoverTitleState and PopoverDescriptionState are PopoverPopupState). A dialog-role Popup with neither a Title nor an aria-label is a development warning once shown.
| Hook | On | Meaning |
|---|---|---|
:popover-open | Popup | The popover is shown (DOM truth, both modes). |
data-side, data-align | Popup | The requested placement (absent with side={null}). |
aria-expanded | Trigger | Open state, both modes (optimistic in library mode). |
data-open | Trigger | Optimistic open state (library mode only). |
data-pending | Trigger | An open, a part-initiated close or a dismissal notification is in flight (library mode only). |
data-pending | Close | Its action is in flight. |
data-disabled | Trigger (library mode), Close | The disabled prop. |
The Popup emits no data-open: :popover-open is exact. Enter and exit animate in CSS, in both modes:
.popover {
margin: 0;
inset: auto; /* the popover UA stylesheet sets inset: 0 */
opacity: 0;
translate: 0 -4px;
transition: opacity 120ms, translate 120ms, display 120ms allow-discrete, overlay 120ms allow-discrete;
}
.popover:popover-open { opacity: 1; translate: 0 0; }
@starting-style { .popover:popover-open { opacity: 0; translate: 0 -4px; } }
.popover[data-side="bottom"] { margin-block-start: 6px; }
@media (prefers-reduced-motion: reduce) { .popover { transition: none; } }The exit animates in Chromium and Safari; Firefox hides a popover instantly. The library keeps the content mounted until the exit animation ends, so a fading popover is never empty. On iOS, position-try-fallbacks together with @starting-style can hang (WebKit bug 317926).
With transition, the Popup renders <ViewTransition> around the popover element itself. A library-driven open shows the popover in the layout effect of its commit and a library-driven close hides it in the layout effect of its commit, so the view transition captures the popup's box as ::view-transition-new on open and ::view-transition-old (with its content) on close. That is the only way a popover's exit can animate in Firefox.
<Popover.Popup transition={{ "fsui-popover-open": "pop-in", "fsui-popover-close": "pop-out", default: "none" }}>::view-transition-new(.pop-in) { animation: 160ms ease-out both pop-in; }
::view-transition-old(.pop-out) { animation: 120ms ease-in both pop-out; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}fsui-popover plus fsui-popover-open or fsui-popover-close. A controlled close carries none.transition set, drop the CSS enter and keep at most a CSS fade for dismissals.transition="none", a plain open starts no document view transition: the content mounts under the already-mounted popover element. A Popover Root inserted by a transition with transition set on its Popup, or a nested Popup with transition that mounts with the outer content, runs the page cross-fade (issue #35). The page receives no pointer events while a view transition runs: keep them short.| Key | Behaviour | Who |
|---|---|---|
| Enter / Space on the Trigger | Toggles | the browser (JS-free) or the Trigger |
| Tab / Shift+Tab | Moves through the popup; the popup follows the Trigger in focus order. Tab past the end leaves, and the popover stays open | the browser |
| Escape | Closes the innermost open popover; focus returns to its Trigger; openChangeAction(false, { reason: "dismiss" }) | the browser (and the library for a nested popover) |
| Escape in a modal Dialog opened from the popup | Closes the Dialog only; a second Escape closes the popover | the browser |
<dialog>, opening runs the dialog focusing steps: the initialFocus target, else the first focusable element, else the popup itself (the APG non-modal dialog), in every engine. For a disclosure-style card that keeps focus on the Trigger, render a <div>: render={<div />} with role="region" and an aria-label.role="dialog" and aria-modal="false" explicitly (WebKit implements no minimum role for popovers; Chromium would expose a <dialog popover> as modal). The Trigger has aria-expanded in both modes and no aria-haspopup (the APG disclosure shape); add aria-haspopup="dialog" or aria-controls yourself if you want them.lightDismiss or modal prop: light dismiss and Escape are what popover="auto" is. A panel that must survive stray clicks is a Dialog."use client";
import { startTransition, use, useState } from "react";
import { Popover } from "@funstack/ui/popover";
import type { PopoverOpenChangeDetails } from "@funstack/ui/popover";
import { sleep } from "./fake-api.js";
// Cache the promises so `use()` sees the same one on every render.
const cache = new Map<string, Promise<readonly string[]>>();
function loadPermissions(id: string): Promise<readonly string[]> {
let promise = cache.get(id);
if (promise === undefined) {
promise = sleep(1000).then(() => (id === "notes" ? ["Rename"] : ["Rename", "Archive"]));
cache.set(id, promise);
}
return promise;
}
function RowActions({ id, archive }: { id: string; archive: () => Promise<void> }) {
const permissions = use(loadPermissions(id));
return (
<div className="demo-popover-actions">
<Popover.Close className="demo-button" action={() => sleep(400)}>
{({ pending }) => (pending ? "Renaming…" : "Rename")}
</Popover.Close>
{permissions.includes("Archive") && (
<Popover.Close className="demo-button demo-danger" action={archive}>
{({ pending }) => (pending ? "Archiving…" : "Archive")}
</Popover.Close>
)}
</div>
);
}
// Library mode: `defaultOpen={false}` (or any action prop) hands the open to the library. The
// Popup's children render from committed state, so <RowActions> mounts inside the opening
// transition and suspends: the popup stays hidden and the Trigger carries data-pending until the
// permissions are loaded, then it opens once, complete. A Close with an `action` runs it with its
// own pending state, then closes through the Root: the commit hides the popup, and
// `transition="pop"` animates that exit with a view transition, Firefox included. Escape and
// clicks outside are the browser's (reason "dismiss") and use the CSS fade.
export function PopoverActions() {
const [archived, setArchived] = useState<readonly string[]>([]);
const [last, setLast] = useState("(none yet)");
const record = (open: boolean, { reason }: PopoverOpenChangeDetails) => {
// The synchronous part of an action: this setState is a transition update.
setLast(`open=${open} reason="${reason}"`);
};
const rows = [
{ id: "report", name: "Quarterly report" },
{ id: "notes", name: "Offsite notes" },
];
return (
<>
<ul className="demo-list">
{rows.map(({ id, name }) => (
<li key={id} className="demo-row" data-archived={archived.includes(id) ? "" : undefined}>
<span>{name}</span>
<Popover.Root defaultOpen={false} openChangeAction={record}>
<Popover.Trigger className="demo-button" aria-label={`Actions for ${name}`}>
{({ pending }) => (pending ? <span className="demo-spinner" aria-hidden /> : "⋯")}
</Popover.Trigger>
<Popover.Popup className="demo-popover demo-popover-vt" align="end" transition="pop">
<Popover.Title>{name}</Popover.Title>
<RowActions
id={id}
archive={async () => {
await sleep(800);
// After an await: wrap the update in startTransition (React limitation).
startTransition(() => setArchived((ids) => [...ids, id]));
}}
/>
</Popover.Popup>
</Popover.Root>
</li>
))}
</ul>
<div className="demo-row">
<button type="button" className="demo-button" onClick={() => cache.clear()}>
Clear the cache
</button>
</div>
<output className="demo-readout">
last openChangeAction: <code>{last}</code>
</output>
</>
);
}/* Popover: the Popup is the <dialog popover> itself. Open state is `:popover-open` on it (exact
in both modes); the Trigger carries `aria-expanded` in both modes and `data-open` /
`data-pending` in library mode only. */
.demo-popover {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
inset: auto;
min-inline-size: 14rem;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
}
/* The gap on the requested side; `data-side` is absent with `side={null}`. */
.demo-popover[data-side="bottom"] {
margin-block-start: 6px;
}
.demo-popover[data-side="top"] {
margin-block-end: 6px;
}
.demo-popover h2 {
margin: 0 0 8px;
padding: 0;
border: 0;
font-size: 1rem;
}
/* Enter and exit with CSS, both modes. Enter works everywhere; the exit animates in Chromium
(with `overlay`) and Safari and is instant in Firefox. On iOS, `position-try-fallbacks`
together with `@starting-style` can hang (WebKit bug 317926). */
.demo-popover {
opacity: 0;
translate: 0 -4px;
transition:
opacity 120ms,
translate 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-popover:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
.demo-popover:popover-open {
opacity: 0;
translate: 0 -4px;
}
}
/* With `transition`, the view transition animates library-driven opens and closes (Firefox
included); keep only a CSS fade for dismissals and drop the CSS enter. */
.demo-popover-vt,
.demo-popover-vt:popover-open {
translate: 0 0;
}
@starting-style {
.demo-popover-vt:popover-open {
opacity: 1;
}
}
::view-transition-new(.pop) {
animation: 160ms ease-out both demo-pop-in;
}
::view-transition-old(.pop) {
animation: 120ms ease-in both demo-pop-out;
}
@keyframes demo-pop-in {
from {
opacity: 0;
scale: 0.96;
}
}
@keyframes demo-pop-out {
to {
opacity: 0;
scale: 0.96;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-popover {
transition: none;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Dim the popup while a Close action runs (the Close button carries data-pending). */
.demo-popover:popover-open:has([data-pending]) {
opacity: 0.8;
}
.demo-popover-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.demo-popover-input {
box-sizing: border-box;
inline-size: 100%;
padding: 0.35em 0.6em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
/* The Trigger: `aria-expanded` in both modes, pending in library mode. */
.demo-button[aria-expanded="true"] {
border-color: var(--color-primary);
color: var(--color-primary);
}
.demo-button[data-pending] {
cursor: progress;
}
ul.demo-list {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
ul.demo-list .demo-row {
justify-content: space-between;
max-inline-size: 20rem;
padding-block: 4px;
}
ul.demo-list .demo-row[data-archived] > span {
color: var(--color-text-secondary);
text-decoration: line-through;
}aria-expanded="true", data-open and data-pending, and openChangeAction(true, { reason: "activate" }) runs.open renders the Popup's children; RowActions suspends, so React holds the commit. The popup stays hidden; the Trigger keeps spinning.showPopover({ source: trigger }) runs in the same commit, so the popup is anchored below the Trigger and the platform focuses fully rendered content. With transition="pop" it animates in.openChangeAction(false, { reason: "activate" }) and the commit that ends it hides the popup inside a view transition: the exit animates in every engine.openChangeAction(false, { reason: "dismiss" }).autoFocus in server-rendered markup. After hydration, and for content React renders on the client, use initialFocus: React does not write autofocus on client-rendered elements.defaultOpen it is, and the Trigger already says it is expanded; only the showing waits for hydration). On hydration the Popup finds it open and dispatches openChangeAction(true, { reason: "platform" }) with no event; the content mounts into the open popover. If the action rejects or a controlled parent refuses, the popover closes again.<button commandfor command="show-popover">, a script showPopover()) is cancelled at beforetoggle and dispatched with that ToggleEvent. A foreign hide-popover closes it natively and is reported as "activate".const [open, setOpen] = useState(false);
<Popover.Root open={open} openChangeAction={(next) => { setOpen(next); }}>
<Popover.Trigger>Rename</Popover.Trigger>
<Popover.Popup initialFocus="[name=title]">
<Popover.Title>Rename</Popover.Title>
<form
action={async (formData) => {
await rename(formData);
startTransition(() => setOpen(false)); // a library-driven close: view transition if set
}}
>
<input name="title" defaultValue={title} />
<SubmitButton>Save</SubmitButton>
<Popover.Close>Cancel</Popover.Close>
</form>
</Popover.Popup>
</Popover.Root><Popover.Root open={open} openChangeAction={(next) => { setOpen(next); }}>
<Popover.Popup side={null} className="popover centered">…</Popover.Popup>
</Popover.Root>Without a Trigger there is no anchor: the popover is centred by the UA stylesheet unless you position it, so use side={null}.
<Popover.Root defaultOpen={false}>
<Popover.Trigger>Share</Popover.Trigger>
<Popover.Popup>
<Popover.Title>Share</Popover.Title>
<Popover.Root defaultOpen={false}> {/* nested auto popover */}
<Popover.Trigger>Permissions</Popover.Trigger>
<Popover.Popup side="right" align="start">…</Popover.Popup>
</Popover.Root>
</Popover.Popup>
</Popover.Root>Opening the inner keeps the outer open; Escape closes the inner and focus returns to "Permissions"; a click outside closes both. Outer parts must sit outside the inner Root. Leave transition off an inner Popup unless you want the animation (it pays the page cross-fade when it mounts with the outer content). In Safari, a nested popover with position-try-fallbacks can hang (WebKit bug 302008): give the inner Popup side={null} and place it in CSS if you hit it.