diff --git a/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json b/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json
new file mode 100644
index 0000000000000..b95ceff2239ea
--- /dev/null
+++ b/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json
@@ -0,0 +1,7 @@
+{
+ "type": "minor",
+ "comment": "feat: add headless Popover'",
+ "packageName": "@fluentui/react-headless-components-preview",
+ "email": "vgenaev@gmail.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md
new file mode 100644
index 0000000000000..e3a863157240a
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md
@@ -0,0 +1,288 @@
+# Popover — Headless Spec
+
+## Overview
+
+Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface can elevate into the browser's **top layer** via the native HTML Popover API, or render inline in DOM order when `inline={true}`. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop.
+
+Popover manages dismissal (click-outside, scroll-outside, Escape, iframe blur), opt-in hover/context interaction, and keeps `data-placement` in sync with the browser's post-flip decision so consumer CSS can style flipped placements. Focus trapping is deferred to a later iteration — the surface is currently a non-modal `role="group"`.
+
+## Composition
+
+```
+Popover
+├── PopoverTrigger (optional — clones a single child, wires events)
+└── PopoverSurface
+ ├── [arrow] (optional — rendered when withArrow={true})
+ └── children / content
+```
+
+Popover is a compound component. `PopoverTrigger` is optional — a surface with no trigger can be opened via `defaultOpen`, a controlled `open` prop, or imperatively via `positioning.target` / `positioning.positioningRef.setTarget`.
+
+`PopoverTrigger` takes **exactly one child** and clones it to attach click/keydown/hover/context-menu handlers plus a merged ref.
+
+## Props API
+
+### `Popover`
+
+| Prop | Type | Default | Description |
+| -------------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. |
+| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. |
+| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). |
+| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). |
+| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. |
+| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. |
+| `closeOnScroll` | `boolean` | `false` | Close when the user scrolls anywhere outside the trigger + surface. |
+| `closeOnIframeFocus` | `boolean` | `true` | Close when focus moves into an external iframe. Internal iframes (inside the surface) don't dismiss. |
+| `disableAutoFocus` | `boolean` | `false` | Reserved for the upcoming focus-management iteration. Currently inert — the surface no longer auto-focuses on open. |
+| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. |
+| `inline` | `boolean` | `false` | Render the surface in DOM order (no top-layer elevation, no `popover="manual"`). |
+| `mountNode` | `HTMLElement \| null` | `null` | Optional portal target for the surface. When omitted, the surface renders in place (top layer if not `inline`). |
+| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). |
+
+### `PopoverTrigger`
+
+| Prop | Type | Default | Description |
+| -------------------------- | -------------- | ------- | -------------------------------------------------------------------------------------------------- |
+| `children` | `ReactElement` | — | Exactly one child element. Cloned with merged handlers and ref. |
+| `disableButtonEnhancement` | `boolean` | `false` | Skip `useARIAButtonProps` enhancement. Use when the child is already a fully-featured ARIA button. |
+
+### `PopoverSurface`
+
+| Prop | Type | Default | Description |
+| ---------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
+| `tabIndex` | `number` | — | Forwarded to the rendered `
` so the surface can be focusable when the consumer needs it (e.g. `tabIndex={-1}`). |
+| `children` | `ReactNode` | — | Surface content. |
+
+## States
+
+| State | Trigger | Behaviour | ARIA |
+| ------------------ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
+| **Closed** | Initial, or after dismissal | Surface unmounted. Trigger has `aria-expanded="false"`, no `data-open`. | `aria-expanded="false"` on trigger. |
+| **Open** | `open={true}` / click / keyboard activation / hover / right-click (depending on props) | Surface mounted. In non-`inline` mode it's promoted into the top layer via `showPopover()` (feature-detected). `data-placement` reflects the requested placement; `usePlacementObserver` overwrites it with the resolved placement. | `aria-expanded="true"`, `data-open` on trigger; `role="group"`, `data-open` on surface. |
+| **Hover-held** | `openOnHover` and pointer inside trigger or surface | Popover stays open while pointer is inside either element; closes `mouseLeaveDelay` ms after it leaves both. | Same as Open. |
+| **Context-pinned** | `openOnContext` + right-click | `onOpenChange(e, { type: 'contextmenu', open: true })` with the mouse event; `contextTarget` state stores `{ x, y }`. Click and keyboard activation on the trigger do nothing. | Same as Open. |
+| **Dismissing** | Click-outside / Escape inside surface / scroll-outside (if `closeOnScroll`) / iframe-focus move | `onOpenChange(e, { open: false, type })` fires with the originating DOM event. Consumer decides to close by updating state or letting uncontrolled state flip. | `aria-expanded` returns to `"false"` on trigger. |
+| **Nested** | Popover rendered inside another Popover's surface | Each instance manages its own Escape / click-outside. Escape filters via `e.target.closest('[data-popover-surface]') === ownSurface` — no `stopPropagation`, no cross-popover coupling. | Each surface keeps its own `role="group"`. |
+
+## Keyboard Navigation
+
+### On trigger
+
+| Key | Action |
+| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| **Enter / Space** | Toggle open state. (Provided by `useARIAButtonProps` when the child is not already a button/link; disabled when `openOnContext={true}`.) |
+| **Escape** | If the surface is open, close it. (Handled on trigger + inside surface — see below.) |
+| **Context-menu key / Shift+F10** | Fires the native `contextmenu` event. When `openOnContext={true}`, opens the popover. |
+
+### Inside surface
+
+| Key | Action |
+| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Tab** | Default browser tab order. The surface does **not** trap focus in this iteration; Tab can move focus out of the surface. |
+| **Shift + Tab** | Default browser reverse tab order. |
+| **Escape** | Dismiss the current popover. Filtered to the nearest enclosing surface, so Escape in a nested popover only closes that popover — not its ancestors. |
+| **Enter / Space** | Default button activation inside the surface. |
+
+## Events
+
+| Event | Signature | When it fires |
+| -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `onOpenChange` | `(e: SyntheticEvent \| DOMEvent, data: { event; type; open: boolean }) => void` | Whenever the popover wants to open or close (trigger click, trigger keyboard activation, hover, context menu, Escape, click-outside, scroll-outside, iframe focus move). |
+
+There is no separate `onDismiss`. The `open: false` dispatches go through `onOpenChange` with a `type` that identifies the source (`'click'`, `'keydown'`, `'mouseleave'`, `'contextmenu'`, `'scroll'`, …).
+
+## Accessibility
+
+### ARIA pattern
+
+```tsx
+// Trigger
+
+
+// Surface (inline mode)
+
+
+// Surface (top layer mode — non-inline, default)
+
+```
+
+### Role selection
+
+The surface always renders as **`role="group"`** in this iteration — a non-modal anchored container suitable for menus, cards, and informational overlays. Modal `role="dialog"` (with `aria-modal="true"`, `aria-haspopup="dialog"` on the trigger, and a focus trap) is planned for a follow-up iteration that re-introduces a focus-management hook. Consumers needing modal semantics today should reach for the `Dialog` headless component instead.
+
+### Focus management
+
+This iteration ships **no built-in focus management**:
+
+- **No auto-focus on open.** The browser handles focus naturally. Top-layer popovers (`popover="manual"`) leave focus on the trigger; consumers can call `.focus()` on the surface or a descendant if needed.
+- **No focus trap.** Tab / Shift+Tab follow the document's normal tab order. With a top-layer surface, focus may move to elements behind the surface — that's expected for a non-modal popover.
+- **Focus restore on Escape only.** When Escape is pressed inside the surface, focus moves back to the trigger (native `popover="manual"` does not restore focus, so the surface's Escape handler does it explicitly). All other dismissal paths (click-outside, scroll-outside, programmatic close) leave focus wherever the interaction left it.
+- **`disableAutoFocus`** is preserved on `PopoverProps` for API stability but is currently inert. It will become meaningful again together with the upcoming focus hook.
+
+### Labeling
+
+- Labels on a `role="group"` surface are optional but recommended when the surface's purpose isn't obvious from nearby context — e.g., `aria-label` or `aria-labelledby` pointing to a heading inside the surface.
+- The trigger's accessible name remains the child element's own name (the component does nothing that would clobber it).
+
+### Live regions
+
+The headless Popover does **not** add `aria-live` to the surface. Consumers rendering dynamic content (loading states, async messages) inside the surface should wrap the dynamic region in their own live region.
+
+## Positioning
+
+Placement is handled entirely by the `usePositioning` hook, which writes native CSS anchor-positioning properties onto the surface element. No JS layout loop.
+
+### Options (all optional)
+
+<<<<<<< HEAD
+| Option | Type | Default | Effect |
+| ------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. |
+| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). |
+| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. |
+| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. |
+| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. |
+| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. |
+| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. |
+| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. |
+| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. |
+| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. |
+||||||| parent of 4f1eba10dc (docs(react-headless-components-preview): align Popover spec with iteration-1 strip-down)
+| Option | Type | Default | Effect |
+| ------------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. |
+| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). |
+| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. |
+| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. |
+| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. |
+| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. |
+| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. |
+| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. |
+| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. |
+| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. |
+| `autoSize` | `boolean \| 'width' \| 'height'` | `false` | Cap the surface dimensions against `overflowBoundary`. Requires `overflowBoundary` — pure-CSS autoSize isn't possible due to spec-level restrictions on `anchor()` in `max-*`. |
+| `overflowBoundary` | `HTMLElement \| RefObject` | — | Element the surface must stay inside. Drives a JS-measured `transform: translate3d()` **cross-axis shift** whenever the primary placement would exceed the boundary. Accepts a DOM element or a React ref to one. When paired with `autoSize`, the same rect is used to compute `max-*` caps. `overflowBoundaryPadding` adds breathing room on top of the shift. |
+| `overflowBoundaryPadding` | `number \| { top, end, bottom, start }` | — | Breathing room kept between the surface and the `overflowBoundary` rect. Implemented as a JS-measured `transform: translate3d()` **cross-axis shift** on the surface (only — main-axis overflow is native flip's job, matching v9 / Floating UI's `shift()` middleware). Does not affect the surface's size. Accepts a uniform number or a logical-side object (RTL-aware). Has no effect when `overflowBoundary` is unset or when `coverTarget` is on. |
+=======
+| Option | Type | Default | Effect |
+| ------------------- | ----------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. |
+| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). |
+| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. |
+| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. |
+| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. |
+| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. |
+| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. |
+| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. |
+| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. |
+| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. |
+| `overflowBoundary` | `HTMLElement \| RefObject` | — | Element the surface must stay inside. Implemented as a JS-measured **clamp**: `useBoundaryClamp` writes logical `max-inline-size` / `max-block-size` on the surface so its far edge cannot extend past the boundary's opposite edge. The surface's start edge stays where CSS anchor positioning placed it; only the far edge is clamped, so content reflows inside the clamped box rather than the surface sliding. |
+
+> > > > > > > 4f1eba10dc (docs(react-headless-components-preview): align Popover spec with iteration-1 strip-down)
+
+### Rendering
+
+- The hook writes `anchor-name: --popover-anchor-` on the anchor (trigger or custom target) via `useIsomorphicLayoutEffect`.
+- On the surface it writes `position: absolute` (or `fixed` if `strategy: 'fixed'`); `inset: auto; margin: 0; position-anchor: --popover-anchor-; position-area: ; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`. The `inset: auto; margin: 0` reset is required because the UA popover stylesheet sets `inset: 0; margin: auto`, which fights `position-area`.
+- For center alignment, the hook also writes `place-self: anchor-center` as a workaround for https://crbug.com/438334710 (Chromium <=130 doesn't reliably apply the implicit anchor-center self-alignment to single-keyword `position-area` values).
+- `data-placement` is set to the requested placement and then live-updated by `usePlacementObserver` (ResizeObserver + scroll listener) to reflect the browser's post-flip decision.
+
+### Arrow
+
+Arrow positioning is **consumer-owned CSS** keyed off `[data-placement]`. The hook doesn't manipulate the arrow element. Consumers writing arrow styles typically target `[data-placement^='above']`, `[data-placement^='below']`, etc., and use anchor queries (`@container anchored()`) for flip-aware styling when supported.
+
+## Dismissal
+
+All dismissal paths are React-side (not the UA `popover="auto"` light-dismiss), so every `open: false` transition carries the originating event:
+
+| Source | Mechanism |
+| ----------------------- | ----------------------------------------------------------------------------------------------------------- |
+| Click outside | `useOnClickOutside` across `triggerRef` + `contentRef`, optionally gated on `closeOnIframeFocus` behaviour. |
+| Escape inside surface | `onKeyDown` on the surface filters to `e.target.closest('[data-popover-surface]') === contentRef.current`. |
+| Scroll outside (opt-in) | `useOnScrollOutside` with `closeOnScroll` or `openOnContext`. |
+| Iframe focus (external) | Same mechanism as click-outside; `closeOnIframeFocus` gates the "iframe focus" case. |
+| Programmatic | Consumer sets `open={false}` or the uncontrolled state flips via toggleOpen. |
+
+`openOnHover` close fires only after `mouseLeaveDelay` ms of pointer being outside both trigger and surface.
+
+## Controlled vs uncontrolled
+
+### Uncontrolled
+
+```tsx
+
+
+
+
+ …content…
+
+```
+
+### Controlled
+
+```tsx
+const [open, setOpen] = React.useState(false);
+
+ setOpen(data.open)} positioning={{ position: 'below', align: 'start' }}>
+
+
+
+ …content…
+;
+```
+
+### Without a trigger
+
+```tsx
+const triggerRef = React.useRef(null);
+
+<>
+
+
+ …
+
+>;
+```
+
+## RTL support
+
+Positioning uses CSS _logical_ properties throughout (`block-start`, `block-end`, `inline-start`, `inline-end`, `margin-inline-start`, …), so placement semantics flip correctly in RTL:
+
+- `position: 'before'` anchors on the inline-start side — left in LTR, right in RTL.
+- `align: 'start'` anchors at the writing-mode start — top in horizontal-tb, left in vertical-rl.
+- Physical v9 aliases (`top` / `bottom` / `left` / `right`) are normalized at the shorthand boundary and become logical internally.
+
+## Native API surface
+
+The package relies on three native browser APIs:
+
+- **CSS Anchor Positioning** (Chromium 125+) — `anchor-name`, `position-anchor`, `position-area`, `anchor-size()`, `position-try-fallbacks`.
+- **HTML Popover API** (Chromium 114+) — `popover="manual"` + `showPopover()` for top-layer elevation. Feature-detected (`typeof el.showPopover === 'function'`); SSR-safe; `inline={true}` opts out entirely.
+- **ResizeObserver** — used sparingly by `usePlacementObserver` (for live `data-placement`)
+
+Firefox and Safari are implementing CSS Anchor Positioning; most features work but flip behaviour is still WIP. `inline={true}` works in every engine.
+
+## Notes
+
+- **Top layer vs inline**: by default the surface is promoted to the top layer via `showPopover()`, which escapes `overflow: hidden` and `z-index` stacking contexts. Set `inline={true}` for scenarios where the surface must stay within a containing block (containerized demos, `contain: layout` ancestors).
+- **Nested popovers**: each popover runs its own Escape / click-outside handlers. Escape in a nested surface closes only that surface.
+- **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface (the surface has its own `mouseenter`/`mouseleave` handlers). `mouseLeaveDelay` protects against accidental close during pointer transitions.
+- **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger.
+- **Positioning is CSS, not JS**: because placement computation is pushed to the browser, there's no JS layout loop and `positioning.updatePosition()` is a no-op. Consumers that need imperative retargeting use `positioning.setTarget(el)`.
diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md
new file mode 100644
index 0000000000000..ca7306b308baa
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md
@@ -0,0 +1,154 @@
+## API Report File for "@fluentui/react-headless-components-preview"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { ComponentProps } from '@fluentui/react-utilities';
+import type { ComponentState } from '@fluentui/react-utilities';
+import type { ContextSelector } from '@fluentui/react-context-selector';
+import type { EventData } from '@fluentui/react-utilities';
+import type { EventHandler } from '@fluentui/react-utilities';
+import type { ForwardRefComponent } from '@fluentui/react-utilities';
+import type { JSXElement } from '@fluentui/react-utilities';
+import * as React_2 from 'react';
+import type { Slot } from '@fluentui/react-utilities';
+
+// @public
+export type OnOpenChangeData = EventData & {
+ open: boolean;
+};
+
+// @public
+export type OpenPopoverEvents = MouseEvent | TouchEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent;
+
+// @public
+export const Popover: {
+ (props: PopoverProps): JSXElement;
+ displayName: string;
+};
+
+// @public
+export const PopoverAuto: {
+ (props: PopoverProps): JSXElement;
+ displayName: string;
+};
+
+// @public
+export type PopoverContextValue = Pick & {
+ positioning: {
+ targetRef: React_2.RefCallback;
+ containerRef: React_2.RefCallback;
+ };
+};
+
+// @public
+export type PopoverProps = {
+ children: [JSXElement, JSXElement] | JSXElement;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: EventHandler;
+ openOnHover?: boolean;
+ openOnContext?: boolean;
+ mouseLeaveDelay?: number;
+ positioning?: PositioningShorthand;
+ withArrow?: boolean;
+ disableAutoFocus?: boolean;
+ closeOnScroll?: boolean;
+ closeOnIframeFocus?: boolean;
+ inline?: boolean;
+ mountNode?: HTMLElement | null;
+};
+
+// @public
+export type PopoverState = Required> & Pick & {
+ setOpen: (e: OpenPopoverEvents, open: boolean) => void;
+ toggleOpen: (e: OpenPopoverEvents) => void;
+ triggerRef: React_2.RefObject;
+ contentRef: React_2.RefObject;
+ arrowRef: React_2.RefObject;
+ popoverTrigger: React_2.ReactElement | undefined;
+ popoverSurface: React_2.ReactElement | undefined;
+ contextTarget: {
+ x: number;
+ y: number;
+ } | undefined;
+ setContextTarget: (target: {
+ x: number;
+ y: number;
+ } | undefined) => void;
+ positioning: PositioningReturn;
+ popoverType: PopoverType;
+};
+
+// @public
+export const PopoverSurface: ForwardRefComponent;
+
+// @public (undocumented)
+export type PopoverSurfaceProps = ComponentProps;
+
+// @public
+export type PopoverSurfaceSlots = {
+ root: Slot<'div'>;
+};
+
+// @public (undocumented)
+export type PopoverSurfaceState = ComponentState & {
+ inline: boolean;
+ withArrow: boolean | undefined;
+ arrowRef: React_2.RefObject;
+ mountNode: HTMLElement | null | undefined;
+ 'data-open': string;
+};
+
+// @public
+export const PopoverTrigger: React_2.FC;
+
+// @public
+export type PopoverTriggerProps = {
+ children: React_2.ReactElement;
+ disableButtonEnhancement?: boolean;
+};
+
+// @public
+export type PopoverTriggerState = {
+ children: React_2.ReactElement | null;
+};
+
+// @public
+export type PopoverType = 'manual' | 'auto';
+
+// @public
+export const renderPopover: (state: PopoverState, contextValues: {
+ popover: PopoverContextValue;
+}) => React_2.ReactElement;
+
+// @public
+export const renderPopoverSurface: (state: PopoverSurfaceState) => JSXElement;
+
+// @public
+export const renderPopoverTrigger: (state: PopoverTriggerState) => JSXElement | null;
+
+// @public
+export const usePopover: (props: PopoverProps) => PopoverState;
+
+// @public (undocumented)
+export const usePopoverAuto: (props: PopoverProps) => PopoverState;
+
+// @public
+export const usePopoverContext: (selector: ContextSelector) => T;
+
+// @public (undocumented)
+export const usePopoverContextValues: (state: PopoverState) => {
+ popover: PopoverContextValue;
+};
+
+// @public
+export const usePopoverSurface: (props: PopoverSurfaceProps, ref: React_2.Ref) => PopoverSurfaceState;
+
+// @public
+export const usePopoverTrigger: (props: PopoverTriggerProps) => PopoverTriggerState;
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md
new file mode 100644
index 0000000000000..e2a3f51c5edb9
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md
@@ -0,0 +1,76 @@
+## API Report File for "@fluentui/react-headless-components-preview"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type * as React_2 from 'react';
+
+// @public (undocumented)
+export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center';
+
+// @public (undocumented)
+export const ALIGNMENTS: {
+ readonly start: "start";
+ readonly center: "center";
+ readonly end: "end";
+};
+
+// @public
+export function getPlacementString(position: Position, align: LogicalAlignment): string;
+
+// @public (undocumented)
+export type Position = 'above' | 'below' | 'before' | 'after';
+
+// @public
+export type PositioningImperativeRef = {
+ setTarget: (target: HTMLElement | null) => void;
+ updatePosition: () => void;
+};
+
+// @public (undocumented)
+export type PositioningProps = {
+ position?: Position;
+ align?: Alignment;
+ offset?: number | {
+ mainAxis?: number;
+ crossAxis?: number;
+ };
+ fallbackPositions?: PositioningShorthandValue[];
+ coverTarget?: boolean;
+ target?: HTMLElement | React_2.RefObject | null;
+ strategy?: 'absolute' | 'fixed';
+ matchTargetSize?: 'width';
+ pinned?: boolean;
+ positioningRef?: React_2.Ref;
+};
+
+// @public (undocumented)
+export type PositioningReturn = {
+ targetRef: React_2.RefCallback;
+ containerRef: React_2.RefCallback;
+};
+
+// @public (undocumented)
+export type PositioningShorthand = PositioningProps | PositioningShorthandValue;
+
+// @public (undocumented)
+export type PositioningShorthandValue = 'above' | 'above-start' | 'above-end' | 'below' | 'below-start' | 'below-end' | 'before' | 'before-start' | 'before-end' | 'after' | 'after-start' | 'after-end';
+
+// @public (undocumented)
+export const POSITIONS: {
+ readonly above: "above";
+ readonly below: "below";
+ readonly before: "before";
+ readonly after: "after";
+};
+
+// @public
+export function resolvePositioningShorthand(value: PositioningShorthand | undefined): PositioningProps;
+
+// @public (undocumented)
+export function usePositioning(options: PositioningProps): PositioningReturn;
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json
index f9ca4be78888c..06dff721d7e46 100644
--- a/packages/react-components/react-headless-components-preview/library/package.json
+++ b/packages/react-components/react-headless-components-preview/library/package.json
@@ -19,6 +19,8 @@
"license": "MIT",
"dependencies": {
"@fluentui/react-accordion": "^9.11.0",
+ "@fluentui/react-aria": "^9.17.10",
+ "@fluentui/keyboard-keys": "^9.0.8",
"@fluentui/react-avatar": "^9.11.1",
"@fluentui/react-badge": "^9.5.2",
"@fluentui/react-button": "^9.9.1",
@@ -41,6 +43,7 @@
"@fluentui/react-search": "^9.4.2",
"@fluentui/react-select": "^9.5.1",
"@fluentui/react-shared-contexts": "^9.26.2",
+ "@fluentui/react-context-selector": "^9.2.15",
"@fluentui/react-skeleton": "^9.7.2",
"@fluentui/react-slider": "^9.6.2",
"@fluentui/react-spinbutton": "^9.6.2",
@@ -132,6 +135,18 @@
"import": "./lib/message-bar.js",
"require": "./lib-commonjs/message-bar.js"
},
+ "./popover": {
+ "types": "./dist/popover.d.ts",
+ "node": "./lib-commonjs/popover.js",
+ "import": "./lib/popover.js",
+ "require": "./lib-commonjs/popover.js"
+ },
+ "./positioning": {
+ "types": "./dist/positioning.d.ts",
+ "node": "./lib-commonjs/positioning.js",
+ "import": "./lib/positioning.js",
+ "require": "./lib-commonjs/positioning.js"
+ },
"./progress-bar": {
"types": "./dist/progress-bar.d.ts",
"node": "./lib-commonjs/progress-bar.js",
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx
new file mode 100644
index 0000000000000..546a8ce5aeb96
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx
@@ -0,0 +1,580 @@
+import * as React from 'react';
+import { mount as mountBase } from '@fluentui/scripts-cypress';
+import { Popover } from './Popover';
+import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger';
+import { PopoverSurface } from './PopoverSurface/PopoverSurface';
+import type { PopoverProps } from './Popover.types';
+import type { JSXElement } from '@fluentui/react-utilities';
+
+const mount = (element: JSXElement) => {
+ mountBase(element);
+};
+
+const popoverTriggerSelector = '[aria-expanded]';
+const popoverContentSelector = '[role="group"]';
+
+describe('Popover', () => {
+ ['uncontrolled', 'controlled'].forEach(scenario => {
+ const UncontrolledExample = () => (
+
+
+
+
+ This is a popover
+
+ );
+
+ const ControlledExample = () => {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+ setOpen(data.open)}>
+
+
+
+ This is a popover
+
+ );
+ };
+
+ describe(scenario, () => {
+ const Example = scenario === 'controlled' ? ControlledExample : UncontrolledExample;
+
+ beforeEach(() => {
+ mount();
+ cy.get('body').click('bottomRight');
+ });
+
+ it('should open when clicked', () => {
+ cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible');
+ });
+
+ (['{enter}', 'Space'] as const).forEach((key: '{enter}' | 'Space') => {
+ it(`should open with ${key}`, () => {
+ cy.get(popoverTriggerSelector).focus().realPress(key);
+ cy.get(popoverContentSelector).should('be.visible');
+ });
+ });
+
+ it('should dismiss on click outside', () => {
+ cy.get(popoverTriggerSelector)
+ .click()
+ .get('body')
+ .click('bottomRight')
+ .get(popoverContentSelector)
+ .should('not.exist');
+ });
+
+ it('should dismiss on Escape keydown', () => {
+ cy.get(popoverTriggerSelector).click().realPress('Escape');
+ cy.get(popoverContentSelector).should('not.exist');
+ });
+
+ it('should keep open state on scroll outside', () => {
+ cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible');
+ cy.get('body').trigger('wheel').get(popoverContentSelector).should('be.visible');
+ });
+ });
+ });
+
+ describe('ARIA attributes', () => {
+ it('should set aria-expanded="false" on closed trigger', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).should('have.attr', 'aria-expanded', 'false');
+ });
+
+ it('should set aria-expanded="true" when open', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).click().should('have.attr', 'aria-expanded', 'true');
+ });
+
+ it('should set aria-haspopup="true" by default', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).should('have.attr', 'aria-haspopup', 'true');
+ });
+
+ it('should set role="group" on surface by default', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).click();
+ cy.get(popoverContentSelector).should('exist');
+ });
+ });
+
+ describe('data-* attributes', () => {
+ it('should set data-open on trigger when open', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).should('not.have.attr', 'data-open');
+ cy.get(popoverTriggerSelector).click().should('have.attr', 'data-open');
+ });
+
+ it('should set data-open on surface', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).click();
+ cy.get(popoverContentSelector).should('have.attr', 'data-open');
+ });
+
+ it('should set popover="manual" on surface', () => {
+ mount(
+
+
+
+
+ Content
+ ,
+ );
+
+ cy.get(popoverTriggerSelector).click();
+ cy.get(popoverContentSelector).should('have.attr', 'popover', 'manual');
+ });
+ });
+
+ describe('Open on hover', () => {
+ beforeEach(() => {
+ mount(
+
+
+
+
+ This is a popover
+ ,
+ );
+ cy.get('body').click('bottomRight');
+ });
+
+ it('should open on hover, and keep open on mouse move to content', () => {
+ cy.get(popoverTriggerSelector).trigger('mouseover').get(popoverContentSelector).should('be.visible');
+ cy.get(popoverContentSelector).trigger('mouseover').get(popoverContentSelector).should('be.visible');
+ });
+ });
+
+ describe('With custom trigger', () => {
+ const CustomTrigger = React.forwardRef((props, ref) => {
+ return (
+
+ );
+ });
+
+ it('should dismiss on click outside', () => {
+ mount(
+
+
+
+
+ This is a popover
+ ,
+ );
+ cy.get(popoverTriggerSelector).get('body').click('bottomRight').get(popoverContentSelector).should('not.exist');
+ });
+ });
+
+ describe('Context popover', () => {
+ beforeEach(() => {
+ mount(
+
+
+
+
+ This is a popover
+ ,
+ );
+ cy.get('body').click('bottomRight');
+ });
+
+ it('should open when right clicked', () => {
+ cy.get(popoverTriggerSelector).rightclick().get(popoverContentSelector).should('be.visible');
+ });
+
+ it('should dismiss on scroll outside', () => {
+ cy.get(popoverTriggerSelector)
+ .rightclick()
+ .get('body')
+ .trigger('wheel')
+ .get(popoverContentSelector)
+ .should('not.exist');
+ });
+ });
+
+ describe('popover with closeOnScroll', () => {
+ beforeEach(() => {
+ mount(
+
+
+
+
+ This is a popover
+ ,
+ );
+ cy.get('body').click('bottomRight');
+ });
+
+ it('should dismiss on scroll outside', () => {
+ cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible');
+ cy.get('body').trigger('wheel').get(popoverContentSelector).should('not.exist');
+ });
+ });
+
+ describe('Nested', () => {
+ const PopoverL1 = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ const PopoverL2 = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+ };
+
+ const Example = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ beforeEach(() => {
+ mount();
+ cy.contains('Root').click().get('body').contains('First').click().get('body').contains('Second').first().click();
+ });
+
+ it('should trap focus with tab', () => {
+ cy.focused().then(beforeFocused => {
+ cy.focused().realPress('Tab');
+ cy.realPress(['Shift', 'Tab']);
+ cy.focused().then(afterFocused => {
+ expect(beforeFocused[0]).eq(afterFocused[0]);
+ });
+ });
+ });
+
+ it('should trap focus with shift+tab', () => {
+ cy.focused().then(beforeFocused => {
+ cy.focused().realPress('Tab');
+ cy.realPress(['Shift', 'Tab']);
+ cy.focused().then(afterFocused => {
+ expect(beforeFocused[0]).eq(afterFocused[0]);
+ });
+ });
+ });
+
+ it('should dismiss all nested popovers on outside click', () => {
+ cy.get('body').click('bottomRight').get(popoverContentSelector).should('not.exist');
+ });
+
+ it('should not dismiss when clicking on nested content', () => {
+ cy.contains('Second nested button').click().get(popoverContentSelector).should('have.length', 3);
+ });
+
+ it('should dismiss child popovers when clicking on parents', () => {
+ // Native top-layer popovers stack visually, so deeper popovers cover
+ // ancestor surface buttons. `{ force: true }` bypasses Cypress's
+ // obscurement check — we're asserting dismissal behavior, not
+ // spatial layout.
+ cy.contains('First nested button')
+ .click({ force: true })
+ .get(popoverContentSelector)
+ .should('have.length', 2)
+ .contains('Root button')
+ .click({ force: true })
+ .get(popoverContentSelector)
+ .should('have.length', 1);
+ });
+
+ it('should when opening a sibling popover, should dismiss other sibling popover', () => {
+ const secondNestedTriggerSelector = 'button:contains(Second nested trigger)';
+
+ // The first sibling's popover is in the top layer and can cover the
+ // other sibling's trigger depending on viewport size. `{ force: true }`
+ // bypasses Cypress's obscurement check — the test asserts dismissal
+ // behavior, not spatial layout.
+ cy.get(secondNestedTriggerSelector)
+ .eq(1)
+ .click({ force: true })
+ .get(popoverContentSelector)
+ .should('have.length', 3)
+ .get(secondNestedTriggerSelector)
+ .eq(0)
+ .click({ force: true })
+ .get(popoverContentSelector)
+ .should('have.length', 3);
+ });
+
+ it('should dismiss each popover in the stack with Escape keydown', () => {
+ cy.focused().realPress('Escape');
+ cy.get(popoverContentSelector).should('have.length', 2);
+ cy.focused().realPress('Escape');
+ cy.get(popoverContentSelector).should('have.length', 1);
+ cy.focused().realPress('Escape');
+ cy.get(popoverContentSelector).should('not.exist');
+ });
+ });
+
+ describe('updating content', () => {
+ const Example = () => {
+ const [visible, setVisible] = React.useState(false);
+
+ const changeContent = () => setVisible(true);
+ const onOpenChange: PopoverProps['onOpenChange'] = (e, data) => {
+ if (data.open === false) {
+ setVisible(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {visible ? (
+
+ Trigger pinned to top-left. Primary above overflows, first fallback before also
+ overflows (no room to the left), so the browser falls through to below. The live{' '}
+ Actual readout should read below.
+
+ Same overflow condition, different chains. Left popover has no fallbackPositions → default{' '}
+ flip-block, flip-inline fires → surface ends up below. Right popover passes ['after']{' '}
+ → custom chain replaces defaults → surface goes to the right instead of flipping.
+
+);
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFallbackPositionsDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFallbackPositionsDescription.md
new file mode 100644
index 0000000000000..3b98950f7436a
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFallbackPositionsDescription.md
@@ -0,0 +1,5 @@
+`fallbackPositions` supplies a custom fallback chain for `position-try-fallbacks` — the browser tries each placement in order when the primary placement would overflow. Each entry is a shorthand string (e.g. `'below-start'`, `'after'`) that maps to an inline `` value in the final CSS.
+
+When `fallbackPositions` is omitted, the default chain is the native `flip-block, flip-inline` tactics. When it's supplied, the custom chain **replaces** the default — it does not compose with flip tactics. The browser walks the list in order and picks the first entry that fits.
+
+The three sub-demos below cover: (1) the basic chain + single fallback picked up, (2) chain walking when the first fallback also overflows, and (3) how a custom chain opts out of the default flip tactics entirely.
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlock.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlock.stories.tsx
new file mode 100644
index 0000000000000..c6810fc719d9c
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlock.stories.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+import { demoBoxClass, demoBoxStyle, flipDemoSurfaceCss } from './demoBox';
+
+import descriptionMd from './PositioningFlippingBlockDescription.md';
+
+const classes = {
+ page: 'flex flex-col gap-4 p-4',
+ grid: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
+ trigger:
+ 'px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
+ surface: 'flip-demo bg-white rounded-md shadow-md border border-gray-200 p-2 w-[160px] text-xs',
+};
+
+export const FlippingBlock = (): React.ReactNode => (
+
+
+
+
+
+
+
+
+
+
+ Requested: above → flips below
+
+
+
+
+
+
+
+
+
+
+ Requested: below → flips above
+
+
+
+
+
+);
+
+FlippingBlock.parameters = {
+ docs: {
+ description: {
+ story: descriptionMd,
+ },
+ },
+};
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlockDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlockDescription.md
new file mode 100644
index 0000000000000..1d220afd4b0ce
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingBlockDescription.md
@@ -0,0 +1,3 @@
+`flip-block` — the browser swaps the block axis (`above` ↔ `below`) when the requested side would overflow. Each demo lives in a dashed, `contain: layout` box so the overflow check happens relative to the box.
+
+Both triggers overflow their requested side → native `position-try-fallbacks: flip-block` swaps the block axis. Compare the **Requested** label with the live **Actual** readout rendered from `[data-placement]`.
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingCorner.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingCorner.stories.tsx
new file mode 100644
index 0000000000000..f9e6be3252ad0
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningFlippingCorner.stories.tsx
@@ -0,0 +1,81 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+import { demoBoxClass, demoBoxStyle, flipDemoSurfaceCss } from './demoBox';
+
+import descriptionMd from './PositioningFlippingCornerDescription.md';
+
+const classes = {
+ page: 'flex flex-col gap-4 p-4',
+ grid: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
+ trigger:
+ 'px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
+ surface: 'bg-white rounded-md shadow-md border border-gray-200 p-3 w-[320px] max-h-[140px] text-sm',
+};
+
+export const FlippingCorner = (): React.ReactNode => (
+
+ Clicking Open popover toggles this surface, but positioning.target makes it anchor
+ to the purple Anchor button instead of the trigger. It appears to the right of the anchor
+ regardless of where the trigger sits.
+
+
+
+
+
+
+ );
+};
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md
new file mode 100644
index 0000000000000..799e0ee6976cb
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md
@@ -0,0 +1,12 @@
+## Best practices
+
+### Do
+
+- Create nested `Popover`s as separate components.
+- If there are no interactive items in the `Popover` content, set `tabIndex={-1}` on the `PopoverSurface`.
+- Use `Popover` to reduce screen clutter and host non-essential information.
+
+### Don't
+
+- Don't use more than 2 levels of nested `Popover`s.
+- Don't use `Popover`s to display too much content; consider if that content belongs on the main page.
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx
new file mode 100644
index 0000000000000..65d8fac2b65ee
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverControlled.stories.tsx
@@ -0,0 +1,32 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+
+const classes = {
+ trigger:
+ 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
+ surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs',
+ checkbox: 'flex items-center gap-2 mb-4 text-sm text-gray-700',
+};
+
+export const Controlled = (): React.ReactNode => {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+ setOpen(data.open)}>
+
+
+
+
+
+ This popover is controlled externally. Toggle the checkbox above or click the trigger to open and close it.
+
+ Native elements and Fluent components have first-class support as children of PopoverTrigger. To
+ use your own component, forward its ref with React.forwardRef so the popover can wire up the
+ trigger ref and aria attributes.
+
+ This is the content of the popover. Click the trigger again or press Escape to close.
+
+
+
+);
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md
new file mode 100644
index 0000000000000..e0f574a530ffc
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDescription.md
@@ -0,0 +1,3 @@
+A popover displays lightweight content anchored to a trigger element.
+
+Popovers are used for transient UI that appears on user interaction, such as additional information, forms, or menus. They support click, hover, and context-menu triggers, optional focus trapping for dialog-like behavior, and can render with an arrow pointing to the trigger.
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInline.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInline.stories.tsx
new file mode 100644
index 0000000000000..cc0dee05a44f7
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverInline.stories.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+
+const classes = {
+ trigger:
+ 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
+ surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[240px] max-w-xs',
+};
+
+export const Inline = (): React.ReactNode => (
+
+
+
+
+
+
+
Inline rendering
+
+ This popover renders without the native HTML popover top-layer. It is still positioned via CSS
+ Anchor Positioning, but stacks with regular z-index rather than being auto-elevated above siblings.
+
+ Popover content can change while the popover is open. When new focusable content is revealed, move focus to it
+ so keyboard users can continue interacting.
+
+
+
+);
+
+NestedManual.parameters = {
+ docs: {
+ description: {
+ story: descriptionMd,
+ },
+ },
+};
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNestedManualDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNestedManualDescription.md
new file mode 100644
index 0000000000000..6d0f9a5d94c86
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNestedManualDescription.md
@@ -0,0 +1,30 @@
+Three popovers nested as JSX descendants using `Popover` (manual mode). All
+dismiss behaviour runs through React handlers — the browser is just a
+top-layer painter for the surface, not a state authority.
+
+**Behaviour:**
+
+- **Open inner** — `useOnClickOutside` on each Popover treats clicks on its
+ own `triggerRef` and `contentRef` as "inside". Because the inner trigger
+ and surface are JSX descendants of the outer surface, opening the inner
+ doesn't fire the outer's outside-click dismiss. Outers stay open.
+- **Escape** — `PopoverSurface.onKeyDown` filters via
+ `e.target.closest('[data-popover-surface]') === ownSurface`, so the
+ Escape keydown event bubbles through every ancestor surface but only the
+ innermost surface (the closest one) matches and closes. Outers stay open.
+- **Click outside** — every popover's `useOnClickOutside` fires
+ independently; the entire chain closes.
+- **Open an unrelated peer** — manual popovers do **not** auto-dismiss each
+ other. Two unrelated chains can be open at the same time.
+
+## How this differs from `Nested` (auto mode)
+
+`PopoverAuto` defers dismiss to the browser via `popover="auto"`, so Escape,
+click-outside, and the popover-stack peer-dismissal happen at HTML-spec
+timing and focus is restored to the invoker for free. The trade-off is
+that the browser only allows one root chain open at a time — opening an
+unrelated peer dismisses the existing root.
+
+Use `PopoverAuto` when you want native, single-root behaviour. Use
+`Popover` when you need multiple unrelated popovers open simultaneously
+or hover/context-driven open with custom dismiss timing.
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx
new file mode 100644
index 0000000000000..e42b0567b4d8b
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverOpenOnContext.stories.tsx
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+
+const classes = {
+ trigger:
+ 'px-6 py-4 rounded-md bg-gray-100 text-gray-700 font-medium border border-dashed border-gray-400 cursor-context-menu focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2',
+ surface: 'bg-white rounded-lg shadow-lg border border-gray-200 py-2 min-w-[160px]',
+ menuItem:
+ 'block w-full px-4 py-1.5 text-sm text-gray-700 text-left hover:bg-gray-100 cursor-pointer border-none bg-transparent',
+};
+
+export const OpenOnContext = (): React.ReactNode => (
+
+
+
+ This popover opens when you hover over the trigger and closes when the mouse leaves both the trigger and the
+ surface.
+
+
+
+);
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx
new file mode 100644
index 0000000000000..9507b31cd9d93
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverWithArrow.stories.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover';
+
+const classes = {
+ wrapper: 'flex flex-col items-start gap-4 p-16',
+ trigger:
+ 'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
+ surface: [
+ // Base surface look
+ 'bg-white rounded-lg p-4 min-w-[240px] max-w-xs overflow-visible',
+ '[filter:drop-shadow(0_0_1px_rgba(0,0,0,0.12))_drop-shadow(0_4px_8px_rgba(0,0,0,0.14))]',
+ // Arrow base (the rotated square rendered by withArrow)
+ '[&_[data-arrow]]:absolute [&_[data-arrow]]:w-3 [&_[data-arrow]]:h-3 [&_[data-arrow]]:bg-white [&_[data-arrow]]:rotate-45',
+ // Main-axis offset — arrow protrudes from the side that faces the trigger
+ "[&[data-placement^='above']_[data-arrow]]:-bottom-1.5",
+ "[&[data-placement^='below']_[data-arrow]]:-top-1.5",
+ "[&[data-placement^='before']_[data-arrow]]:-right-1.5",
+ "[&[data-placement^='after']_[data-arrow]]:-left-1.5",
+ // Cross-axis centering for the plain (center-aligned) placements
+ "[&[data-placement='above']_[data-arrow]]:inset-x-0 [&[data-placement='above']_[data-arrow]]:mx-auto",
+ "[&[data-placement='below']_[data-arrow]]:inset-x-0 [&[data-placement='below']_[data-arrow]]:mx-auto",
+ "[&[data-placement='before']_[data-arrow]]:inset-y-0 [&[data-placement='before']_[data-arrow]]:my-auto",
+ "[&[data-placement='after']_[data-arrow]]:inset-y-0 [&[data-placement='after']_[data-arrow]]:my-auto",
+ // Start/end-aligned placements — arrow pinned via logical inset, padding from --arrow-padding
+ "[&[data-placement$='-start']_[data-arrow]]:start-[var(--arrow-padding,12px)]",
+ "[&[data-placement$='-end']_[data-arrow]]:end-[var(--arrow-padding,12px)]",
+ ].join(' '),
+};
+
+export const WithArrow = (): React.ReactNode => (
+
+
+
+
+
+
+
Arrow popover
+
+ Arrow orientation follows the data-placement attribute, which usePositioning keeps
+ in sync with the actual placement as you scroll or resize.
+
+
+
+
+
+
+
+
+
+
Arrow padded from corner
+
+ Arrow positioning is fully CSS-owned. For start/end alignments, the Tailwind variant reads{' '}
+ var(--arrow-padding, 12px); this surface overrides the fallback by setting{' '}
+ --arrow-padding: 16px in its inline style.
+