diff --git a/.changeset/portal-css-containment.md b/.changeset/portal-css-containment.md
new file mode 100644
index 00000000000..c11b9f7be0c
--- /dev/null
+++ b/.changeset/portal-css-containment.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": minor
+---
+
+Added `primer_react_css_contain_portal` feature flag that applies CSS containment (`contain: layout style`) to Portal elements when enabled. This can improve rendering performance by isolating layout and style calculations within portals without clipping overflow content.
diff --git a/packages/react/src/Portal/Portal.test.tsx b/packages/react/src/Portal/Portal.test.tsx
index 7b4975b4616..05635e12396 100644
--- a/packages/react/src/Portal/Portal.test.tsx
+++ b/packages/react/src/Portal/Portal.test.tsx
@@ -1,9 +1,10 @@
-import {describe, expect, it} from 'vitest'
-import Portal, {registerPortalRoot, PortalContext} from '../Portal/index'
+import {describe, expect, it, vi, beforeEach} from 'vitest'
+import Portal, {registerPortalRoot, PortalContext, resetCSSContainmentTracking} from '../Portal/index'
import {render} from '@testing-library/react'
import BaseStyles from '../BaseStyles'
import React from 'react'
+import {FeatureFlags} from '../FeatureFlags'
describe('Portal', () => {
it('renders a default portal into document.body (no BaseStyles present)', () => {
@@ -183,4 +184,100 @@ describe('Portal', () => {
document.body.removeChild(contextPortalRoot)
document.body.removeChild(propPortalRoot)
})
+
+ describe('CSS containment feature flag', () => {
+ beforeEach(() => {
+ // Reset containment tracking between tests
+ resetCSSContainmentTracking()
+ })
+
+ it('does not apply CSS containment by default', () => {
+ const {baseElement} = render(test-content)
+ const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement
+
+ expect(generatedRoot).toBeInstanceOf(HTMLElement)
+ expect(generatedRoot.style.contain).toBe('')
+
+ baseElement.innerHTML = ''
+ })
+
+ it('applies CSS containment when primer_react_css_contain_portal flag is enabled', () => {
+ const toRender = (
+
+ contained-content
+
+ )
+
+ const {baseElement} = render(toRender)
+ const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement
+
+ expect(generatedRoot).toBeInstanceOf(HTMLElement)
+ expect(generatedRoot.style.contain).toBe('layout style')
+
+ baseElement.innerHTML = ''
+ })
+
+ it('does not apply CSS containment when flag is explicitly disabled', () => {
+ const toRender = (
+
+ uncontained-content
+
+ )
+
+ const {baseElement} = render(toRender)
+ const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement
+
+ expect(generatedRoot).toBeInstanceOf(HTMLElement)
+ expect(generatedRoot.style.contain).toBe('')
+
+ baseElement.innerHTML = ''
+ })
+
+ it('applies CSS containment to pre-existing portal root', () => {
+ // Create a pre-existing portal root declaratively
+ const existingRoot = document.createElement('div')
+ existingRoot.id = '__primerPortalRoot__'
+ document.body.appendChild(existingRoot)
+
+ const toRender = (
+
+ content-in-existing-root
+
+ )
+
+ const {baseElement} = render(toRender)
+ const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement
+
+ expect(generatedRoot).toBeInstanceOf(HTMLElement)
+ expect(generatedRoot.style.contain).toBe('layout style')
+
+ baseElement.innerHTML = ''
+ document.body.removeChild(existingRoot)
+ })
+
+ it('warns when overriding existing contain value', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ // Create a pre-existing portal root with a different contain value
+ const existingRoot = document.createElement('div')
+ existingRoot.id = '__primerPortalRoot__'
+ existingRoot.style.contain = 'size'
+ document.body.appendChild(existingRoot)
+
+ const toRender = (
+
+ content-with-override
+
+ )
+
+ render(toRender)
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Portal root already has contain: "size". Overriding with "layout style" due to primer_react_css_contain_portal flag.',
+ )
+
+ warnSpy.mockRestore()
+ document.body.removeChild(existingRoot)
+ })
+ })
})
diff --git a/packages/react/src/Portal/Portal.tsx b/packages/react/src/Portal/Portal.tsx
index 68797db5a15..c142ad21794 100644
--- a/packages/react/src/Portal/Portal.tsx
+++ b/packages/react/src/Portal/Portal.tsx
@@ -1,12 +1,25 @@
import React, {useContext} from 'react'
import {createPortal} from 'react-dom'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
+import {useFeatureFlag} from '../FeatureFlags'
const PRIMER_PORTAL_ROOT_ID = '__primerPortalRoot__'
const DEFAULT_PORTAL_CONTAINER_NAME = '__default__'
const portalRootRegistry: Partial> = {}
+// Track which portal roots have had CSS containment applied (auto-cleans when element is GC'd)
+const cssContainmentApplied = new WeakMap()
+
+// Reset containment tracking (exported for testing)
+export function resetCSSContainmentTracking(): void {
+ // WeakMap doesn't have a clear method, but we can reset by deleting the current portal root entry
+ const portalRoot = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME]
+ if (portalRoot) {
+ cssContainmentApplied.delete(portalRoot)
+ }
+}
+
/**
* Register a container to serve as a portal root.
* @param root The element that will be the root for portals created in this container
@@ -20,7 +33,7 @@ export function registerPortalRoot(root: Element, name = DEFAULT_PORTAL_CONTAINE
// Ensures that a default portal root exists and is registered. If a DOM element exists
// with id __primerPortalRoot__, allow that element to serve as the default portal root.
// Otherwise, create that element and attach it to the end of document.body.
-function ensureDefaultPortal() {
+function ensureDefaultPortal(enableCSSContainment = false) {
const existingDefaultPortalContainer = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME]
if (!existingDefaultPortalContainer || !document.body.contains(existingDefaultPortalContainer)) {
let defaultPortalContainer = document.getElementById(PRIMER_PORTAL_ROOT_ID)
@@ -41,6 +54,22 @@ function ensureDefaultPortal() {
registerPortalRoot(defaultPortalContainer)
}
+
+ // Apply CSS containment to the portal root if enabled (only once per root)
+ if (enableCSSContainment) {
+ const portalRoot = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME]
+ if (portalRoot instanceof HTMLElement && !cssContainmentApplied.has(portalRoot)) {
+ const existingContain = portalRoot.style.contain
+ if (existingContain && existingContain !== 'layout style') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Portal root already has contain: "${existingContain}". Overriding with "layout style" due to primer_react_css_contain_portal flag.`,
+ )
+ }
+ portalRoot.style.contain = 'layout style'
+ cssContainmentApplied.set(portalRoot, true)
+ }
+ }
}
/**
@@ -75,6 +104,7 @@ export const Portal: React.FC> = ({
containerName: _containerName,
}) => {
const {portalContainerName} = useContext(PortalContext)
+ const enableCSSContainment = useFeatureFlag('primer_react_css_contain_portal')
const elementRef = React.useRef(null)
if (!elementRef.current) {
const div = document.createElement('div')
@@ -92,7 +122,7 @@ export const Portal: React.FC> = ({
let containerName = _containerName ?? portalContainerName
if (containerName === undefined) {
containerName = DEFAULT_PORTAL_CONTAINER_NAME
- ensureDefaultPortal()
+ ensureDefaultPortal(enableCSSContainment)
}
const parentElement = portalRootRegistry[containerName]
diff --git a/packages/react/src/Portal/index.ts b/packages/react/src/Portal/index.ts
index 375dce81355..493f74ac3b5 100644
--- a/packages/react/src/Portal/index.ts
+++ b/packages/react/src/Portal/index.ts
@@ -1,7 +1,7 @@
import type {PortalProps} from './Portal'
-import {Portal, registerPortalRoot, PortalContext} from './Portal'
+import {Portal, registerPortalRoot, PortalContext, resetCSSContainmentTracking} from './Portal'
export default Portal
-export {registerPortalRoot}
+export {registerPortalRoot, resetCSSContainmentTracking}
export type {PortalProps}
export {PortalContext}