diff --git a/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx b/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
new file mode 100644
index 000000000000..dfe9a35bf72f
--- /dev/null
+++ b/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
@@ -0,0 +1,37 @@
+import type {Responsive} from '@sentry/scraps/layout';
+import {Container, Flex} from '@sentry/scraps/layout';
+
+import {IconSlashForward} from 'sentry/icons';
+
+interface BreadcrumbDividerComboProps {
+ children: React.ReactNode;
+ /** Controls visibility — use responsive values for container-query toggling. */
+ display?: Responsive<'flex' | 'none'>;
+}
+
+/**
+ * Internal wrapper that pairs a breadcrumb item with a trailing slash divider.
+ * Not exported — only BreadcrumbList should use this to ensure consistent structure.
+ *
+ * The visibility toggle lives on a `Container`, not the inner `Flex`: `Flex`
+ * defaults every unspecified breakpoint/axis slot to `flex`, which emits an
+ * always-matching `@media (min-width: 0px)` rule that shadows the container
+ * query and pins the element visible. `Container` skips unspecified slots, so
+ * the `@container` query actually drives the collapse.
+ */
+export function BreadcrumbDividerCombo({children, display}: BreadcrumbDividerComboProps) {
+ return (
+ // flexShrink={999} makes parent crumbs give up width first, so the current
+ // page (a much lower shrink) truncates last. These wrappers keep the default
+ // min-width:auto (no min-width:0) so they can't collapse past the crumb's own
+ // floor — set on the item's outer Flex — down to 0 when the row is very tight.
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/static/app/components/breadcrumbList/breadcrumbList.mdx b/static/app/components/breadcrumbList/breadcrumbList.mdx
new file mode 100644
index 000000000000..3dcfece30a25
--- /dev/null
+++ b/static/app/components/breadcrumbList/breadcrumbList.mdx
@@ -0,0 +1,246 @@
+---
+title: BreadcrumbList
+description: A typed, accessible breadcrumb trail that collapses parent links into an overflow menu in narrow containers.
+category: navigation
+source: 'sentry/components/breadcrumbList'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/breadcrumbList/breadcrumbList.tsx
+ figma: https://www.figma.com/design/a638AEl7pFxj29zMODCiOB/Specs--Page-Frame-v1?node-id=1198-17420
+---
+
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
+
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
+import {IconSettings, IconStar} from 'sentry/icons';
+import * as Storybook from 'sentry/stories';
+
+export const documentation =
+ import('!!type-loader!sentry/components/breadcrumbList/breadcrumbList');
+
+`BreadcrumbList` renders a horizontal breadcrumb trail as inline content. It is designed to sit inside the page's heading (e.g. the TopBar title `
`), which owns the landmark and heading semantics — so the trail itself renders no `` landmark and the current-page crumb is inline text, not its own heading. Each slot in the trail has an explicit `type` — developers must consciously choose a variant rather than having it inferred from position. At container widths below 800px, parent link crumbs automatically collapse into a single overflow (`…`) menu via a container query, keeping the page title always visible.
+
+## Usage
+
+```tsx
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
+```
+
+Pass a typed `items` array. Each entry is a discriminated union with a `type` key and a `props` object matching that type's interface.
+
+
+
+
+```tsx
+
+```
+
+## Item types
+
+### `'link'` — parent page
+
+A clickable, muted-weight crumb that navigates to a parent page. Use for every item except the current page. Set `preservePageFilters` to carry project/environment/date filters across the navigation. Labels are truncated at 132px and wrapped in a tooltip showing the full text.
+
+
+
+
+```tsx
+{type: 'link', props: {label: 'Projects', to: '/organizations/sentry/projects/'}}
+{type: 'link', props: {label: 'Issues', to: '/issues/', preservePageFilters: true}}
+```
+
+### `'page-title'` — current page
+
+The final crumb — bold, non-interactive, and always visible. Represents the page the user is currently on. Labels are truncated at 200px and wrapped in a tooltip showing the full text.
+
+
+
+
+```tsx
+{type: 'page-title', props: {label: 'TypeError: Cannot read properties of undefined'}}
+```
+
+### `'select-projects'` — project picker
+
+A `CompactSelect` trigger used in settings pages where the user can switch between projects inline. Accepts standard `options`, `value`, and `onChange` props.
+
+
+ {},
+ },
+ },
+ {type: 'page-title', props: {label: 'Client Keys (DSN)'}},
+ ]}
+ />
+
+```tsx
+{
+ type: 'select-projects',
+ props: {
+ value: 'javascript',
+ options: [{value: 'javascript', label: 'javascript'}, {value: 'python', label: 'python'}],
+ onChange: option => navigate(`/settings/${org.slug}/projects/${option.value}/`),
+ },
+}
+```
+
+## Leading graphics
+
+```tsx
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
+```
+
+All item types accept an optional `leadingGraphic` prop typed as `React.ReactElement`. Pass a ` ` element — platform icons, Sentry icons, or avatars.
+
+
+ } />
+ ),
+ },
+ },
+ {
+ type: 'link',
+ props: {
+ label: 'javascript',
+ to: '/settings/sentry/projects/javascript/',
+ leadingGraphic: (
+
+ ),
+ },
+ },
+ {
+ type: 'page-title',
+ props: {
+ label: 'Client Keys (DSN)',
+ leadingGraphic: } />,
+ },
+ },
+ ]}
+ />
+
+```tsx leadingGraphic=
+{ } />}
+leadingGraphic=
+{ }
+leadingGraphic={ } />}
+```
+
+## Pagination
+
+The `'page-title'` item accepts an optional `pagination` prop for pages where the user can step through a list of items (e.g. issues, replays, traces). Pass a `{previous, next}` object — each with a `to` URL, `ariaLabel`, optional `disabled` flag, and an optional rich `tooltip`. The two chevron buttons are rendered internally.
+
+
+
+
+```tsx pagination=
+{{
+ previous: {ariaLabel: 'Previous issue', to: '/issues/122/'},
+ next: {ariaLabel: 'Next issue', to: '/issues/124/', disabled: true},
+}}
+```
+
+## Trailing actions
+
+The `'page-title'` item also accepts a `trailingActions` slot for up to 52px of action buttons rendered after the label — e.g. a bookmark or share button.
+
+```tsx
+trailingActions={
+ } aria-label="Bookmark" />
+}
+```
+
+## Overflow (container query)
+
+`BreadcrumbList` sets `containerType="inline-size"` on its outer element. When that container is narrower than 800px, every parent item is hidden and only the `'page-title'` stays visible. `'link'` parents are additionally replaced with a single `…` overflow button; other parent types (e.g. `'select-projects'`) simply collapse out of view — they are not added to the overflow menu.
+
+Drag the sizing handle below to see the transition:
+
+
+
+
diff --git a/static/app/components/breadcrumbList/breadcrumbList.spec.tsx b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
new file mode 100644
index 000000000000..7a4a2b61d057
--- /dev/null
+++ b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
@@ -0,0 +1,216 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
+
+function collectCssRules(): string[] {
+ const rules: string[] = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ let cssRules: CSSRuleList;
+ try {
+ cssRules = sheet.cssRules;
+ } catch {
+ continue;
+ }
+ for (const rule of Array.from(cssRules)) {
+ rules.push(rule.cssText);
+ }
+ }
+ return rules;
+}
+
+/** CSS rule texts (from inserted stylesheets) that target any of `element`'s classes. */
+function rulesForElement(element: Element): string[] {
+ const classes = (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
+ return collectCssRules().filter(r => classes.some(c => r.includes(`.${c}`)));
+}
+
+/**
+ * True when `element` carries the "hide below sm" container-query toggle:
+ * a base `display: none` plus an `@container (min-width: 800px) { display: flex }`
+ * that reveals it only in wide containers.
+ */
+function hidesBelowSm(element: Element): boolean {
+ const classes = (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
+ const own = collectCssRules().filter(r => classes.some(c => r.includes(`.${c}`)));
+ const hasBaseNone = own.some(
+ r => !r.includes('@container') && /display:\s*none/.test(r)
+ );
+ const revealsAtSm = own.some(
+ r => /@container[^{]*min-width:\s*800px/.test(r) && /display:\s*flex/.test(r)
+ );
+ return hasBaseNone && revealsAtSm;
+}
+
+describe('BreadcrumbList container-query collapse', () => {
+ let consoleError: jest.SpyInstance;
+
+ beforeEach(() => {
+ // Known pre-existing issue: the `containerType` prop leaks a `containertype`
+ // attribute onto the DOM node, which React warns about. That's a bug in the
+ // core Container primitive, unrelated to the collapse behavior under test —
+ // tolerate exactly that warning and re-throw anything else.
+ consoleError = jest.spyOn(console, 'error').mockImplementation((...args) => {
+ // React formats warnings with %s placeholders, so the offending prop name
+ // ("containerType") lands in a later arg — check them all.
+ if (args.some(arg => typeof arg === 'string' && arg.includes('containerType'))) {
+ return;
+ }
+ throw new Error(`Unexpected console.error: ${args.map(String).join(' ')}`);
+ });
+ });
+
+ afterEach(() => {
+ consoleError.mockRestore();
+ });
+
+ it('emits an @container display rule for link crumbs, not an always-on @media shadow', () => {
+ render(
+
+ );
+
+ // Presence check — the list renders (as inline content, not a landmark).
+ expect(screen.getByTestId('breadcrumb-list')).toBeInTheDocument();
+
+ const rules = collectCssRules();
+
+ // The list establishes an inline-size query container.
+ // (jsdom's getComputedStyle can't read `container-type`, so assert the rule.)
+ expect(rules.some(r => /container-type:\s*inline-size/.test(r))).toBe(true);
+
+ // The collapse must be driven by a container query at the sm (800px) breakpoint.
+ expect(rules.some(r => /@container[^{]*min-width:\s*800px/.test(r))).toBe(true);
+
+ // Regression guard: the buggy path routed the show/hide toggle through `Flex`,
+ // whose display resolver defaulted every unspecified slot to `flex`. That
+ // emitted an always-matching `@media (min-width: 0px) { display: flex }` that
+ // shadowed the container query and pinned link crumbs visible. It must not exist.
+ const alwaysOnMediaFlex = rules.some(
+ r => /@media[^{]*min-width:\s*0px/.test(r) && /display:\s*flex/.test(r)
+ );
+ expect(alwaysOnMediaFlex).toBe(false);
+ });
+
+ it('renders the current page as inline text and hides dividers from AT', () => {
+ render(
+
+ );
+
+ // The current-page crumb renders as inline text, not a heading — the
+ // surrounding context (e.g. the TopBar title ) owns the page heading, so
+ // the crumb's label must not surface as its own heading.
+ expect(screen.getByTestId('breadcrumb-item')).toHaveTextContent('General');
+ expect(screen.queryByRole('heading', {name: 'General'})).not.toBeInTheDocument();
+
+ // Parent links must not be marked current.
+ expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
+ 'aria-current'
+ );
+
+ // The decorative slash dividers are hidden from the accessibility tree.
+ const dividers = document.querySelectorAll('svg[role="img"]:not([aria-hidden])');
+ expect(dividers).toHaveLength(0);
+ });
+
+ it('gives the select-projects trigger a descriptive accessible name', async () => {
+ render(
+ {},
+ },
+ },
+ {type: 'page-title', props: {label: 'Client Keys'}},
+ ]}
+ />
+ );
+
+ // The trigger names both its purpose and the current selection.
+ // findBy lets CompactSelect's deferred mount-time state update flush in act.
+ expect(
+ await screen.findByRole('button', {name: 'Selected Project: javascript'})
+ ).toBeInTheDocument();
+ });
+
+ it('collapses non-link parents (select-projects) below the sm breakpoint', async () => {
+ render(
+ {},
+ },
+ },
+ {type: 'page-title', props: {label: 'Client Keys'}},
+ ]}
+ />
+ );
+
+ // The wrapping the project picker hides below 800px, same as link crumbs.
+ const trigger = await screen.findByRole('button', {
+ name: 'Selected Project: javascript',
+ });
+ const selectItem = trigger.closest('li');
+ expect(selectItem).not.toBeNull();
+ expect(hidesBelowSm(selectItem!)).toBe(true);
+
+ // The last crumb is not wrapped in a hiding — it always stays visible.
+ const current = screen.getByTestId('breadcrumb-item');
+ expect(current).toHaveTextContent('Client Keys');
+ expect(hidesBelowSm(current.closest('li')!)).toBe(false);
+ });
+
+ it('gives crumbs a visible-width floor and never collapses them to 0', () => {
+ render(
+
+ );
+
+ const link = screen.getByTestId('breadcrumb-link');
+ const current = screen.getByTestId('breadcrumb-item');
+ const parentLi = link.closest('li')!;
+
+ // The fixed max-width caps are gone, so labels size to content when there's
+ // room. (jsdom can't compute layout — this guards the CSS intent, not pixels.)
+ expect(rulesForElement(link).some(r => /max-width:\s*132px/.test(r))).toBe(false);
+ expect(rulesForElement(current).some(r => /max-width:\s*200px/.test(r))).toBe(false);
+
+ // Regression guard: the parent must not carry min-width:0 — that let it
+ // collapse to 0 width when the current page's label was very long.
+ expect(rulesForElement(parentLi).join(' ')).not.toContain('min-width: 0');
+
+ // A positive-px min-width floor is emitted so a crumb never shrinks to nothing.
+ expect(collectCssRules().some(r => /min-width:\s*[1-9]\d*px/.test(r))).toBe(true);
+
+ // Parents give up width first (high flex-shrink) so the current page truncates last.
+ expect(rulesForElement(parentLi).some(r => /flex-shrink:\s*999/.test(r))).toBe(true);
+ });
+});
diff --git a/static/app/components/breadcrumbList/breadcrumbList.tsx b/static/app/components/breadcrumbList/breadcrumbList.tsx
new file mode 100644
index 000000000000..3837a503d1e4
--- /dev/null
+++ b/static/app/components/breadcrumbList/breadcrumbList.tsx
@@ -0,0 +1,116 @@
+import {Container, Flex} from '@sentry/scraps/layout';
+
+import type {BreadcrumbItemLinkProps} from './items/breadcrumbItemLink';
+import {BreadcrumbItemLink} from './items/breadcrumbItemLink';
+import {BreadcrumbItemMenuBreadcrumbs} from './items/breadcrumbItemMenuBreadcrumbs';
+import type {BreadcrumbItemPageTitleProps} from './items/breadcrumbItemPageTitle';
+import {BreadcrumbItemPageTitle} from './items/breadcrumbItemPageTitle';
+import type {BreadcrumbItemSelectProjectsProps} from './items/breadcrumbItemSelectProjects';
+import {BreadcrumbItemSelectProjects} from './items/breadcrumbItemSelectProjects';
+import {BreadcrumbDividerCombo} from './breadcrumbDividerCombo';
+
+/** @public Public API of the redesigned breadcrumbs; consumers migrate onto it in a downstream PR. */
+export type BreadcrumbItem =
+ | {props: BreadcrumbItemLinkProps; type: 'link'}
+ | {props: BreadcrumbItemPageTitleProps; type: 'page-title'}
+ | {props: BreadcrumbItemSelectProjectsProps; type: 'select-projects'};
+
+/** @public Public API of the redesigned breadcrumbs; consumers migrate onto it in a downstream PR. */
+export interface BreadcrumbListProps extends React.HTMLAttributes {
+ items: BreadcrumbItem[];
+}
+
+function renderItem(item: BreadcrumbItem) {
+ switch (item.type) {
+ case 'link':
+ return ;
+ case 'page-title':
+ return ;
+ case 'select-projects':
+ return ;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Renders a horizontal breadcrumb trail. Uses a container query to collapse
+ * parent link crumbs into an overflow (…) menu when the container is narrow
+ * (below the 'sm' breakpoint — 800px).
+ *
+ * Consumers pass a typed `items` array so each breadcrumb slot has an explicit
+ * variant — no implicit inference based on position.
+ *
+ * Overflow behaviour:
+ * - Wide (≥ 800px): all items render individually
+ * - Narrow (< 800px): every parent item is hidden, leaving only the last crumb.
+ * 'link' parents additionally collapse into a single BreadcrumbItemMenuBreadcrumbs
+ * overflow button; non-link parents (e.g. 'select-projects') just hide.
+ *
+ * @public Consumed once call sites migrate onto the typed API in a downstream PR.
+ */
+export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
+ if (items.length === 0) {
+ return null;
+ }
+
+ const lastItem = items[items.length - 1]!;
+ const parentItems = items.slice(0, -1);
+
+ // Collect link items for the overflow menu (narrow layout)
+ const collapsibleLinkItems = parentItems.filter(
+ (item): item is Extract => item.type === 'link'
+ );
+ const menuItems = collapsibleLinkItems.map(item => ({
+ label: item.props.label,
+ to: item.props.to,
+ }));
+
+ // Responsive display values using container queries (bare breakpoint keys):
+ // '2xs' is the smallest breakpoint → applies as the base
+ // 'sm' = 800px → overrides at container width ≥ 800px
+ const showWide = {sm: 'flex', '2xs': 'none'} as const;
+ const showNarrow = {sm: 'none', '2xs': 'flex'} as const;
+
+ return (
+ // Renders as inline content (no landmark, no own heading): breadcrumbs
+ // are placed inside the page heading (e.g. the TopBar title ), which owns
+ // the landmark/heading semantics. A `` here would both nest
+ // invalidly and override that 's accessible name.
+
+ {/*
+ * The query container is this inner element, not the above. emotion's
+ * `as` swap on a styled component bypasses the wrapper that strips
+ * `containerType`, so pairing `as="nav"` with `containerType` would leak the
+ * prop onto the DOM node. Keeping `containerType` on an un-swapped Container
+ * (rendered as a plain div) lets the primitive strip it as intended while
+ * still establishing the container at full width — the @container collapse
+ * below resolves against it either way.
+ */}
+
+
+ {parentItems.map((item, index) => (
+ // Wide: show every parent item. Narrow: hide them all — 'link' parents
+ // reappear in the overflow menu below; other types (e.g. 'select-projects')
+ // simply collapse out of view.
+
+ {renderItem(item)}
+
+ ))}
+
+ {/* Overflow menu — only visible in narrow layout when there are link items to collapse */}
+ {menuItems.length > 0 && (
+
+
+
+ )}
+
+ {/* Page title — always visible, no divider after it */}
+
+ {renderItem(lastItem)}
+
+
+
+
+ );
+}
diff --git a/static/app/components/breadcrumbList/index.tsx b/static/app/components/breadcrumbList/index.tsx
new file mode 100644
index 000000000000..b7e3b6b00efd
--- /dev/null
+++ b/static/app/components/breadcrumbList/index.tsx
@@ -0,0 +1,3 @@
+export {BreadcrumbList} from './breadcrumbList';
+/** @public Consumed once call sites migrate onto the typed API in a downstream PR. */
+export type {BreadcrumbItem, BreadcrumbListProps} from './breadcrumbList';
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
new file mode 100644
index 000000000000..2f384f956a50
--- /dev/null
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
@@ -0,0 +1,72 @@
+import {Container, Flex} from '@sentry/scraps/layout';
+import type {LeadingGraphicProps} from '@sentry/scraps/leadingGraphic';
+import type {LinkProps} from '@sentry/scraps/link';
+import {Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
+import {Tooltip} from '@sentry/scraps/tooltip';
+
+import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useLocation} from 'sentry/utils/useLocation';
+
+export interface BreadcrumbItemLinkProps {
+ label: string;
+ to: LinkProps['to'];
+ /** Renders a tooltip showing the full label — useful when text is truncated. Defaults to true. */
+ labelTooltip?: boolean;
+ leadingGraphic?: React.ReactElement;
+ preservePageFilters?: boolean;
+}
+
+export function BreadcrumbItemLink({
+ label,
+ to,
+ labelTooltip = true,
+ leadingGraphic,
+ preservePageFilters,
+}: BreadcrumbItemLinkProps) {
+ const location = useLocation();
+
+ function handleClick() {
+ trackAnalytics('breadcrumbs.link.clicked', {organization: null});
+ }
+
+ const toWithQuery =
+ preservePageFilters && to
+ ? typeof to === 'string'
+ ? {pathname: to, query: extractSelectionParameters(location.query)}
+ : {
+ ...to,
+ query: {
+ ...extractSelectionParameters(location.query),
+ ...(typeof to === 'object' && to !== null && 'query' in to ? to.query : {}),
+ },
+ }
+ : to;
+
+ return (
+
+ {leadingGraphic}
+ {/* style={{minWidth: 0}} unblocks the Tooltip's wrapper (a flex item
+ whose default min-width:auto would otherwise refuse to shrink). */}
+
+ {/* The visible-width floor lives on the outer Flex above (min-width: 32px).
+ Here the label just fills that floored space and ellipsizes within it. */}
+
+ {styleProps => (
+
+
+ {label}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemMenuBreadcrumbs.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemMenuBreadcrumbs.tsx
new file mode 100644
index 000000000000..0bf3fd52c98c
--- /dev/null
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemMenuBreadcrumbs.tsx
@@ -0,0 +1,51 @@
+import {Flex} from '@sentry/scraps/layout';
+import type {LinkProps} from '@sentry/scraps/link';
+
+import {DropdownButton} from 'sentry/components/dropdownButton';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import {IconEllipsis} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+interface BreadcrumbMenuLinkItem {
+ label: string;
+ to: LinkProps['to'];
+}
+
+interface BreadcrumbItemMenuBreadcrumbsProps {
+ /** The collapsed parent crumbs to show in the dropdown. */
+ items: BreadcrumbMenuLinkItem[];
+}
+
+/**
+ * Internal component — rendered automatically by BreadcrumbList when the container
+ * is too narrow to show all parent crumbs. Collapses them into an ellipsis button.
+ */
+export function BreadcrumbItemMenuBreadcrumbs({
+ items,
+}: BreadcrumbItemMenuBreadcrumbsProps) {
+ const menuItems = items.map(item => ({
+ key: typeof item.to === 'string' ? item.to : JSON.stringify(item.to),
+ label: item.label,
+ to: item.to,
+ }));
+
+ return (
+
+ (
+ }
+ />
+ )}
+ />
+
+ );
+}
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
new file mode 100644
index 000000000000..cb6de231896f
--- /dev/null
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
@@ -0,0 +1,102 @@
+import {LinkButton} from '@sentry/scraps/button';
+import {Container, Flex} from '@sentry/scraps/layout';
+import type {LeadingGraphicProps} from '@sentry/scraps/leadingGraphic';
+import type {LinkProps} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
+import {Tooltip} from '@sentry/scraps/tooltip';
+
+import {IconChevron} from 'sentry/icons';
+
+interface BreadcrumbPaginationItem {
+ ariaLabel: string;
+ to: LinkProps['to'];
+ disabled?: boolean;
+ onClick?: () => void;
+ /** Optional tooltip content — useful for rich tooltips like "Learn More" links. */
+ tooltip?: React.ReactNode;
+}
+
+interface BreadcrumbItemPaginationProps {
+ next: BreadcrumbPaginationItem;
+ previous: BreadcrumbPaginationItem;
+}
+
+export interface BreadcrumbItemPageTitleProps {
+ label: string;
+ /** Renders a tooltip showing the full label — useful when text is truncated. Defaults to true. */
+ labelTooltip?: boolean;
+ leadingGraphic?: React.ReactElement;
+ /** Structured prev/next navigation rendered before the label. */
+ pagination?: BreadcrumbItemPaginationProps;
+ /** Trailing action slot (up to 52px wide). */
+ trailingActions?: React.ReactNode;
+}
+
+export function BreadcrumbItemPageTitle({
+ label,
+ labelTooltip = true,
+ leadingGraphic,
+ pagination,
+ trailingActions,
+}: BreadcrumbItemPageTitleProps) {
+ return (
+
+ {pagination && (
+
+
+ }
+ aria-label={pagination.previous.ariaLabel}
+ disabled={pagination.previous.disabled}
+ to={pagination.previous.to}
+ onClick={pagination.previous.onClick}
+ />
+
+
+ }
+ aria-label={pagination.next.ariaLabel}
+ disabled={pagination.next.disabled}
+ to={pagination.next.to}
+ onClick={pagination.next.onClick}
+ />
+
+
+ )}
+ {leadingGraphic}
+ {/* style={{minWidth: 0}} unblocks the Tooltip's wrapper so the label
+ can shrink. The visible-width floor lives on the outer Flex above. */}
+
+
+ {styleProps => (
+ // Rendered as inline text (a ), not a heading: the surrounding
+ // context (e.g. the TopBar title ) owns the page heading, so the
+ // current-page crumb must be phrasing content that nests cleanly
+ // inside it. Mirrors the legacy breadcrumb's current-crumb rendering.
+
+ {label}
+
+ )}
+
+
+ {trailingActions && (
+
+ {trailingActions}
+
+ )}
+
+ );
+}
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemSelectProjects.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemSelectProjects.tsx
new file mode 100644
index 000000000000..a3968c3c6f80
--- /dev/null
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemSelectProjects.tsx
@@ -0,0 +1,45 @@
+import {CompactSelect} from '@sentry/scraps/compactSelect';
+import type {SelectKey, SelectOption} from '@sentry/scraps/compactSelect';
+import {Flex} from '@sentry/scraps/layout';
+import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
+
+import {t} from 'sentry/locale';
+
+export interface BreadcrumbItemSelectProjectsProps {
+ onChange: (value: SelectOption) => void;
+ options: Array>;
+ value: Value;
+}
+
+export function BreadcrumbItemSelectProjects({
+ options,
+ value,
+ onChange,
+}: BreadcrumbItemSelectProjectsProps) {
+ // Prefer the selected option's human-readable label; fall back to the raw value
+ // when the label isn't a plain string (it may be a React node).
+ const selected = options.find(option => option.value === value);
+ const selectedLabel =
+ typeof selected?.label === 'string' ? selected.label : String(value);
+
+ return (
+
+ (
+
+ )}
+ />
+
+ );
+}
diff --git a/static/app/components/core/leadingGraphic/index.tsx b/static/app/components/core/leadingGraphic/index.tsx
new file mode 100644
index 000000000000..a22ecabc49c3
--- /dev/null
+++ b/static/app/components/core/leadingGraphic/index.tsx
@@ -0,0 +1,163 @@
+import {cloneElement, Fragment} from 'react';
+import PlatformIcon from 'platformicons/build/platformIcon';
+
+import {Container, Stack} from '@sentry/scraps/layout';
+
+import {IconAllProjects, IconMyProjects} from 'sentry/icons';
+
+// badge-project variant — absorbs the 0/1/2+ platform logic from
+// SecondaryNavigationProjectIcon so both nav and breadcrumbs share one component.
+interface LeadingGraphicBadgeProject {
+ /**
+ * Platform slugs for the project(s) to display.
+ * - 0 entries: renders an all-projects or my-projects icon
+ * - 1 entry: renders a single bordered platform icon
+ * - 2+ entries: renders two stacked platform icons (top-right + bottom-right)
+ */
+ projectPlatforms: string[];
+ variant: 'badge-project';
+ /** When projectPlatforms is empty, use all-projects icon instead of my-projects */
+ allProjects?: boolean;
+}
+
+interface LeadingGraphicIcon {
+ icon: React.ReactElement<{size?: string}>;
+ variant: 'icon';
+}
+
+interface LeadingGraphicAvatar {
+ avatar: React.ReactNode;
+ variant: 'avatar';
+}
+
+export type LeadingGraphicProps =
+ | LeadingGraphicBadgeProject
+ | LeadingGraphicIcon
+ | LeadingGraphicAvatar;
+
+/**
+ * A 16×16 leading graphic used by breadcrumb items and navigation links.
+ * Supports three visual variants: badge-project (platform icons), icon, and avatar.
+ */
+export function LeadingGraphic(props: LeadingGraphicProps) {
+ if (props.variant === 'icon') {
+ const icon = cloneElement(props.icon, {
+ size: props.icon.props.size ?? 'md',
+ });
+ return (
+
+ {icon}
+
+ );
+ }
+
+ if (props.variant === 'avatar') {
+ return (
+
+ {props.avatar}
+
+ );
+ }
+
+ // badge-project variant
+ return ;
+}
+
+function BadgeProjectGraphic({
+ projectPlatforms,
+ allProjects,
+}: LeadingGraphicBadgeProject) {
+ let icons: React.ReactNode;
+
+ switch (projectPlatforms.length) {
+ case 0:
+ icons = allProjects ? (
+
+ ) : (
+
+ );
+ break;
+
+ case 1:
+ icons = (
+
+ {p => (
+
+ )}
+
+ );
+ break;
+
+ default:
+ // Two overlapping icons: first at top-right, second at bottom-right.
+ // Positioned within a 16×16 container:
+ // first: right=4px → left edge at 0px (x: 0–12)
+ // second: right=0 → left edge at 4px (x: 4–16)
+ icons = (
+
+
+ {p => (
+
+ )}
+
+
+ {p => (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ {icons}
+
+ );
+}
diff --git a/static/app/components/core/leadingGraphic/leadingGraphic.mdx b/static/app/components/core/leadingGraphic/leadingGraphic.mdx
new file mode 100644
index 000000000000..c476880c3a40
--- /dev/null
+++ b/static/app/components/core/leadingGraphic/leadingGraphic.mdx
@@ -0,0 +1,136 @@
+---
+title: LeadingGraphic
+description: A 16×16 leading graphic — an icon, avatar, or project badge — rendered before breadcrumb and navigation labels.
+category: display
+source: '@sentry/scraps/leadingGraphic'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/leadingGraphic/index.tsx
+---
+
+import {Flex} from '@sentry/scraps/layout';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
+import {Text} from '@sentry/scraps/text';
+
+import {IconSettings, IconStar, IconUser} from 'sentry/icons';
+import * as Storybook from 'sentry/stories';
+
+export const documentation = import('!!type-loader!@sentry/scraps/leadingGraphic');
+
+`LeadingGraphic` renders a fixed 16×16 visual before a label. It's used by breadcrumb
+items and secondary-navigation links to give a crumb or link a recognizable glyph while
+keeping every leading slot the same size, so labels stay aligned.
+
+A single `variant` prop selects one of three shapes: a Sentry `icon`, an `avatar`, or a
+project `badge-project` that adapts to the number of projects it represents.
+
+## Usage
+
+```tsx
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
+```
+
+Choose a `variant` and pass the matching prop for that variant.
+
+
+ } />
+
+```tsx
+ } />
+```
+
+## Icon
+
+The `icon` variant centers a Sentry icon in the 16×16 box. Pass any icon from
+`sentry/icons`; it's sized to fit automatically.
+
+
+
+ } />
+ } />
+
+
+```tsx
+ } />
+ } />
+```
+
+## Avatar
+
+The `avatar` variant centers any avatar node — a user, team, or organization avatar —
+in the same 16×16 box. Pass the avatar element via the `avatar` prop.
+
+
+ } />
+
+```tsx
+ } />
+```
+
+## Project badge
+
+The `badge-project` variant renders project platform icons and adapts to how many
+platform slugs you pass in `projectPlatforms`:
+
+- **0 platforms** — a "my projects" glyph, or an "all projects" glyph when `allProjects`
+ is set.
+- **1 platform** — a single bordered platform icon.
+- **2+ platforms** — the first two platforms as stacked icons (extra platforms are not
+ shown).
+
+
+
+
+ My projects (0)
+
+
+
+ All projects (0)
+
+
+
+ 1 platform
+
+
+
+ 2 platforms
+
+
+
+ 3 platforms (first two shown)
+
+
+```tsx
+{/* No projects selected */}
+
+{/* All projects */}
+
+{/* A single project */}
+
+{/* Multiple projects — first two platforms are stacked */}
+
+```
+
+Platform slugs are passed straight through to `PlatformIcon` from `platformicons`, so any
+supported platform renders:
+
+
+ {['javascript', 'python', 'ruby', 'php', 'java', 'go', 'rust', 'node'].map(platform => (
+
+
+ {platform}
+
+ ))}
+
+
+## Accessibility
+
+Every variant renders `aria-hidden` — the graphic is decorative, and its meaning is
+carried by the label it sits beside.
+
+### Developer responsibilities
+
+- Always pair `LeadingGraphic` with a visible text label. Never rely on the graphic
+ alone to convey which page, project, or item a crumb or link points to.
diff --git a/static/app/views/detectors/components/details/common/header.tsx b/static/app/views/detectors/components/details/common/header.tsx
index 30a42454e2a7..98e19d685341 100644
--- a/static/app/views/detectors/components/details/common/header.tsx
+++ b/static/app/views/detectors/components/details/common/header.tsx
@@ -1,6 +1,6 @@
import {Fragment} from 'react';
-import {Breadcrumbs} from 'sentry/components/breadcrumbs';
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
import {t} from 'sentry/locale';
import type {Project} from 'sentry/types/project';
import type {Detector} from 'sentry/types/workflowEngine/detectors';
@@ -26,17 +26,23 @@ type DetectorDetailsHeaderProps = {
function DetectorDetailsBreadcrumbs({detector}: {detector: Detector}) {
const organization = useOrganization();
return (
-
);
diff --git a/static/app/views/navigation/secondary/components.tsx b/static/app/views/navigation/secondary/components.tsx
index 93296aa2b7ba..d9a5cdec016b 100644
--- a/static/app/views/navigation/secondary/components.tsx
+++ b/static/app/views/navigation/secondary/components.tsx
@@ -30,23 +30,17 @@ import {css, useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import {mergeProps, mergeRefs} from '@react-aria/utils';
import {AnimatePresence, motion} from 'framer-motion';
-import PlatformIcon from 'platformicons/build/platformIcon';
import {Button} from '@sentry/scraps/button';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
import {Link, type LinkProps} from '@sentry/scraps/link';
import {Separator} from '@sentry/scraps/separator';
import {Text} from '@sentry/scraps/text';
import {useScrollLock} from '@sentry/scraps/useScrollLock';
import {useHovercardContext} from 'sentry/components/hovercard';
-import {
- IconAllProjects,
- IconChevron,
- IconClose,
- IconGrabbable,
- IconMyProjects,
-} from 'sentry/icons';
+import {IconChevron, IconClose, IconGrabbable} from 'sentry/icons';
import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
@@ -500,49 +494,8 @@ interface SecondaryNavigationProjectIconProps {
}
function SecondaryNavigationProjectIcon(props: SecondaryNavigationProjectIconProps) {
- let icons: React.ReactNode;
-
- switch (props.projectPlatforms.length) {
- case 0:
- icons = props.allProjects ? (
-
- ) : (
-
- );
- break;
- case 1:
- icons = (
-
- );
- break;
- default:
- icons = (
-
-
- {p => (
-
- )}
-
-
- {p => (
-
- )}
-
-
- );
- }
-
return (
+ // Keep the 18×18 nav-specific outer Stack; LeadingGraphic renders at 16×16 inside it.
- {icons}
+
);
}
diff --git a/static/app/views/navigation/topBar.tsx b/static/app/views/navigation/topBar.tsx
index 44b6e69e4f49..78694c8794a1 100644
--- a/static/app/views/navigation/topBar.tsx
+++ b/static/app/views/navigation/topBar.tsx
@@ -76,10 +76,18 @@ function TopBarContent() {
* as the page heading. The Heading uses variant="inherit" so it carries
* the TopBar typography (no visual weight of its own), and Flex's render
* function applies the layout className to that same element.
+ *
+ * flexGrow={1} lets the occupy the available inline space (the
+ * header is justify="between", so this just absorbs the empty middle;
+ * content stays left-aligned, actions stay pinned right). This is
+ * required by any title-slot child that establishes a container query
+ * (e.g. BreadcrumbList's `container-type: inline-size`): without a
+ * definite inline size to resolve against, size containment collapses
+ * the child to 0 width and its container queries always read as narrow.
*/}
{props => (
-
+
{flexProps => (
)}