FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Popover

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.

Share this page

Show code
PopoverShare.tsx
// 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.css
/* 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.

Anatomy

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>
  • Root renders no DOM. Trigger and Popup are linked by id through 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.
  • Popup is the <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.
  • No 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.

Two modes

JS-free modeLibrary mode
Selected whenRoot has none of open, defaultOpen, openChangeActionRoot has any of them
Openthe 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 browserthe library: openChangeAction(false) is awaited, then hidePopover() runs in the commit
Close by a dismissal (Escape, click outside, another popover)the browserthe browser; openChangeAction(false) is a notification
Popup contentalways mounted (in the server HTML)rendered from committed open; unmounted while closed unless keepMounted
Trigger aria-expandedkept current on the element from the popup's toggle eventrendered from optimistic state
Trigger data-open, data-pendingnot emittedemitted
Before hydrationeverything worksthe native open happens; the content arrives on hydration
transition on Popupno 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).

Props

Popover.Root

PropTypeDefaultDescription
openboolean–Controlled open state. undefined means uncontrolled. Selects library mode.
defaultOpenboolean–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.
openChangeActionChangeAction<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.
childrenReactNode | ((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

PropTypeDefaultDescription
disabledbooleanfalseNative disabled: a disabled button is not an invoker and never dispatches. Owned, so a render element cannot re-enable it.
classNamestring | ((state: PopoverTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: PopoverTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: PopoverTriggerState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
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.
keepMountedbooleanfalseLibrary 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).
initialFocusRefObject<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).
transitionViewTransitionClass–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.
classNamestring | ((state: PopoverPopupState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: PopoverPopupState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: PopoverPopupState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
actionAction<[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.
disabledbooleanfalseNative disabled; owned, so a render element cannot re-enable it.
classNamestring | ((state: PopoverCloseState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: PopoverCloseState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: PopoverCloseState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: PopoverTitleState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: PopoverTitleState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: PopoverTitleState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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.

Data attributes and pseudo-classes

HookOnMeaning
:popover-openPopupThe popover is shown (DOM truth, both modes).
data-side, data-alignPopupThe requested placement (absent with side={null}).
aria-expandedTriggerOpen state, both modes (optimistic in library mode).
data-openTriggerOptimistic open state (library mode only).
data-pendingTriggerAn open, a part-initiated close or a dismissal notification is in flight (library mode only).
data-pendingCloseIts action is in flight.
data-disabledTrigger (library mode), CloseThe 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).

ViewTransition: an exit in every engine

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; }
}
  • Transition types: fsui-popover plus fsui-popover-open or fsui-popover-close. A controlled close carries none.
  • Dismissals (Escape, a click outside) happen in the browser before React hears about them, so they animate with the CSS exit only. With transition set, drop the CSS enter and keep at most a CSS fade for dismissals.
  • With 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.

Keyboard, focus and ARIA

KeyBehaviourWho
Enter / Space on the TriggerTogglesthe browser (JS-free) or the Trigger
Tab / Shift+TabMoves through the popup; the popup follows the Trigger in focus order. Tab past the end leaves, and the popover stays openthe browser
EscapeCloses 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 popupCloses the Dialog only; a second Escape closes the popoverthe browser
  • Initial focus. With the default <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.
  • Focus return. The platform returns focus to the Trigger for the first popover in a stack. For a nested popover it returns nothing, so the library focuses the inner Trigger when focus was inside the popup and was left on the body. A click outside leaves focus where the click put it.
  • ARIA. The Popup has 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.
  • No focus trap and no lightDismiss or modal prop: light dismiss and Escape are what popover="auto" is. A panel that must survive stray clicks is a Dialog.

Async walkthrough (library mode)

  • Quarterly report
  • Offsite notes
last openChangeAction: (none yet)
Show code
PopoverActions.tsx
"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.css
/* 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;
}
  1. Click ⋯: the click is cancelled (so the native command does not run), the Trigger gets aria-expanded="true", data-open and data-pending, and openChangeAction(true, { reason: "activate" }) runs.
  2. The commit that sets committed open renders the Popup's children; RowActions suspends, so React holds the commit. The popup stays hidden; the Trigger keeps spinning.
  3. The data arrives: the content commits and 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.
  4. Click Archive: the button shows its own pending label while its action runs; then the Close dispatches openChangeAction(false, { reason: "activate" }) and the commit that ends it hides the popup inside a view transition: the exit animates in every engine.
  5. Press Escape or click outside instead: the browser hides the popover at once (CSS fade), and the Root receives openChangeAction(false, { reason: "dismiss" }).

Refusing a close

A controlled parent that keeps open after a part-initiated close vetoes it: nothing was hidden, the Trigger's data-open snaps back. After a dismissal the popover is already hidden; a parent that keeps open gets it re-opened once the action settles, which the user sees as a blink. Adopt false for dismissals.

The platform paths

  • Before hydration, JS-free mode: everything works, including 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.
  • Before hydration, library mode: the Trigger already carries the command, so a click opens the popover natively, but the content is not in the HTML yet (with 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.
  • Foreign invokers and scripts: in library mode any open that the library did not start (a <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".

Recipes

Submit, then close (controlled)

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>

Controlled, opened without a Trigger

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

Nested popovers

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