diff --git a/.changeset/violet-horses-arrive.md b/.changeset/violet-horses-arrive.md
new file mode 100644
index 00000000000..93fd0a02bc6
--- /dev/null
+++ b/.changeset/violet-horses-arrive.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Update Breadcrumbs component to enable more SSR friendly rendering
diff --git a/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts b/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts
index 0956fa0becb..c84f5bc6e7c 100644
--- a/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts
+++ b/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts
@@ -6,7 +6,7 @@ const directory = path.resolve(import.meta.dirname)
const FIXTURE_PATH = path.join(directory, 'fixtures')
describe('getPropTypeForComponent', () => {
- it('extracts props for FunctionComponent', () => {
+ it('extracts props for FunctionComponent', {timeout: 10_000}, () => {
const info = parseTypeInfo(FIXTURE_PATH, 'FunctionComponent')
expect(info.props.foo).toMatchObject({name: 'foo', type: 'string', required: true})
expect(info.props.bar).toMatchObject({name: 'bar', type: 'number', required: false})
diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx
index 45dd4b80712..3e26a1e6c6f 100644
--- a/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx
+++ b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx
@@ -1,7 +1,8 @@
import {useState} from 'react'
-import Breadcrumbs from '.'
+import Breadcrumbs, {useBreadcrumbsResponsive, BreadcrumbsOverflowMenu} from '.'
import TextInput from '../TextInput'
import classes from './Breadcrumbs.dev.stories.module.css'
+import overflowClasses from './Breadcrumbs.module.css'
export default {
title: 'Components/Breadcrumbs/Dev',
@@ -153,3 +154,179 @@ export const WithEditableNameInput = () => (
)
+
+/**
+ * Demonstrates using `responsive={false}` to disable automatic overflow behavior.
+ * All items are rendered as-is without any overflow menu, regardless of container width.
+ */
+export const ResponsiveFalse = () => (
+
+
+
With responsive=false (no overflow menu)
+
+
+ Home
+ Products
+ Category
+ Subcategory
+
+ Current Page
+
+
+
+
+
+
+
With responsive=true (default, shows overflow menu)
+
+
+ Home
+ Products
+ Category
+ Subcategory
+
+ Current Page
+
+
+
+
+
+)
+
+/**
+ * Demonstrates using the `useBreadcrumbsResponsive` hook for manual control.
+ * This enables SSR-friendly conditional rendering patterns.
+ */
+export const UseBreadcrumbsResponsiveHook = () => {
+ const children = [
+
+ Home
+ ,
+
+ Category
+ ,
+
+ Subcategory
+ ,
+
+ Product
+ ,
+
+ Details
+ ,
+
+ Specifications
+ ,
+
+ Reviews
+ ,
+ ]
+
+ const {visibleItems, menuItems, showRoot, rootItem, containerRef} = useBreadcrumbsResponsive({
+ children,
+ overflow: 'menu-with-root',
+ })
+
+ // This is a simplified example - in real usage, you would use the hook
+ // to conditionally render different layouts for mobile vs desktop
+
+ return (
+
+
+
Hook return values:
+
+ {JSON.stringify(
+ {
+ visibleItemsCount: visibleItems.length,
+ menuItemsCount: menuItems.length,
+ showRoot,
+ hasRootItem: !!rootItem,
+ hasContainerRef: !!containerRef,
+ },
+ null,
+ 2,
+ )}
+
+
+
+
+
Manual rendering using hook data:
+
}
+ aria-label="Breadcrumbs"
+ className={overflowClasses.BreadcrumbsBase}
+ data-overflow="menu-with-root"
+ >
+
+ {showRoot && rootItem && (
+
+ {rootItem}
+
+
+ )}
+ {menuItems.length > 0 && (
+
+
+
+
+ )}
+ {visibleItems.map((item, index) => (
+
+ {item}
+
+
+ ))}
+
+
+
+
+
+
+ The useBreadcrumbsResponsive hook allows you to get the computed visible/menu items and manually
+ render the breadcrumbs. This is useful for SSR scenarios where you want to conditionally render different
+ versions for mobile and desktop.
+
+
+
+ )
+}
+
+// Helper component for stories
+const ItemSeparator = () => (
+
+
+
+
+
+)
+
+/**
+ * Demonstrates using the BreadcrumbsOverflowMenu component standalone.
+ */
+export const StandaloneBreadcrumbsOverflowMenu = () => {
+ const items = [
+
+ Home
+ ,
+
+ Products
+ ,
+
+ Category
+ ,
+ ]
+
+ return (
+
+
BreadcrumbsOverflowMenu as a standalone component:
+
+ Click the menu:
+
+
+
+ The BreadcrumbsOverflowMenu component can be used standalone when building custom breadcrumb
+ layouts with the useBreadcrumbsResponsive hook.
+
+
+ )
+}
diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx
new file mode 100644
index 00000000000..e0bc0f47759
--- /dev/null
+++ b/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx
@@ -0,0 +1,57 @@
+import type {Meta} from '@storybook/react-vite'
+import type React from 'react'
+import type {ComponentProps} from '../utils/types'
+import Breadcrumbs from './Breadcrumbs'
+import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu'
+import {FeatureFlags} from '../FeatureFlags'
+
+export default {
+ title: 'Components/Breadcrumbs/Examples',
+ component: Breadcrumbs,
+} as Meta>
+
+export const ExternallyControlled = () => (
+
+ Home
+ Products
+ Category
+ Subcategory
+ Item
+ Details
+
+ Current Page
+
+
+)
+
+export const WithManualOverflow = () => (
+
+
+
+ Home
+ ,
+
+ Products
+ ,
+
+ Category
+ ,
+
+ Subcategory
+ ,
+
+ Item
+ ,
+
+ Details
+ ,
+ ]}
+ />
+
+ Current Page
+
+
+
+)
diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css
index e699fb42587..d98e3de24cf 100644
--- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css
+++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css
@@ -17,6 +17,11 @@
display: flex;
flex-direction: row;
}
+
+ /* Prevent overflow during SSR before JS calculates the overflow menu (responsive mode only) */
+ &[data-responsive='true'] {
+ overflow: hidden;
+ }
}
.ItemSeparator {
diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx
index 5a59efe2f4c..d8e0f27a60b 100644
--- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx
+++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx
@@ -1,16 +1,10 @@
import {clsx} from 'clsx'
import type {To} from 'history'
-import React, {useState, useRef, useCallback, useEffect, useMemo, type ForwardedRef} from 'react'
+import React, {useCallback, type ForwardedRef} from 'react'
import classes from './Breadcrumbs.module.css'
-import Details from '../Details'
-import {ActionList} from '../ActionList'
-import {IconButton} from '../Button/IconButton'
-import {KebabHorizontalIcon} from '@primer/octicons-react'
-import {useResizeObserver} from '../hooks/useResizeObserver'
-import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
-import {useOnEscapePress} from '../hooks/useOnEscapePress'
-import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
import {useFeatureFlag} from '../FeatureFlags'
+import {useBreadcrumbsResponsive} from './useBreadcrumbsResponsive'
+import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu'
export type BreadcrumbsProps = React.PropsWithChildren<{
/**
@@ -34,324 +28,143 @@ export type BreadcrumbsProps = React.PropsWithChildren<{
* Allows passing of CSS custom properties to the breadcrumbs container.
*/
style?: React.CSSProperties
+ /**
+ * Controls whether the component automatically handles responsive overflow behavior.
+ * When true (default), the component will automatically collapse items into an overflow menu
+ * based on available width. When false, children are rendered as-is, allowing manual control
+ * using the `useBreadcrumbsResponsive` hook for SSR-friendly conditional rendering.
+ * @default true
+ */
+ responsive?: boolean
}>
const BreadcrumbsList = ({children}: React.PropsWithChildren) => {
return {children}
}
-type BreadcrumbsMenuItemProps = {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- items: React.ReactElement[]
- 'aria-label'?: string
-}
-
-const BreadcrumbsMenuItem = React.forwardRef(
- ({items, 'aria-label': ariaLabel, ...rest}, menuRefCallback) => {
- const [isOpen, setIsOpen] = useState(false)
- const detailsRef = useRef(null)
- const menuButtonRef = useRef(null)
- const menuContainerRef = useRef(null)
- const detailsRefCallback = useCallback(
- (element: HTMLDetailsElement | null) => {
- detailsRef.current = element
- if (typeof menuRefCallback === 'function') {
- menuRefCallback(element)
- }
- },
- [menuRefCallback],
- )
- const handleSummaryClick = useCallback((event: React.MouseEvent) => {
- // Prevent the button click from bubbling up and interfering with details toggle
- event.preventDefault()
- // Manually toggle the details element
- if (detailsRef.current) {
- const newOpenState = !detailsRef.current.open
- detailsRef.current.open = newOpenState
- setIsOpen(newOpenState)
- }
- }, [])
-
- const closeOverlay = useCallback(() => {
- if (detailsRef.current) {
- detailsRef.current.open = false
- setIsOpen(false)
- }
- }, [])
-
- const focusOnMenuButton = useCallback(() => {
- menuButtonRef.current?.focus()
- }, [])
-
- useOnEscapePress(
- (event: KeyboardEvent) => {
- if (isOpen) {
- event.preventDefault()
- closeOverlay()
- focusOnMenuButton()
- }
- },
- [isOpen],
- )
-
- useOnOutsideClick({
- onClickOutside: closeOverlay,
- containerRef: menuContainerRef,
- ignoreClickRefs: [menuButtonRef],
- })
-
- return (
-
-
-
-
- {items.map((item, index) => {
- const href = item.props.href
- const children = item.props.children
- const selected = item.props.selected
- return (
-
- {children}
-
- )
- })}
-
-
-
- )
- },
-)
-
-BreadcrumbsMenuItem.displayName = 'Breadcrumbs.MenuItem'
-
-const getValidChildren = (children: React.ReactNode) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[]
-}
-
-function Breadcrumbs({className, children, style, overflow = 'wrap', variant = 'normal'}: BreadcrumbsProps) {
+function Breadcrumbs({
+ className,
+ children,
+ style,
+ overflow = 'wrap',
+ variant = 'normal',
+ responsive = true,
+}: BreadcrumbsProps) {
const overflowMenuEnabled = useFeatureFlag('primer_react_breadcrumbs_overflow_menu')
- const wrappedChildren = React.Children.map(children, child => {child} )
- const containerRef = useRef(null)
-
- const measureMenuButton = useCallback((element: HTMLDetailsElement | null) => {
- if (element) {
- const iconButtonElement = element.querySelector('button[data-component="IconButton"]')
- if (iconButtonElement) {
- const measuredWidth = (iconButtonElement as HTMLElement).offsetWidth
- // eslint-disable-next-line react-hooks/immutability
- setMenuButtonWidth(measuredWidth)
- }
- }
- }, [])
-
- const hideRoot = !(overflow === 'menu-with-root')
- const [effectiveHideRoot, setEffectiveHideRoot] = useState(hideRoot)
- const childArray = useMemo(() => getValidChildren(children), [children])
-
- const rootItem = childArray[0]
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const [visibleItems, setVisibleItems] = useState[]>(() => childArray)
- const [childArrayWidths, setChildArrayWidths] = useState(() => [])
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const [menuItems, setMenuItems] = useState[]>([])
- const [rootItemWidth, setRootItemWidth] = useState(0)
-
- const MENU_BUTTON_FALLBACK_WIDTH = 32 // Design system small IconButton
- const [menuButtonWidth, setMenuButtonWidth] = useState(MENU_BUTTON_FALLBACK_WIDTH)
-
- useEffect(() => {
- const listElement = containerRef.current?.querySelector('ol')
- if (
- overflowMenuEnabled &&
- listElement &&
- listElement.children.length > 0 &&
- listElement.children.length === childArray.length
- ) {
- const listElementArray = Array.from(listElement.children) as HTMLElement[]
- const widths = listElementArray.map(child => child.offsetWidth)
- setChildArrayWidths(widths)
- setRootItemWidth(listElementArray[0].offsetWidth)
- }
- }, [childArray, overflowMenuEnabled])
-
- const calculateOverflow = useCallback(
- (availableWidth: number) => {
- let eHideRoot = effectiveHideRoot
- const MENU_BUTTON_WIDTH = menuButtonWidth
- const MIN_VISIBLE_ITEMS = !eHideRoot ? 3 : 4
-
- const calculateVisibleItemsWidth = (w: number[]) => {
- const widths = w.reduce((sum, width) => sum + width + 16, 0)
- return !eHideRoot ? rootItemWidth + widths : widths
- }
-
- let currentVisibleItems = [...childArray]
- let currentVisibleItemWidths = [...childArrayWidths]
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let currentMenuItems: React.ReactElement[] = []
- let currentMenuItemsWidths: number[] = []
-
- if (availableWidth > 0 && currentVisibleItemWidths.length > 0) {
- let visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths)
-
- if (currentMenuItems.length > 0) {
- visibleItemsWidthTotal += MENU_BUTTON_WIDTH
- }
- while (
- (overflow === 'menu' || overflow === 'menu-with-root') &&
- (visibleItemsWidthTotal > availableWidth || currentVisibleItems.length > MIN_VISIBLE_ITEMS)
- ) {
- const itemToHide = currentVisibleItems[0]
- const itemToHideWidth = currentVisibleItemWidths[0]
- currentMenuItems = [...currentMenuItems, itemToHide]
- currentMenuItemsWidths = [...currentMenuItemsWidths, itemToHideWidth]
- currentVisibleItems = currentVisibleItems.slice(1)
- currentVisibleItemWidths = currentVisibleItemWidths.slice(1)
-
- visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths)
- if (currentMenuItems.length > 0) {
- visibleItemsWidthTotal += MENU_BUTTON_WIDTH
- }
-
- if (currentVisibleItems.length === 1 && visibleItemsWidthTotal > availableWidth) {
- eHideRoot = true
- break
- } else {
- eHideRoot = hideRoot
- }
+ // Use the responsive hook for automatic overflow handling
+ const {visibleItems, menuItems, showRoot, rootItem, containerRef, _setMenuButtonWidth} = useBreadcrumbsResponsive({
+ children,
+ overflow,
+ }) as ReturnType & {
+ _setMenuButtonWidth: React.Dispatch>
+ }
+
+ const measureMenuButton = useCallback(
+ (element: HTMLDetailsElement | null) => {
+ if (element) {
+ const iconButtonElement = element.querySelector('button[data-component="IconButton"]')
+ if (iconButtonElement) {
+ const measuredWidth = (iconButtonElement as HTMLElement).offsetWidth
+ _setMenuButtonWidth(measuredWidth)
}
}
- return {
- visibleItems: currentVisibleItems,
- menuItems: currentMenuItems,
- effectiveHideRoot: eHideRoot,
- }
},
- [childArray, childArrayWidths, effectiveHideRoot, hideRoot, overflow, rootItemWidth, menuButtonWidth],
+ [_setMenuButtonWidth],
)
- const handleResize = useCallback(
- (entries: ResizeObserverEntry[]) => {
- if (overflowMenuEnabled && entries[0]) {
- const containerWidth = entries[0].contentRect.width
- const result = calculateOverflow(containerWidth)
- if (
- (visibleItems.length !== result.visibleItems.length && menuItems.length !== result.menuItems.length) ||
- result.effectiveHideRoot !== effectiveHideRoot
- ) {
- setVisibleItems(result.visibleItems)
- setMenuItems(result.menuItems)
- setEffectiveHideRoot(result.effectiveHideRoot)
- }
- }
- },
- [calculateOverflow, effectiveHideRoot, menuItems.length, overflowMenuEnabled, visibleItems.length],
- )
+ // Non-responsive mode: render children as-is
+ if (!responsive) {
+ const childArray = React.Children.toArray(children)
+ const wrappedChildren =
+ overflow === 'menu' || overflow === 'menu-with-root'
+ ? childArray.map((child, index) => (
+
+ {child}
+ {index < childArray.length - 1 && }
+
+ ))
+ : React.Children.map(children, child => {child} )
+ return (
+
+ {wrappedChildren}
+
+ )
+ }
- useResizeObserver(handleResize, containerRef)
+ // Responsive mode with feature flag disabled: render wrapped children
+ if (!overflowMenuEnabled) {
+ const wrappedChildren = React.Children.map(children, child => {child} )
+ return (
+
+ {wrappedChildren}
+
+ )
+ }
- useEffect(() => {
- if (
- overflowMenuEnabled &&
- (overflow === 'menu' || overflow === 'menu-with-root') &&
- childArray.length > 5 &&
- menuItems.length === 0
- ) {
- const containerWidth = containerRef.current?.offsetWidth || 800
- const result = calculateOverflow(containerWidth)
- setVisibleItems(result.visibleItems)
- setMenuItems(result.menuItems)
- setEffectiveHideRoot(result.effectiveHideRoot)
+ // Responsive mode with feature flag enabled: use computed overflow
+ const finalChildren = (() => {
+ if (overflow === 'wrap' || menuItems.length === 0) {
+ return React.Children.map(children, child => {child} )
}
- }, [overflow, childArray, calculateOverflow, menuItems.length, overflowMenuEnabled])
-
- const finalChildren = React.useMemo(() => {
- if (overflowMenuEnabled) {
- if (overflow === 'wrap' || menuItems.length === 0) {
- return React.Children.map(children, child => {child} )
- }
-
- let effectiveMenuItems = [...menuItems]
- // In 'menu-with-root' mode, include the root item inside the menu even if it's visible in the breadcrumbs
- if (!effectiveHideRoot) {
- effectiveMenuItems = [...menuItems.slice(1)]
- }
- const menuElement = (
-
-
-
-
- )
- const visibleElements = visibleItems.map((child, index) => (
-
- {child}
-
-
- ))
+ const menuElement = (
+
+
+
+
+ )
- const rootElement = (
-
- {rootItem}
-
-
- )
+ const visibleElements = visibleItems.map((child, index) => (
+
+ {child}
+
+
+ ))
+
+ const rootElement = (
+
+ {rootItem}
+
+
+ )
- if (effectiveHideRoot) {
- // Show: [overflow menu, leaf breadcrumb]
- return [menuElement, ...visibleElements]
- } else {
- // Show: [root breadcrumb, overflow menu, leaf breadcrumb]
- return [rootElement, menuElement, ...visibleElements]
- }
+ if (!showRoot) {
+ // Show: [overflow menu, leaf breadcrumb]
+ return [menuElement, ...visibleElements]
+ } else {
+ // Show: [root breadcrumb, overflow menu, leaf breadcrumb]
+ return [rootElement, menuElement, ...visibleElements]
}
- }, [overflowMenuEnabled, overflow, menuItems, effectiveHideRoot, measureMenuButton, visibleItems, rootItem, children])
+ })()
- return overflowMenuEnabled ? (
+ return (
}
data-overflow={overflow}
+ data-responsive="true"
data-variant={variant}
>
{finalChildren}
- ) : (
-
- {wrappedChildren}
-
)
}
diff --git a/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx b/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx
new file mode 100644
index 00000000000..94a6c1e2b20
--- /dev/null
+++ b/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx
@@ -0,0 +1,129 @@
+import React, {useState, useRef, useCallback} from 'react'
+import classes from './Breadcrumbs.module.css'
+import Details from '../Details'
+import {ActionList} from '../ActionList'
+import {IconButton} from '../Button/IconButton'
+import {KebabHorizontalIcon} from '@primer/octicons-react'
+import {useOnEscapePress} from '../hooks/useOnEscapePress'
+import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
+
+export type BreadcrumbsOverflowMenuProps = {
+ /**
+ * The breadcrumb items to display in the overflow menu
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ items: React.ReactElement[]
+ /**
+ * Accessible label for the menu button
+ */
+ 'aria-label'?: string
+}
+
+/**
+ * Overflow menu component for Breadcrumbs.
+ * Displays hidden breadcrumb items in a dropdown menu.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const BreadcrumbsOverflowMenu = React.forwardRef(
+ ({items, 'aria-label': ariaLabel, ...rest}, menuRefCallback) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const detailsRef = useRef(null)
+ const menuButtonRef = useRef(null)
+ const menuContainerRef = useRef(null)
+
+ const detailsRefCallback = useCallback(
+ (element: HTMLDetailsElement | null) => {
+ detailsRef.current = element
+ if (typeof menuRefCallback === 'function') {
+ menuRefCallback(element)
+ }
+ },
+ [menuRefCallback],
+ )
+
+ const handleSummaryClick = useCallback((event: React.MouseEvent) => {
+ // Prevent the button click from bubbling up and interfering with details toggle
+ event.preventDefault()
+ // Manually toggle the details element
+ if (detailsRef.current) {
+ const newOpenState = !detailsRef.current.open
+ detailsRef.current.open = newOpenState
+ setIsOpen(newOpenState)
+ }
+ }, [])
+
+ const closeOverlay = useCallback(() => {
+ if (detailsRef.current) {
+ detailsRef.current.open = false
+ setIsOpen(false)
+ }
+ }, [])
+
+ const focusOnMenuButton = useCallback(() => {
+ menuButtonRef.current?.focus()
+ }, [])
+
+ useOnEscapePress(
+ (event: KeyboardEvent) => {
+ if (isOpen) {
+ event.preventDefault()
+ closeOverlay()
+ focusOnMenuButton()
+ }
+ },
+ [isOpen],
+ )
+
+ useOnOutsideClick({
+ onClickOutside: closeOverlay,
+ containerRef: menuContainerRef,
+ ignoreClickRefs: [menuButtonRef],
+ })
+
+ return (
+
+
+
+
+ {items.map((item, index) => {
+ const href = item.props.href
+ const children = item.props.children
+ const selected = item.props.selected
+ return (
+
+ {children}
+
+ )
+ })}
+
+
+
+ )
+ },
+)
+
+BreadcrumbsOverflowMenu.displayName = 'BreadcrumbsOverflowMenu'
diff --git a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
index 54105c764de..b2c6b452af2 100644
--- a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
+++ b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
@@ -548,4 +548,44 @@ describe('Breadcrumbs', () => {
expect(container.firstChild).toHaveAttribute('data-variant', 'spacious')
})
})
+
+ describe('responsive prop', () => {
+ it('renders children as-is when responsive={false}', () => {
+ renderWithTheme(
+
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ Item 6
+ ,
+ {primer_react_breadcrumbs_overflow_menu: true},
+ )
+
+ // All items should be visible when responsive is false
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.getByText('Item 2')).toBeInTheDocument()
+ expect(screen.getByText('Item 3')).toBeInTheDocument()
+ expect(screen.getByText('Item 4')).toBeInTheDocument()
+ expect(screen.getByText('Item 5')).toBeInTheDocument()
+ expect(screen.getByText('Item 6')).toBeInTheDocument()
+
+ // No overflow menu should be rendered
+ expect(screen.queryByRole('button', {name: /more breadcrumb items/i})).not.toBeInTheDocument()
+ })
+
+ it('defaults responsive to true', () => {
+ const {container} = renderWithTheme(
+
+ Home
+ Docs
+ ,
+ {primer_react_breadcrumbs_overflow_menu: true},
+ )
+
+ // Should have data-overflow when responsive is true (default)
+ expect(container.firstChild).toHaveAttribute('data-overflow', 'menu')
+ })
+ })
})
diff --git a/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx b/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx
new file mode 100644
index 00000000000..f7306ee09ba
--- /dev/null
+++ b/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx
@@ -0,0 +1,113 @@
+import {renderHook} from '@testing-library/react'
+import {describe, expect, it} from 'vitest'
+import React from 'react'
+import {useBreadcrumbsResponsive, getValidChildren} from '../useBreadcrumbsResponsive'
+import {BreadcrumbsOverflowMenu} from '../BreadcrumbsOverflowMenu'
+
+// Mock breadcrumb items for testing
+const createMockItems = (count: number) => {
+ return Array.from({length: count}, (_, i) => (
+
+ Item {i + 1}
+
+ ))
+}
+
+describe('useBreadcrumbsResponsive', () => {
+ describe('getValidChildren', () => {
+ it('filters out non-element children', () => {
+ const children = [Valid , null, undefined, 'text', Also Valid
]
+ const result = getValidChildren(children)
+ expect(result).toHaveLength(2)
+ })
+
+ it('returns empty array for no valid children', () => {
+ const result = getValidChildren([null, undefined, 'text'])
+ expect(result).toHaveLength(0)
+ })
+ })
+
+ describe('hook return values', () => {
+ it('returns all children as visibleItems initially for wrap mode', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ overflow: 'wrap',
+ }),
+ )
+
+ expect(result.current.visibleItems).toHaveLength(3)
+ expect(result.current.menuItems).toHaveLength(0)
+ // In 'wrap' mode (not menu-with-root), showRoot is false since we're not using menu overflow
+ expect(result.current.showRoot).toBe(false)
+ })
+
+ it('returns containerRef for attaching to container element', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ overflow: 'menu',
+ }),
+ )
+
+ expect(result.current.containerRef).toBeDefined()
+ expect(result.current.containerRef.current).toBeNull() // Initially null
+ })
+
+ it('returns BreadcrumbsOverflowMenu component', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ overflow: 'menu',
+ }),
+ )
+
+ expect(result.current.BreadcrumbsOverflowMenu).toBe(BreadcrumbsOverflowMenu)
+ })
+
+ it('returns rootItem as the first valid child element', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ overflow: 'menu',
+ }),
+ )
+
+ // Check that rootItem is a valid element with the expected props
+ expect(result.current.rootItem).toBeDefined()
+ expect(result.current.rootItem?.props.href).toBe('/0')
+ })
+ })
+
+ describe('overflow modes', () => {
+ it('defaults overflow to wrap', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ }),
+ )
+
+ // In wrap mode, all items should be visible
+ expect(result.current.visibleItems).toHaveLength(3)
+ expect(result.current.menuItems).toHaveLength(0)
+ })
+
+ it('handles menu-with-root overflow mode', () => {
+ const items = createMockItems(3)
+ const {result} = renderHook(() =>
+ useBreadcrumbsResponsive({
+ children: items,
+ overflow: 'menu-with-root',
+ }),
+ )
+
+ // showRoot should be true for menu-with-root mode
+ expect(result.current.showRoot).toBe(true)
+ })
+ })
+})
diff --git a/packages/react/src/Breadcrumbs/index.ts b/packages/react/src/Breadcrumbs/index.ts
index 1e65b108770..9c488176d73 100644
--- a/packages/react/src/Breadcrumbs/index.ts
+++ b/packages/react/src/Breadcrumbs/index.ts
@@ -1,2 +1,11 @@
export {default, Breadcrumb} from './Breadcrumbs'
export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbsItemProps, BreadcrumbItemProps} from './Breadcrumbs'
+export {useBreadcrumbsResponsive} from './useBreadcrumbsResponsive'
+export type {
+ UseBreadcrumbsResponsiveOptions,
+ UseBreadcrumbsResponsiveReturn,
+ BreadcrumbsOverflowMode,
+ BreadcrumbsItemElement,
+} from './useBreadcrumbsResponsive'
+export {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu'
+export type {BreadcrumbsOverflowMenuProps} from './BreadcrumbsOverflowMenu'
diff --git a/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts b/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts
new file mode 100644
index 00000000000..062429778fa
--- /dev/null
+++ b/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts
@@ -0,0 +1,225 @@
+import React, {useState, useRef, useCallback, useEffect, useMemo} from 'react'
+import {useResizeObserver} from '../hooks/useResizeObserver'
+import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
+import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu'
+
+export type BreadcrumbsOverflowMode = 'wrap' | 'menu' | 'menu-with-root'
+
+export interface UseBreadcrumbsResponsiveOptions {
+ /**
+ * The breadcrumb items to manage
+ */
+ children: React.ReactNode
+ /**
+ * Controls the overflow behavior of the breadcrumbs.
+ * - 'wrap': All items wrap in the given space (no overflow menu)
+ * - 'menu': Overflowing items appear in a dropdown menu
+ * - 'menu-with-root': Like 'menu', but root item stays visible outside the menu
+ * @default 'wrap'
+ */
+ overflow?: BreadcrumbsOverflowMode
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type BreadcrumbsItemElement = React.ReactElement
+
+export interface UseBreadcrumbsResponsiveReturn {
+ /**
+ * Items to display directly in the breadcrumb trail
+ */
+ visibleItems: BreadcrumbsItemElement[]
+ /**
+ * Items to display in the overflow menu
+ */
+ menuItems: BreadcrumbsItemElement[]
+ /**
+ * Whether to show the root item separately (outside menu).
+ * Only relevant when overflow is 'menu-with-root'.
+ */
+ showRoot: boolean
+ /**
+ * The root item element (first child), if available
+ */
+ rootItem: BreadcrumbsItemElement | undefined
+ /**
+ * Ref to attach to the breadcrumbs container for resize observation
+ */
+ containerRef: React.RefObject
+ /**
+ * Pre-built overflow menu component for rendering hidden items
+ */
+ BreadcrumbsOverflowMenu: typeof BreadcrumbsOverflowMenu
+}
+
+/**
+ * Filters React children to only valid elements
+ */
+export const getValidChildren = (children: React.ReactNode): BreadcrumbsItemElement[] => {
+ return React.Children.toArray(children).filter(child => React.isValidElement(child)) as BreadcrumbsItemElement[]
+}
+
+const MENU_BUTTON_FALLBACK_WIDTH = 32 // Design system small IconButton
+
+/**
+ * Hook that manages responsive overflow behavior for Breadcrumbs.
+ *
+ * This hook handles the logic for determining which breadcrumb items should be
+ * visible and which should be collapsed into an overflow menu based on
+ * available container width.
+ *
+ * @example
+ * ```tsx
+ * const { visibleItems, menuItems, showRoot, rootItem, containerRef, BreadcrumbsOverflowMenu } =
+ * useBreadcrumbsResponsive({ children, overflow: 'menu' })
+ *
+ * return (
+ *
+ * {showRoot && rootItem}
+ * {menuItems.length > 0 && }
+ * {visibleItems.map(item => item)}
+ *
+ * )
+ * ```
+ */
+export function useBreadcrumbsResponsive(options: UseBreadcrumbsResponsiveOptions): UseBreadcrumbsResponsiveReturn {
+ const {children, overflow = 'wrap'} = options
+
+ const containerRef = useRef(null)
+ const hideRoot = !(overflow === 'menu-with-root')
+ const [effectiveHideRoot, setEffectiveHideRoot] = useState(hideRoot)
+ const childArray = useMemo(() => getValidChildren(children), [children])
+
+ const rootItem = childArray[0]
+
+ const [visibleItems, setVisibleItems] = useState(() => childArray)
+ const [childArrayWidths, setChildArrayWidths] = useState(() => [])
+ const [menuItems, setMenuItems] = useState([])
+ const [rootItemWidth, setRootItemWidth] = useState(0)
+ const [menuButtonWidth, setMenuButtonWidth] = useState(MENU_BUTTON_FALLBACK_WIDTH)
+
+ // Measure child widths when children change
+ useEffect(() => {
+ const listElement = containerRef.current?.querySelector('ol')
+ if (listElement && listElement.children.length > 0 && listElement.children.length === childArray.length) {
+ const listElementArray = Array.from(listElement.children) as HTMLElement[]
+ const widths = listElementArray.map(child => child.offsetWidth)
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setChildArrayWidths(widths)
+
+ setRootItemWidth(listElementArray[0].offsetWidth)
+ }
+ }, [childArray])
+
+ const calculateOverflow = useCallback(
+ (availableWidth: number) => {
+ let eHideRoot = effectiveHideRoot
+ const MENU_BUTTON_WIDTH = menuButtonWidth
+ const MIN_VISIBLE_ITEMS = !eHideRoot ? 3 : 4
+
+ const calculateVisibleItemsWidth = (w: number[]) => {
+ const widths = w.reduce((sum, width) => sum + width + 16, 0)
+ return !eHideRoot ? rootItemWidth + widths : widths
+ }
+
+ let currentVisibleItems = [...childArray]
+ let currentVisibleItemWidths = [...childArrayWidths]
+ let currentMenuItems: BreadcrumbsItemElement[] = []
+ let currentMenuItemsWidths: number[] = []
+
+ if (availableWidth > 0 && currentVisibleItemWidths.length > 0) {
+ let visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths)
+
+ if (currentMenuItems.length > 0) {
+ visibleItemsWidthTotal += MENU_BUTTON_WIDTH
+ }
+ while (
+ (overflow === 'menu' || overflow === 'menu-with-root') &&
+ (visibleItemsWidthTotal > availableWidth || currentVisibleItems.length > MIN_VISIBLE_ITEMS)
+ ) {
+ const itemToHide = currentVisibleItems[0]
+ const itemToHideWidth = currentVisibleItemWidths[0]
+ currentMenuItems = [...currentMenuItems, itemToHide]
+ currentMenuItemsWidths = [...currentMenuItemsWidths, itemToHideWidth]
+ currentVisibleItems = currentVisibleItems.slice(1)
+ currentVisibleItemWidths = currentVisibleItemWidths.slice(1)
+
+ visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths)
+
+ if (currentMenuItems.length > 0) {
+ visibleItemsWidthTotal += MENU_BUTTON_WIDTH
+ }
+
+ if (currentVisibleItems.length === 1 && visibleItemsWidthTotal > availableWidth) {
+ eHideRoot = true
+ break
+ } else {
+ eHideRoot = hideRoot
+ }
+ }
+ }
+ return {
+ visibleItems: currentVisibleItems,
+ menuItems: currentMenuItems,
+ effectiveHideRoot: eHideRoot,
+ }
+ },
+ [childArray, childArrayWidths, effectiveHideRoot, hideRoot, overflow, rootItemWidth, menuButtonWidth],
+ )
+
+ const handleResize = useCallback(
+ (entries: ResizeObserverEntry[]) => {
+ if (entries[0]) {
+ const containerWidth = entries[0].contentRect.width
+ const result = calculateOverflow(containerWidth)
+ if (
+ (visibleItems.length !== result.visibleItems.length && menuItems.length !== result.menuItems.length) ||
+ result.effectiveHideRoot !== effectiveHideRoot
+ ) {
+ setVisibleItems(result.visibleItems)
+ setMenuItems(result.menuItems)
+ setEffectiveHideRoot(result.effectiveHideRoot)
+ }
+ }
+ },
+ [calculateOverflow, effectiveHideRoot, menuItems.length, visibleItems.length],
+ )
+
+ useResizeObserver(handleResize, containerRef)
+
+ // Initial overflow calculation for many items
+ useEffect(() => {
+ if ((overflow === 'menu' || overflow === 'menu-with-root') && childArray.length > 5 && menuItems.length === 0) {
+ const containerWidth = containerRef.current?.offsetWidth || 800
+ const result = calculateOverflow(containerWidth)
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setVisibleItems(result.visibleItems)
+
+ setMenuItems(result.menuItems)
+
+ setEffectiveHideRoot(result.effectiveHideRoot)
+ }
+ }, [overflow, childArray, calculateOverflow, menuItems.length])
+
+ // Compute effective menu items based on hideRoot setting
+ const effectiveMenuItems = useMemo(() => {
+ if (overflow === 'wrap' || menuItems.length === 0) {
+ return []
+ }
+ // In 'menu-with-root' mode, exclude root from menu since it's shown separately
+ if (!effectiveHideRoot) {
+ return menuItems.slice(1)
+ }
+ return menuItems
+ }, [overflow, menuItems, effectiveHideRoot])
+
+ return {
+ visibleItems,
+ menuItems: effectiveMenuItems,
+ showRoot: !effectiveHideRoot,
+ rootItem,
+ containerRef,
+ BreadcrumbsOverflowMenu,
+ // Internal setter for menu button width measurement (used by Breadcrumbs component)
+ _setMenuButtonWidth: setMenuButtonWidth,
+ } as UseBreadcrumbsResponsiveReturn & {_setMenuButtonWidth: React.Dispatch>}
+}
diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
index 77f3b55f2a2..bb757c95bef 100644
--- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -38,7 +38,11 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"type BreadcrumbItemProps",
"type BreadcrumbProps",
"Breadcrumbs",
+ "type BreadcrumbsItemElement",
"type BreadcrumbsItemProps",
+ "BreadcrumbsOverflowMenu",
+ "type BreadcrumbsOverflowMenuProps",
+ "type BreadcrumbsOverflowMode",
"type BreadcrumbsProps",
"Button",
"ButtonBase",
@@ -210,6 +214,9 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"type UnderlineNavItemProps",
"type UnderlineNavProps",
"useAnchoredPosition",
+ "useBreadcrumbsResponsive",
+ "type UseBreadcrumbsResponsiveOptions",
+ "type UseBreadcrumbsResponsiveReturn",
"useColorSchemeVar",
"useConfirm",
"useDetails",
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index b3b6a1ca40d..ace7a8e986d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -80,8 +80,18 @@ export type {BannerProps} from './Banner'
export {default as BranchName} from './BranchName'
export type {BranchNameProps} from './BranchName'
-export {default as Breadcrumbs, Breadcrumb} from './Breadcrumbs'
-export type {BreadcrumbsProps, BreadcrumbsItemProps, BreadcrumbProps, BreadcrumbItemProps} from './Breadcrumbs'
+export {default as Breadcrumbs, Breadcrumb, useBreadcrumbsResponsive, BreadcrumbsOverflowMenu} from './Breadcrumbs'
+export type {
+ BreadcrumbsProps,
+ BreadcrumbsItemProps,
+ BreadcrumbProps,
+ BreadcrumbItemProps,
+ UseBreadcrumbsResponsiveOptions,
+ UseBreadcrumbsResponsiveReturn,
+ BreadcrumbsOverflowMode,
+ BreadcrumbsItemElement,
+ BreadcrumbsOverflowMenuProps,
+} from './Breadcrumbs'
export {default as ButtonGroup} from './ButtonGroup'
export type {ButtonGroupProps} from './ButtonGroup'
export type {CircleBadgeProps, CircleBadgeIconProps} from './CircleBadge'
diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs
index 2b934a53fd3..f952a0a7a9d 100755
--- a/script/check-classname-tests.mjs
+++ b/script/check-classname-tests.mjs
@@ -23,6 +23,7 @@ const IGNORED_FILES = [
'packages/react/src/__tests__/ThemeProvider.test.tsx',
'packages/react/src/__tests__/deprecated/ActionMenu.test.tsx',
'packages/react/src/__tests__/Caret.test.tsx',
+ 'packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx',
]
function getAllTestFiles(dir, files = []) {