Skip to content

[docs] Container-aware Breadcrumbs experiment - #48964

Draft
LukasTy wants to merge 1 commit into
mui:masterfrom
LukasTy:claude/collapsible-breadcrumbs-container-532a13
Draft

[docs] Container-aware Breadcrumbs experiment#48964
LukasTy wants to merge 1 commit into
mui:masterfrom
LukasTy:claude/collapsible-breadcrumbs-container-532a13

Conversation

@LukasTy

@LukasTy LukasTy commented Aug 14, 2026

Copy link
Copy Markdown
Member

Draft. Docs-only: adds docs/pages/experiments/responsive-breadcrumbs.tsx. No package change yet.

The experiment is a proof of concept for making Breadcrumbs collapse to fit its container instead of a fixed item count. The design and the implementation plan for the component change are below, for discussion before any code lands in packages/.

Problem

maxItems collapses on item count. It cannot know whether the row fits. Today a path that exceeds its container wraps to a second line, because flexWrap: 'wrap' is hardcoded on the <ol>.

What the experiment does

A userland ResponsiveBreadcrumbs wrapper around the published component, no patching. It measures the items in an off-screen mirror, picks the widest layout that still fits, and puts the dropped items in a menu.

It does not use the built-in collapse at all. It slices the children itself and passes an IconButton as an ordinary Breadcrumbs child, the way the condensed-with-menu demo does.

Run it with pnpm --filter docs dev, then open /experiments/responsive-breadcrumbs.

What the experiment proves

Verified in headless Chromium across 68 container widths, 900px down to 230px:

Check Result
Rungs reachable 8, 7, 6, 5, 4, 3, 2 -- one item at a time
Rows overflowing their container 0 / 68
Resize after opening the menu recollapses every time: 8 -> 6 -> 2 -> 8 -> 2, all fit
Menu holds exactly the hidden items Catalog, Accessories, Bags, Backpacks
Menu items are real links 4 anchors with href
ArrowDown reaches a menuitem yes
Page errors none

Findings that shape the design

  1. maxItems is a switch, not a dial. Once the count passes the threshold, the component always renders itemsBeforeCollapse + ellipsis + itemsAfterCollapse. Tuning maxItems alone can never drop items gradually. The experiment drives itemsAfterCollapse down a ladder instead.
  2. expanded is one way. It is internal state that never resets, so after a click the row overflows its container again and userland cannot undo it. Container-driven collapsing and expand-in-place are incompatible: expanding is exactly the thing that breaks the fit.
  3. A children-based API makes the overflow menu guesswork. Hidden items are opaque nodes, so the wrapper reads href off each child to keep the menu item a real link, then wraps it in <li role="none"> because an <a> cannot be a direct child of the menu <ul>.
  4. Measuring from outside needs a duplicate DOM tree. Collapsed items leave the DOM, so the wrapper renders every child twice. The component itself would not need this -- see the plan.
  5. The floor can still overflow. Once itemsBeforeCollapse + itemsAfterCollapse items remain, dropping stops. A narrower container overflows, because nothing truncates label text.
  6. Menu's scroll lock fights the measurement. Opening a default Menu pads the body, which resizes the container and recomputes the layout under the open menu. Needs disableScrollLock.

Design

API

maxItems?: number | 'auto';

'auto' means "fit the container". itemsBeforeCollapse and itemsAfterCollapse keep their meaning and become the floor. Reusing the existing prop keeps the API one value wider instead of adding a second, conflicting knob.

Behavior in 'auto' mode

  • The <ol> switches to flex-wrap: nowrap.
  • The component picks the widest layout that fits, from a ladder: all items, then itemsAfterCollapse walking down from count - 1 - itemsBeforeCollapse to its floor. The first rung that fits wins.
  • A ResizeObserver on the root recomputes on container resize.
  • expanded is not used. There is no expand-in-place, so there is no stuck state.

The collapse indicator

This is the open question, and the reason this PR is a draft.

Container-driven collapsing makes the current ellipsis-expands-in-place behavior incoherent, so 'auto' needs a different affordance. An overflow menu is the natural one, and it is what the experiment uses. But hardcoding Menu inside Breadcrumbs pulls in Popover and Modal. Breadcrumbs currently imports only Typography and ButtonBase, so that is a real bundle-size regression for every consumer.

Recommendation: do not hardcode the menu. Promote the collapse indicator to a real slot that receives the hidden items, ship the ellipsis button as the default, and document the menu as the recommended pattern.

slots?: {
  CollapsedIcon?: React.ElementType;
  collapsed?: React.ElementType;   // new, receives ownerState.hiddenItems
};

That keeps the weight opt-in and makes the experiment's wrapper expressible in ~20 lines instead of a fork. If reviewers would rather ship a batteries-included menu, that is a separate decision and I am happy to take it the other way.

Measurement

The component can do this better than any wrapper. On first layout it renders uncollapsed, measures every item in a useLayoutEffect, and commits the collapsed layout before paint -- no flash, and no duplicate DOM tree. Widths are cached per item and re-measured when the children change or the fonts load.

Accessibility

  • The hidden items stay reachable through the collapse slot.
  • Focus is not moved on resize.
  • Links in the overflow menu must stay real anchors, wrapped in <li role="none">.

Implementation plan

1. Layout mechanics -- packages/mui-material/src/Breadcrumbs/

  • Accept maxItems="auto"; widen the type and integerPropType to a union.
  • Add an internal useBreadcrumbsFit hook: ladder construction, measurement, ResizeObserver, chosen rung.
  • Apply flexWrap: 'nowrap' to BreadcrumbsOl in auto mode only, so existing behavior is untouched.
  • Derive the available width from the fractional rect, not clientWidth. clientWidth is integer-rounded and hides sub-pixel overflow -- this cost me a real bug in the experiment, a 636.04px row sitting in a 636px box.

2. Collapse slot

  • Add slots.collapsed / slotProps.collapsed through useSlotProps.
  • Put the hidden items on ownerState so the slot can render them.
  • Keep BreadcrumbCollapsed as the default, unchanged for the non-auto path.

3. Tests

  • Unit (Breadcrumbs.test.js, jsdom): ladder selection and slot wiring, with stubbed rects. jsdom has no layout, so the fit logic itself cannot be tested there.
  • e2e (test/e2e, Playwright): the real assertions -- every rung reachable, no row overflows, layout recomputes on resize. This mirrors the verification already run against the experiment.

4. Docs

  • New demo on the Breadcrumbs page for the auto mode.
  • A menu-based demo using slots.collapsed, replacing the hand-rolled condensed-with-menu example.

Out of scope

Text truncation. It is the only way to go below the floor, it changes what a breadcrumb is, and it should be its own discussion.

Notes for reviewers

  • Docs-only, so no changelog entry.
  • .claude/launch.json is intentionally not committed.

Add an experiment that collapses Breadcrumbs to fit its container.

The wrapper does not use the built-in collapse. maxItems is a switch, not
a dial: above the threshold the component always renders the same
three-slot layout, and its expanded state never resets. So the wrapper
slices the children itself and passes a menu button as an ordinary child,
the way the condensed-with-menu demo does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@code-infra-dashboard

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-48964--material-ui.netlify.app/
QR code for https://deploy-preview-48964--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 0B(0.00%) 0B(0.00%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@LukasTy LukasTy added the RFC Request For Comments. label Aug 14, 2026
@LukasTy LukasTy self-assigned this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFC Request For Comments.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant