From c0d75b603afd70eeece322439177d4627ce862df Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 17 Apr 2026 10:54:30 -0400 Subject: [PATCH 01/28] Make popover in `AnchoredOverlay` opt-in --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 7421e6a84d6..d7b29f816e5 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -120,6 +120,13 @@ interface AnchoredOverlayBaseProps extends Pick + /** + * When enabled (and CSS anchor positioning feature flag is on), uses the Popover API + * to render the overlay in the browser's top layer. This helps the overlay escape + * stacking contexts and appear above other elements like sticky headers. + * @default false + */ + usePopoverApi?: boolean } export type AnchoredOverlayProps = AnchoredOverlayBaseProps & @@ -162,10 +169,13 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') const supportsNativeCSSAnchorPositioning = useRef(false) const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current + // Only use Popover API when both CSS anchor positioning is enabled AND usePopoverApi is true + const shouldUsePopover = cssAnchorPositioning && usePopoverApi const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const anchorId = useId(externalAnchorId) @@ -282,14 +292,17 @@ export const AnchoredOverlay: React.FC { if (overlayProps?.ref) { assignRef(overlayProps.ref, node) From f68e82ba761ef777bdbf2748c6edb14d48f5ba9d Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 17 Apr 2026 13:47:46 -0400 Subject: [PATCH 02/28] Some clean up --- .../react/src/AnchoredOverlay/AnchoredOverlay.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index d7b29f816e5..decf20419db 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -122,11 +122,8 @@ interface AnchoredOverlayBaseProps extends Pick /** * When enabled (and CSS anchor positioning feature flag is on), uses the Popover API - * to render the overlay in the browser's top layer. This helps the overlay escape - * stacking contexts and appear above other elements like sticky headers. - * @default false */ - usePopoverApi?: boolean + usePopover?: boolean } export type AnchoredOverlayProps = AnchoredOverlayBaseProps & @@ -169,13 +166,13 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') const supportsNativeCSSAnchorPositioning = useRef(false) const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current - // Only use Popover API when both CSS anchor positioning is enabled AND usePopoverApi is true - const shouldUsePopover = cssAnchorPositioning && usePopoverApi + // Only use Popover API when both CSS anchor positioning is enabled AND usePopover is true + const shouldUsePopover = cssAnchorPositioning && usePopover const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const anchorId = useId(externalAnchorId) @@ -292,7 +289,7 @@ export const AnchoredOverlay: React.FC Date: Fri, 17 Apr 2026 14:27:47 -0400 Subject: [PATCH 03/28] Add changeset --- .changeset/hot-phones-sing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-phones-sing.md diff --git a/.changeset/hot-phones-sing.md b/.changeset/hot-phones-sing.md new file mode 100644 index 00000000000..2c078eddf7c --- /dev/null +++ b/.changeset/hot-phones-sing.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +AnchoredOverlay: (Behind feature flag) Make popover API usage opt-in From d9716f0a8fc326b83a62f0fd453af0fa15131fdd Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 20 Apr 2026 10:40:28 -0400 Subject: [PATCH 04/28] Update packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index decf20419db..37f4009c175 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -121,7 +121,8 @@ interface AnchoredOverlayBaseProps extends Pick /** - * When enabled (and CSS anchor positioning feature flag is on), uses the Popover API + * When `true`, uses the Popover API only if the CSS anchor positioning feature flag is enabled + * and the browser supports native CSS anchor positioning. Has no effect otherwise. Defaults to `false`. */ usePopover?: boolean } From f7596e942f059223bff3b23490a2a1accac22f74 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 20 Apr 2026 10:54:43 -0400 Subject: [PATCH 05/28] Change prop name; add test --- .changeset/hot-phones-sing.md | 2 +- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 60 +++++++++++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 20 +++---- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/.changeset/hot-phones-sing.md b/.changeset/hot-phones-sing.md index 2c078eddf7c..83c4be66599 100644 --- a/.changeset/hot-phones-sing.md +++ b/.changeset/hot-phones-sing.md @@ -1,5 +1,5 @@ --- -'@primer/react': patch +'@primer/react': minor --- AnchoredOverlay: (Behind feature flag) Make popover API usage opt-in diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 2c8e149e7b1..345f379317e 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -21,6 +21,7 @@ type TestComponentSettings = { className?: string withCSSAnchorPositioningFeatureFlag?: boolean overlayProps?: Pick + renderAs?: 'portal' | 'popover' } const AnchoredOverlayTestComponent = ({ @@ -31,6 +32,7 @@ const AnchoredOverlayTestComponent = ({ className, withCSSAnchorPositioningFeatureFlag, overlayProps, + renderAs, }: TestComponentSettings = {}) => { const [open, setOpen] = useState(initiallyOpen) const onOpen = useCallback( @@ -57,6 +59,7 @@ const AnchoredOverlayTestComponent = ({ renderAnchor={props => } onPositionChange={onPositionChange} className={className} + renderAs={renderAs} {...overlayProps} > @@ -303,6 +306,52 @@ describe('AnchoredOverlay feature flag specific behavior', () => { const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') expect(overlay).toHaveAttribute('data-anchor-position', 'true') }) + + it('should set popover="manual" on overlay when renderAs is "popover"', () => { + const {baseElement} = render( + + + , + ) + + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') + expect(overlay).toHaveAttribute('popover', 'manual') + }) + + it('should set popovertarget on anchor when renderAs is "popover"', () => { + const {baseElement} = render( + + + , + ) + + const anchor = baseElement.querySelector('[aria-haspopup="true"]') + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') + expect(anchor).toHaveAttribute('popovertarget') + expect(anchor!.getAttribute('popovertarget')).toBe(overlay!.getAttribute('id')) + }) + + it('should not set popover attribute on overlay when renderAs is "portal"', () => { + const {baseElement} = render( + + + , + ) + + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') + expect(overlay).not.toHaveAttribute('popover') + }) + + it('should not set popover attribute on overlay when renderAs defaults to "portal"', () => { + const {baseElement} = render( + + + , + ) + + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') + expect(overlay).not.toHaveAttribute('popover') + }) }) describe('with primer_react_css_anchor_positioning feature flag disabled', () => { @@ -329,5 +378,16 @@ describe('AnchoredOverlay feature flag specific behavior', () => { const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') expect(overlay).toHaveAttribute('data-anchor-position', 'false') }) + + it('should not set popover attribute on overlay when renderAs is "popover" but flag is disabled', () => { + const {baseElement} = render( + + + , + ) + + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') + expect(overlay).not.toHaveAttribute('popover') + }) }) }) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 37f4009c175..ec6efb87d73 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -124,7 +124,7 @@ interface AnchoredOverlayBaseProps extends Pick { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') const supportsNativeCSSAnchorPositioning = useRef(false) const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current - // Only use Popover API when both CSS anchor positioning is enabled AND usePopover is true - const shouldUsePopover = cssAnchorPositioning && usePopover + // Only use Popover API when both CSS anchor positioning is enabled AND renderAs is true + const shouldRenderAs = cssAnchorPositioning && renderAs === 'popover' const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const anchorId = useId(externalAnchorId) @@ -290,8 +290,8 @@ export const AnchoredOverlay: React.FC { if (overlayProps?.ref) { assignRef(overlayProps.ref, node) From c24827c27bc1e36c020fd999996e64b5e5fc970a Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 23 Apr 2026 15:03:18 -0400 Subject: [PATCH 06/28] Ensure CSS anchor position persists on remount --- .../src/ActionMenu/ActionMenu.dev.stories.tsx | 46 ++++++++++++ .../AnchoredOverlay/AnchoredOverlay.test.tsx | 61 ++++++++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 71 ++++++++++--------- 3 files changed, 143 insertions(+), 35 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx index a436e4499ff..a0df92ec445 100644 --- a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx @@ -1,7 +1,9 @@ import type {Meta} from '@storybook/react-vite' +import {useRef, useState} from 'react' import type {ComponentProps} from '../utils/types' import {ActionMenu} from './ActionMenu' import {ActionList} from '../ActionList' +import {Button} from '../Button' export default { title: 'Components/ActionMenu/Dev', @@ -34,3 +36,47 @@ export const WithCss = () => ( ) + +/** + * Reproduces a bug where switching the anchor DOM element (via unmount/remount) + * causes the CSS anchor positioning to break because `anchor-name` is never + * re-applied to the new element. + */ +export const AnchorElementReplacement = () => { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [anchorKey, setAnchorKey] = useState(0) + + return ( +
+

+ 1. Open the menu below. 2. Click "Switch anchor (remount)" inside the menu. 3. The overlay should + remain anchored to the button — not jump to the top-left corner. +

+ + + + + + + { + // Prevent the menu from closing when clicking this item + event.preventDefault() + setAnchorKey(k => k + 1) + }} + > + Switch anchor (remount) + + + Item one + Item two + Item three + + + +
+ ) +} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 345f379317e..d93f2672041 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -391,3 +391,64 @@ describe('AnchoredOverlay feature flag specific behavior', () => { }) }) }) + +describe('AnchoredOverlay anchor element replacement', () => { + it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => { + function TestComponent() { + const anchorRef = useRef(null) + const [open, setOpen] = useState(true) + const [anchorKey, setAnchorKey] = useState(0) + + return ( + + + + + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={null} + anchorRef={anchorRef} + > +
content
+
+
+ ) + } + + const {baseElement} = render() + + // Verify anchor-name is set on the initial anchor element + const initialAnchor = baseElement.querySelector('[data-testid="anchor"]') as HTMLElement + expect(initialAnchor.style.getPropertyValue('anchor-name')).not.toBe('') + const anchorName = initialAnchor.style.getPropertyValue('anchor-name') + + // Close the overlay + const toggleButton = baseElement.querySelector('[data-testid="toggle"]') as HTMLElement + act(() => { + toggleButton.click() + }) + + // Replace the anchor DOM element by changing its key while overlay is closed + const switchButton = baseElement.querySelector('[data-testid="switch"]') as HTMLElement + act(() => { + switchButton.click() + }) + + // Reopen the overlay — the new anchor should get anchor-name before paint + act(() => { + toggleButton.click() + }) + + const newAnchor = baseElement.querySelector('[data-testid="anchor"]') as HTMLElement + expect(newAnchor).not.toBe(initialAnchor) + expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(anchorName) + }) +}) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index ec6efb87d73..0f1b58695bb 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -1,6 +1,5 @@ import type React from 'react' -import {useCallback, useEffect, useRef, type JSX} from 'react' -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import {useCallback, useEffect, useRef, useState, type JSX} from 'react' import type {OverlayProps} from '../Overlay' import Overlay from '../Overlay' import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' @@ -175,6 +174,16 @@ export const AnchoredOverlay: React.FC(anchorRef.current) + // Detect when `anchorRef.current` has been mutated by a consumer (e.g. via + // their own `ref={anchorRef}`) without React's ref system notifying us. + if (anchorRef.current !== anchorElement) { + setAnchorElement(anchorRef.current) + } const [overlayRef, updateOverlayRef] = useRenderForcingRef() const anchorId = useId(externalAnchorId) @@ -251,56 +260,48 @@ export const AnchoredOverlay: React.FC { - if (!cssAnchorPositioning || !anchorRef.current) return - - const anchor = anchorRef.current - const overlay = overlayRef.current - anchor.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`) - - return () => { - anchor.style.removeProperty('anchor-name') - if (overlay) { - overlay.style.removeProperty('position-anchor') - } - } - }, [cssAnchorPositioning, anchorRef, overlayRef, id, open]) - // Track the overlay element so we can re-run the effect when it changes. // The overlay unmounts when closed, so each open creates a new DOM node - // that needs showPopover() called. const overlayElement = overlayRef.current - useLayoutEffect(() => { - // Read ref inside effect to get the value after child refs are attached - const currentOverlay = overlayRef.current + useEffect(() => { + if (!cssAnchorPositioning || !anchorElement) return + + // Link the anchor and the overlay (when present) via CSS anchor positioning. + anchorElement.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`) - if (!cssAnchorPositioning || !open || !currentOverlay) return - currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) + if (open && overlayElement) { + overlayElement.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) - const anchorElement = anchorRef.current - if (anchorElement) { const overlayWidth = width ? parseInt(widthMap[width]) : null const result = getDefaultPosition(anchorElement, overlayWidth) - currentOverlay.setAttribute('data-align', result.horizontal) + overlayElement.setAttribute('data-align', result.horizontal) // Apply offset only when viewport is too narrow const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset - currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) + overlayElement.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) + + // Only call showPopover when shouldRenderAs is enabled + if (shouldRenderAs) { + try { + if (!overlayElement.matches(':popover-open')) { + overlayElement.showPopover() + } + } catch { + // Ignore if popover is already showing or not supported + } + } } - // Only call showPopover when renderAs is enabled - if (shouldRenderAs) { - try { - if (!currentOverlay.matches(':popover-open')) { - currentOverlay.showPopover() - } - } catch { - // Ignore if popover is already showing or not supported + return () => { + anchorElement.style.removeProperty('anchor-name') + if (overlayElement) { + overlayElement.style.removeProperty('position-anchor') } } - }, [cssAnchorPositioning, shouldRenderAs, open, overlayElement, id, overlayRef, anchorRef, width]) + }, [cssAnchorPositioning, shouldRenderAs, open, anchorElement, overlayElement, id, width]) const showXIcon = onClose && variant.narrow === 'fullscreen' && displayCloseButton const XButtonAriaLabelledBy = closeButtonProps['aria-labelledby'] From 3ad5e6cca7adc691def5ce6fdc5f6d77850bcc7d Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 23 Apr 2026 15:06:15 -0400 Subject: [PATCH 07/28] Add back `shouldRenderAsPopover` --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 0440336dc25..391c135df12 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -120,8 +120,8 @@ interface AnchoredOverlayBaseProps extends Pick /** - * When `true`, uses the Popover API only if the CSS anchor positioning feature flag is enabled - * and the browser supports native CSS anchor positioning. Has no effect otherwise. Defaults to `false`. + * When `"popover"`, uses the Popover API only if the CSS anchor positioning feature flag is enabled + * and the browser supports native CSS anchor positioning. Has no effect otherwise. Defaults to `"portal"`. */ renderAs?: 'portal' | 'popover' } @@ -173,7 +173,7 @@ export const AnchoredOverlay: React.FC { if (overlayProps?.ref) { assignRef(overlayProps.ref, node) From 4f8a01299d3b89126aef3c86523be78e41c6fad7 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 23 Apr 2026 17:02:09 -0400 Subject: [PATCH 08/28] Add back `currentOverlay` --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 391c135df12..c6e0dc91546 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -271,26 +271,28 @@ export const AnchoredOverlay: React.FC { if (!cssAnchorPositioning || !anchorElement) return + const currentOverlay = overlayRef.current + // Link the anchor and the overlay (when present) via CSS anchor positioning. anchorElement.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`) - if (open && overlayElement) { - overlayElement.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) + if (open && currentOverlay) { + currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) const overlayWidth = width ? parseInt(widthMap[width]) : null const result = getDefaultPosition(anchorElement, overlayWidth) - overlayElement.setAttribute('data-align', result.horizontal) + currentOverlay.setAttribute('data-align', result.horizontal) // Apply offset only when viewport is too narrow const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset - overlayElement.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) + currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) // Only call showPopover when shouldRenderAsPopover is enabled if (shouldRenderAsPopover) { try { - if (!overlayElement.matches(':popover-open')) { - overlayElement.showPopover() + if (!currentOverlay.matches(':popover-open')) { + currentOverlay.showPopover() } } catch { // Ignore if popover is already showing or not supported @@ -300,11 +302,11 @@ export const AnchoredOverlay: React.FC { anchorElement.style.removeProperty('anchor-name') - if (overlayElement) { - overlayElement.style.removeProperty('position-anchor') + if (currentOverlay) { + currentOverlay.style.removeProperty('position-anchor') } } - }, [cssAnchorPositioning, shouldRenderAsPopover, open, anchorElement, overlayElement, id, width]) + }, [cssAnchorPositioning, shouldRenderAsPopover, open, anchorElement, overlayElement, overlayRef, id, width]) const showXIcon = onClose && variant.narrow === 'fullscreen' && displayCloseButton const XButtonAriaLabelledBy = closeButtonProps['aria-labelledby'] From f44bdc5ab1620b1307663a9c1d10ee01944bd3bb Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 24 Apr 2026 11:10:15 -0400 Subject: [PATCH 09/28] Fix lint issue --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index c6e0dc91546..2fa911227c0 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -186,6 +186,7 @@ export const AnchoredOverlay: React.FC() + const [overlayElement, setOverlayElement] = useState(null) const anchorId = useId(externalAnchorId) const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose]) @@ -262,12 +263,6 @@ export const AnchoredOverlay: React.FC { if (!cssAnchorPositioning || !anchorElement) return @@ -354,6 +349,7 @@ export const AnchoredOverlay: React.FC Date: Fri, 24 Apr 2026 11:15:58 -0400 Subject: [PATCH 10/28] Add changeset --- .changeset/poor-carrots-guess.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/poor-carrots-guess.md diff --git a/.changeset/poor-carrots-guess.md b/.changeset/poor-carrots-guess.md new file mode 100644 index 00000000000..9522e985590 --- /dev/null +++ b/.changeset/poor-carrots-guess.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +AnchoredOverlay: Ensure styles persist on anchors even when re-mounted (behind feature flag) From 17da3bcd8e9ed92930d21579fbef5339cef5174f Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 24 Apr 2026 11:34:37 -0400 Subject: [PATCH 11/28] Fix more lint --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 2fa911227c0..92ada60e90e 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -170,7 +170,7 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') const supportsNativeCSSAnchorPositioning = useRef(false) - // eslint-disable-next-line react-hooks/refs + const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current // Only use Popover API when both CSS anchor positioning is enabled AND renderAs is true const shouldRenderAsPopover = cssAnchorPositioning && renderAs === 'popover' @@ -240,7 +240,7 @@ export const AnchoredOverlay: React.FC { anchorElement.style.removeProperty('anchor-name') + // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. if (currentOverlay) { currentOverlay.style.removeProperty('position-anchor') } @@ -312,7 +313,6 @@ export const AnchoredOverlay: React.FC {renderAnchor && - // eslint-disable-next-line react-hooks/refs renderAnchor({ ref: anchorRef, id: anchorId, From c9c556281dac21403bd858d857140a36916756e1 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 24 Apr 2026 11:55:35 -0400 Subject: [PATCH 12/28] Fix even more lint --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 92ada60e90e..51a532bcccb 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import {useCallback, useEffect, useRef, useState, type JSX} from 'react' +import {useCallback, useEffect, useState, type JSX} from 'react' import type {OverlayProps} from '../Overlay' import Overlay from '../Overlay' import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' @@ -169,9 +169,13 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning') - const supportsNativeCSSAnchorPositioning = useRef(false) + // Lazy initial state so feature detection runs once per mount on the client. + // Guarded for SSR where `document` is undefined. + const [supportsNativeCSSAnchorPositioning] = useState( + () => typeof document !== 'undefined' && 'anchorName' in document.documentElement.style, + ) - const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning.current + const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning // Only use Popover API when both CSS anchor positioning is enabled AND renderAs is true const shouldRenderAsPopover = cssAnchorPositioning && renderAs === 'popover' const anchorRef = useProvidedRefOrCreate(externalAnchorRef) @@ -179,9 +183,13 @@ export const AnchoredOverlay: React.FC(anchorRef.current) + const [anchorElement, setAnchorElement] = useState(null) // Detect when `anchorRef.current` has been mutated by a consumer (e.g. via // their own `ref={anchorRef}`) without React's ref system notifying us. + // Reading the ref during render and conditionally calling setState is the + // documented React pattern for syncing external mutable state, but the + // react-hooks/refs lint rule is conservative and disallows it. + // eslint-disable-next-line react-hooks/refs if (anchorRef.current !== anchorElement) { setAnchorElement(anchorRef.current) } @@ -241,12 +249,10 @@ export const AnchoredOverlay: React.FC { - supportsNativeCSSAnchorPositioning.current = 'anchorName' in document.documentElement.style - // ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening if (!open && overlayRef.current) { updateOverlayRef(null) @@ -302,7 +308,9 @@ export const AnchoredOverlay: React.FC {renderAnchor && + // anchorRef is a ref object passed as a JSX `ref` prop on the rendered + // anchor; React writes to it at commit time, it is not read during render. + // eslint-disable-next-line react-hooks/refs renderAnchor({ ref: anchorRef, id: anchorId, From 58058e2d081adafd85fcabed8d764f6b2e277d39 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 27 Apr 2026 16:41:56 -0400 Subject: [PATCH 13/28] Some perf improvements --- .../react/src/ActionMenu/ActionMenu.test.tsx | 62 +++++++++++++++++++ .../AnchoredOverlay.dev.stories.tsx | 36 +++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 33 +++++++--- .../react/src/hooks/useAnchoredPosition.ts | 13 +++- 4 files changed, 134 insertions(+), 10 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index 78d33063fb4..09303b7923b 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -2,6 +2,7 @@ import {describe, expect, it, vi, beforeEach} from 'vitest' import {render as HTMLRender, waitFor, act, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import type React from 'react' +import {useRef, useState} from 'react' import BaseStyles from '../BaseStyles' import {ActionMenu, ActionList, Button, IconButton, Dialog} from '..' import Tooltip from '../Tooltip' @@ -94,6 +95,38 @@ function ExampleWithTooltipV2(actionMenuTrigger: React.ReactElement): JSX.E ) } +function ExampleWithReplaceableAnchor(): JSX.Element { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [anchorKey, setAnchorKey] = useState(0) + + return ( + + + + + + + { + // Prevent the menu from closing so the overlay stays mounted + event.preventDefault() + setAnchorKey(k => k + 1) + }} + > + Switch anchor + + Item one + + + + + + ) +} + function ExampleWithSubmenus(): JSX.Element { return ( @@ -737,6 +770,35 @@ describe('ActionMenu', () => { const button = component.getByRole('button', {name: 'Toggle Menu'}) expect(button).toHaveClass('test-class') }) + + it('keeps anchor-name and position-anchor linked when the anchor is replaced while the menu is open', async () => { + const user = userEvent.setup() + const component = HTMLRender() + + const initialAnchor = component.getByRole('button', {name: 'Open menu'}) + await user.click(initialAnchor) + + const overlay = component.baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement + expect(overlay).not.toBeNull() + + const initialAnchorName = initialAnchor.style.getPropertyValue('anchor-name') + const initialPositionAnchor = overlay.style.getPropertyValue('position-anchor') + expect(initialAnchorName).not.toBe('') + expect(initialPositionAnchor).not.toBe('') + expect(initialPositionAnchor).toBe(initialAnchorName) + + // Click the item that remounts the anchor while keeping the menu open + const switchItem = component.getByRole('menuitem', {name: 'Switch anchor'}) + await user.click(switchItem) + + const newAnchor = component.getByRole('button', {name: 'Open menu'}) + expect(newAnchor).not.toBe(initialAnchor) + + // The new anchor should have the same anchor-name re-applied, and the + // overlay should still reference it via position-anchor. + expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(initialAnchorName) + expect(overlay.style.getPropertyValue('position-anchor')).toBe(initialPositionAnchor) + }) }) describe('calls event handlers on trigger', () => { diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx index 88f7a778309..fc95111e9a3 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx @@ -309,3 +309,39 @@ export const WithActionMenu = { }, }, } + +const ManyOverlaysItem = ({index}: {index: number}) => { + const [open, setOpen] = useState(false) + return ( + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={props => } + overlayProps={{role: 'dialog', 'aria-label': `Overlay ${index}`}} + focusZoneSettings={{disabled: true}} + > +
Overlay #{index}
+
+ ) +} + +export const ManyOverlays = () => { + const count = 50 + const items = Array.from({length: count}, (_, i) => i) + return ( +
+

+ Renders {count} AnchoredOverlay instances. Use the per-trigger button to open any subset, or open + all via document.querySelectorAll('[aria-haspopup=true]').forEach(b => b.click()). +

+
+ {items.map(i => ( + + ))} +
+ {/* Spacer so the page is scrollable */} +
+
+ ) +} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 51a532bcccb..a34ceaa0f40 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -247,6 +247,10 @@ export const AnchoredOverlay: React.FC { + rafId = null + const overlayWidth = width ? parseInt(widthMap[width]) : null + const result = getDefaultPosition(anchorElement, overlayWidth) - currentOverlay.setAttribute('data-align', result.horizontal) + currentOverlay.setAttribute('data-align', result.horizontal) - // Apply offset only when viewport is too narrow - const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset - currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) + // Apply offset only when viewport is too narrow + const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset + currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) + }) // Only call showPopover when shouldRenderAsPopover is enabled if (shouldRenderAsPopover) { @@ -302,6 +318,7 @@ export const AnchoredOverlay: React.FC { + if (rafId !== null) cancelAnimationFrame(rafId) anchorElement.style.removeProperty('anchor-name') // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. if (currentOverlay) { diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index b9ab4fcfb0e..1ceb4f5d46a 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -31,6 +31,12 @@ export interface AnchoredPositionHookSettings extends Partial anchorElementRef?: React.RefObject pinPosition?: boolean onPositionChange?: (position: AnchorPosition | undefined) => void + /** + * When false, skips position computation, scroll listeners, and resize + * observers. Useful when an external mechanism (e.g. native CSS anchor + * positioning) is handling positioning instead. Defaults to true. + */ + enabled?: boolean } /** @@ -52,6 +58,7 @@ export function useAnchoredPosition( } { const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef) const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef) + const enabled = settings?.enabled ?? true const savedOnPositionChange = React.useRef(settings?.onPositionChange) const [position, setPosition] = React.useState(undefined) // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -83,6 +90,7 @@ export function useAnchoredPosition( const updatePosition = React.useCallback( () => { + if (!enabled) return if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings) setPosition(prev => { @@ -109,7 +117,7 @@ export function useAnchoredPosition( setPrevHeight(floatingElementRef.current?.clientHeight) }, // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo - [floatingElementRef, anchorElementRef, ...dependencies], + [floatingElementRef, anchorElementRef, enabled, ...dependencies], ) useLayoutEffect(() => { @@ -124,6 +132,7 @@ export function useAnchoredPosition( // Recalculate position when any scrollable ancestor of the anchor scrolls. // Uses requestAnimationFrame to avoid layout thrashing during scroll. React.useEffect(() => { + if (!enabled) return const anchorEl = anchorElementRef.current if (!anchorEl) return @@ -150,7 +159,7 @@ export function useAnchoredPosition( cancelAnimationFrame(rafId) } } - }, [anchorElementRef, updatePosition]) + }, [anchorElementRef, updatePosition, enabled]) return { floatingElementRef, From 570e6bdcfaf50423ef47c29e50ed712ef96fd213 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Wed, 29 Apr 2026 11:48:35 -0400 Subject: [PATCH 14/28] Disable `useResizeObserver` usage when flag is on --- packages/react/src/hooks/useAnchoredPosition.ts | 9 ++------- packages/react/src/hooks/useResizeObserver.ts | 6 +++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 1ceb4f5d46a..2040b72f92d 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -31,11 +31,6 @@ export interface AnchoredPositionHookSettings extends Partial anchorElementRef?: React.RefObject pinPosition?: boolean onPositionChange?: (position: AnchorPosition | undefined) => void - /** - * When false, skips position computation, scroll listeners, and resize - * observers. Useful when an external mechanism (e.g. native CSS anchor - * positioning) is handling positioning instead. Defaults to true. - */ enabled?: boolean } @@ -126,8 +121,8 @@ export function useAnchoredPosition( useLayoutEffect(updatePosition, [updatePosition]) - useResizeObserver(updatePosition) // watches for changes in window size - useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size + useResizeObserver(updatePosition, undefined, [], enabled) // watches for changes in window size + useResizeObserver(updatePosition, floatingElementRef as React.RefObject, [], enabled) // watches for changes in floating element size // Recalculate position when any scrollable ancestor of the anchor scrolls. // Uses requestAnimationFrame to avoid layout thrashing during scroll. diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 704673af082..20636071b8a 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -13,6 +13,7 @@ export function useResizeObserver( callback: ResizeObserverCallback, target?: RefObject, depsArray: unknown[] = [], + enabled: boolean = true, ) { const [targetClientRect, setTargetClientRect] = useState(null) const savedCallback = useRef(callback) @@ -22,6 +23,9 @@ export function useResizeObserver( }) useLayoutEffect(() => { + if (!enabled) { + return + } const targetEl = target && 'current' in target ? target.current : document.documentElement if (!targetEl) { return @@ -59,5 +63,5 @@ export function useResizeObserver( } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target?.current, ...depsArray]) + }, [target?.current, enabled, ...depsArray]) } From 018006e18b91521fc8893ee2dd3500ca2d050c68 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Wed, 29 Apr 2026 12:39:52 -0400 Subject: [PATCH 15/28] More cleanup --- .../react/src/ActionMenu/ActionMenu.dev.stories.tsx | 2 ++ .../react/src/AnchoredOverlay/AnchoredOverlay.tsx | 11 ----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx index a0df92ec445..3a7cb9c18ec 100644 --- a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx @@ -41,6 +41,8 @@ export const WithCss = () => ( * Reproduces a bug where switching the anchor DOM element (via unmount/remount) * causes the CSS anchor positioning to break because `anchor-name` is never * re-applied to the new element. + * + * https://github.com/github/primer/issues/6616 */ export const AnchorElementReplacement = () => { const anchorRef = useRef(null) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index a34ceaa0f40..dc738295375 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -179,16 +179,7 @@ export const AnchoredOverlay: React.FC(null) - // Detect when `anchorRef.current` has been mutated by a consumer (e.g. via - // their own `ref={anchorRef}`) without React's ref system notifying us. - // Reading the ref during render and conditionally calling setState is the - // documented React pattern for syncing external mutable state, but the - // react-hooks/refs lint rule is conservative and disallows it. // eslint-disable-next-line react-hooks/refs if (anchorRef.current !== anchorElement) { setAnchorElement(anchorRef.current) @@ -338,8 +329,6 @@ export const AnchoredOverlay: React.FC {renderAnchor && - // anchorRef is a ref object passed as a JSX `ref` prop on the rendered - // anchor; React writes to it at commit time, it is not read during render. // eslint-disable-next-line react-hooks/refs renderAnchor({ ref: anchorRef, From de5ae7fe43007eb44faf42bbb4b5f6b19f9105c3 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Wed, 29 Apr 2026 12:47:08 -0400 Subject: [PATCH 16/28] Update requestAnimationFrame --- .../react/src/AnchoredOverlay/AnchoredOverlay.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index dc738295375..aa5c0818d0a 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -276,16 +276,14 @@ export const AnchoredOverlay: React.FC { - rafId = null + // Defer the getBoundingClientRect read into a `requestAnimationFrame` so the style write above + // does not force a synchronous layout. + pendingPositionFrame = requestAnimationFrame(() => { + pendingPositionFrame = null const overlayWidth = width ? parseInt(widthMap[width]) : null const result = getDefaultPosition(anchorElement, overlayWidth) @@ -309,7 +307,7 @@ export const AnchoredOverlay: React.FC { - if (rafId !== null) cancelAnimationFrame(rafId) + if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame) anchorElement.style.removeProperty('anchor-name') // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. if (currentOverlay) { From 7e0009b07f685ff70bbafb20ae5e313d30ea5f34 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Wed, 29 Apr 2026 16:25:35 -0400 Subject: [PATCH 17/28] Fix test --- packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index d93f2672041..4caecfa0682 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -174,7 +174,9 @@ describe.each([true, false])( expect(mockCloseCallback).toHaveBeenCalledWith('escape') }) - it('should call onPositionChange when provided', async () => { + // onPositionChange is not supported when the CSS anchor positioning flag is enabled, + // because positioning is handled by the browser rather than `useAnchoredPosition`. + it.skipIf(withCSSAnchorPositioningFeatureFlag)('should call onPositionChange when provided', async () => { const mockPositionChangeCallback = vi.fn(({position}: {position: AnchorPosition}) => position) render( Date: Fri, 1 May 2026 15:10:04 -0400 Subject: [PATCH 18/28] Add fix for overflow --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index aa5c0818d0a..5600cef0f6b 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -284,12 +284,16 @@ export const AnchoredOverlay: React.FC { pendingPositionFrame = null - const overlayWidth = width ? parseInt(widthMap[width]) : null - const result = getDefaultPosition(anchorElement, overlayWidth) + const fallbackWidth = width ? parseInt(widthMap[width]) : parseInt(widthMap.small) + const result = getDefaultPosition(anchorElement, currentOverlay, fallbackWidth) currentOverlay.setAttribute('data-align', result.horizontal) + // Imperatively override `data-side` (set on the JSX from `side`) when + // the overlay overflows both axes, so CSS can flip without a re-render. + if (result.suggestedSide) { + currentOverlay.setAttribute('data-side', result.suggestedSide) + } - // Apply offset only when viewport is too narrow const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`) }) @@ -396,27 +400,40 @@ export const AnchoredOverlay: React.FC spaceRight ? 'left' : 'right' - // If there's no explicit overlay width, or either side has enough space - // to contain the overlay, let CSS position-try-fallbacks handle positioning - if (!overlayWidth || spaceLeft >= overlayWidth + viewportMargin || spaceRight >= overlayWidth + viewportMargin) { - return {horizontal} + // Suggest flipping to the side of the anchor with more room when the + // overlay is currently overflowing both axes. + const overflowsX = overlayRect.right > vw || overlayRect.left < 0 + const overflowsY = overlayRect.bottom > vh || overlayRect.top < 0 + const suggestedSide = + overflowsX && overflowsY ? (spaceLeft > spaceRight ? 'outside-left' : 'outside-right') : undefined + + // If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow. + let leftOffset: number | undefined + let rightOffset: number | undefined + if (spaceLeft < overlayWidth + margin && spaceRight < overlayWidth + margin) { + leftOffset = Math.max(0, overlayWidth - anchorRect.right + margin) + rightOffset = Math.max(0, anchorRect.left + overlayWidth - vw + margin) } - // If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow - // leftOffset is how much to shift the overlay to the right, rightOffset is how much to shift the overlay to the left - const leftOffset = Math.max(0, overlayWidth - rect.right + viewportMargin) - const rightOffset = Math.max(0, rect.left + overlayWidth - vw + viewportMargin) - - return {horizontal, leftOffset, rightOffset} + return {horizontal, leftOffset, rightOffset, suggestedSide} } function assignRef( From 721ed096d09fe1148d1b453f7c6f5c6f538c7802 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 1 May 2026 16:08:00 -0400 Subject: [PATCH 19/28] Account for `outside-bottom` --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 5600cef0f6b..4baf409f03b 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -406,7 +406,7 @@ function getDefaultPosition( horizontal: 'left' | 'right' leftOffset?: number rightOffset?: number - suggestedSide?: 'outside-left' | 'outside-right' + suggestedSide?: 'outside-left' | 'outside-right' | 'outside-bottom' } { const anchorRect = anchorElement.getBoundingClientRect() const overlayRect = overlayElement.getBoundingClientRect() @@ -418,12 +418,21 @@ function getDefaultPosition( const spaceRight = vw - anchorRect.right const horizontal: 'left' | 'right' = spaceLeft > spaceRight ? 'left' : 'right' - // Suggest flipping to the side of the anchor with more room when the - // overlay is currently overflowing both axes. + // Suggest a flip when the overlay is currently overflowing both axes: + // prefer the side of the anchor with enough room, otherwise fall back to + // outside-bottom and let the offsets keep it inside the viewport. const overflowsX = overlayRect.right > vw || overlayRect.left < 0 const overflowsY = overlayRect.bottom > vh || overlayRect.top < 0 - const suggestedSide = - overflowsX && overflowsY ? (spaceLeft > spaceRight ? 'outside-left' : 'outside-right') : undefined + let suggestedSide: 'outside-left' | 'outside-right' | 'outside-bottom' | undefined + if (overflowsX && overflowsY) { + if (spaceLeft >= overlayWidth + margin) { + suggestedSide = 'outside-left' + } else if (spaceRight >= overlayWidth + margin) { + suggestedSide = 'outside-right' + } else { + suggestedSide = 'outside-bottom' + } + } // If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow. let leftOffset: number | undefined From 8e4ac410f487872dbd2c7cb731b6f9b332d4c60b Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 1 May 2026 17:05:35 -0400 Subject: [PATCH 20/28] Fix bug where anchor and overlay could disconnect --- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 4baf409f03b..2bb42649aca 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -267,18 +267,31 @@ export const AnchoredOverlay: React.FC { if (!cssAnchorPositioning || !anchorElement) return + if (anchorElement.style.getPropertyValue('anchor-name')) return + anchorElement.style.setProperty('anchor-name', anchorName) + return () => { + if (anchorElement.style.getPropertyValue('anchor-name') === anchorName) { + anchorElement.style.removeProperty('anchor-name') + } + } + }, [cssAnchorPositioning, anchorElement, anchorName]) - const currentOverlay = overlayRef.current + useEffect(() => { + if (!cssAnchorPositioning || !anchorElement) return - // Link the anchor and the overlay (when present) via CSS anchor positioning. - anchorElement.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`) + const currentOverlay = overlayRef.current + const resolvedAnchorName = anchorElement.style.getPropertyValue('anchor-name') || anchorName let pendingPositionFrame: number | null = null if (open && currentOverlay) { - currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`) + currentOverlay.style.setProperty('position-anchor', resolvedAnchorName) // Defer the getBoundingClientRect read into a `requestAnimationFrame` so the style write above // does not force a synchronous layout. @@ -312,7 +325,6 @@ export const AnchoredOverlay: React.FC { if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame) - anchorElement.style.removeProperty('anchor-name') // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. if (currentOverlay) { currentOverlay.style.removeProperty('position-anchor') From f75ae3ecc3affbfa61d4cf06df567dba9a3784a5 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 09:02:02 -0400 Subject: [PATCH 21/28] Add for height --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 2bb42649aca..8098d6ae347 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -309,6 +309,14 @@ export const AnchoredOverlay: React.FC Date: Mon, 4 May 2026 11:08:52 -0400 Subject: [PATCH 22/28] Ensure overlay is within viewport --- .../react/src/AnchoredOverlay/AnchoredOverlay.module.css | 8 ++++++-- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css index f40e73c6283..bd82698eb49 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css @@ -54,14 +54,18 @@ &[data-side='outside-left'] { right: anchor(left); - top: anchor(top); + /* Falls back to `anchor(top)` when JS hasn't overridden it. JS sets the + override when the overlay's bottom would overflow the viewport so we + can lift it up to keep the bottom edge on screen, mirroring the JS + anchored-positioning code path. */ + top: var(--anchored-overlay-top-override, anchor(top)); margin-right: var(--base-size-4); position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-left-to-bottom; } &[data-side='outside-right'] { left: anchor(right); - top: anchor(top); + top: var(--anchored-overlay-top-override, anchor(top)); margin-left: var(--base-size-4); position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-right-to-bottom; } diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 8098d6ae347..d0e147bb743 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -317,6 +317,14 @@ export const AnchoredOverlay: React.FC 0) { + const clampedTop = Math.max(0, settledRect.top - overflowBottom - 8) + currentOverlay.style.setProperty('--anchored-overlay-top-override', `${clampedTop}px`) + } else { + currentOverlay.style.removeProperty('--anchored-overlay-top-override') + } }) // Only call showPopover when shouldRenderAsPopover is enabled From 24ce7d75e2e02eb5590ac3d713de05950dee6d5e Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 13:15:55 -0400 Subject: [PATCH 23/28] Add to dev stories --- .../src/ActionMenu/ActionMenu.dev.stories.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx index 3a7cb9c18ec..3e074e924ab 100644 --- a/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx @@ -82,3 +82,61 @@ export const AnchorElementReplacement = () => {
) } + +export const RightAlignedWithLargeSubmenus = () => { + const submenuItems = Array.from({length: 30}, (_, i) => `Item ${i + 1}`) + + return ( +
+ + Open menu + + + + + First submenu (outside bottom) + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + Second submenu (outside left) + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + Third submenu + + + + {submenuItems.map(item => ( + alert(`${item} clicked`)}> + {item} + + ))} + + + + + + +
+ ) +} From 55e29e9997cfd6a2e0de9ed935e9a9be425d88f2 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 13:52:52 -0400 Subject: [PATCH 24/28] Fix merge --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index a11331d7935..d0e147bb743 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -341,7 +341,6 @@ export const AnchoredOverlay: React.FC { if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame) - anchorElement.style.removeProperty('anchor-name') // The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it. if (currentOverlay) { currentOverlay.style.removeProperty('position-anchor') From 2f9bd6519d940f363e1c51ae7ead931e43f6b22b Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 15:32:31 -0400 Subject: [PATCH 25/28] Remove height logic --- .../react/src/AnchoredOverlay/AnchoredOverlay.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index d0e147bb743..4697e5f3f09 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -301,8 +301,6 @@ export const AnchoredOverlay: React.FC 0) { const clampedTop = Math.max(0, settledRect.top - overflowBottom - 8) From ffbc3f2d94b54779f1ce11fff0c23e347b1bb81a Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 16:14:36 -0400 Subject: [PATCH 26/28] Some cleanup --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 4697e5f3f09..c7ad63f7cac 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -308,11 +308,7 @@ export const AnchoredOverlay: React.FC 0) { From ef40b1d7465a8a31cbbdb525dc834f310cdf3040 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 4 May 2026 17:45:35 -0400 Subject: [PATCH 27/28] Add changeset --- .changeset/social-deer-laugh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/social-deer-laugh.md diff --git a/.changeset/social-deer-laugh.md b/.changeset/social-deer-laugh.md new file mode 100644 index 00000000000..013233c02f2 --- /dev/null +++ b/.changeset/social-deer-laugh.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +AnchoredOverlay: Ensure overlay fits within viewport by calculating viewport height + width (behind `primer_react_css_anchor_positioning` feature flag) \ No newline at end of file From 00afccf1ae0b018aca4c85a377603bfc3babaecc Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 5 May 2026 10:57:00 -0400 Subject: [PATCH 28/28] Add test --- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 4caecfa0682..ca50de82813 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -1,6 +1,6 @@ import {act, createRef, useCallback, useRef, useState} from 'react' import {describe, expect, it, vi} from 'vitest' -import {render} from '@testing-library/react' +import {render, waitFor} from '@testing-library/react' import {userEvent} from 'vitest/browser' import {AnchoredOverlay} from '../AnchoredOverlay' import {Button} from '../Button' @@ -394,6 +394,115 @@ describe('AnchoredOverlay feature flag specific behavior', () => { }) }) +describe('AnchoredOverlay CSS anchor positioning viewport handling', () => { + it('should set --anchored-overlay-top-override when the overlay would overflow the viewport bottom', async () => { + function TestComponent() { + const ref = useRef(null) + return ( + + + + {}} + onClose={() => {}} + renderAnchor={null} + anchorRef={ref} + side="outside-bottom" + > + {/* Make the overlay taller than the viewport so it overflows */} +
tall content
+
+
+
+ ) + } + + const {baseElement} = render() + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement + + await waitFor(() => { + expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).not.toBe('') + }) + + // The override should clamp top to keep the bottom edge on screen. + const value = overlay.style.getPropertyValue('--anchored-overlay-top-override') + expect(value.endsWith('px')).toBe(true) + expect(parseFloat(value)).toBeGreaterThanOrEqual(0) + }) + + it('should not set --anchored-overlay-top-override when the overlay fits in the viewport', async () => { + function TestComponent() { + const ref = useRef(null) + return ( + + + + {}} onClose={() => {}} renderAnchor={null} anchorRef={ref}> +
short content
+
+
+
+ ) + } + + const {baseElement} = render() + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement + + // Wait two frames so the rAF positioning callback has run. + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null)))) + + expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).toBe('') + }) + + it('should set data-side to a suggested side when the overlay overflows both axes', async () => { + function TestComponent() { + const ref = useRef(null) + // Anchor pinned to the bottom-right corner so an outside-bottom + start + // overlay overflows both the right and bottom edges of the viewport. + return ( + + + + {}} + onClose={() => {}} + renderAnchor={null} + anchorRef={ref} + side="outside-bottom" + width="medium" + > +
tall content
+
+
+
+ ) + } + + const {baseElement} = render() + const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement + + await waitFor(() => { + // The JS should suggest a flip because the overlay overflows both axes. + // In this layout there is enough room to the left of the anchor, so the + // suggested side should flip to outside-left. + expect(overlay.getAttribute('data-side')).toBe('outside-left') + }) + }) +}) + describe('AnchoredOverlay anchor element replacement', () => { it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => { function TestComponent() {