diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts
index a7fa20e61..0050e847d 100644
--- a/apps/www/src/content/docs/components/breadcrumb/demo.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts
@@ -113,11 +113,23 @@ export const asDemo = {
`
};
+export const disabledDemo = {
+ type: 'code',
+ code: `
+
+ Home
+
+ Loading…
+
+ Products
+ `
+};
+
export const iconsDemo = {
type: 'code',
tabs: [
{
- name: 'Text with Icon',
+ name: 'Leading Icon',
code: `
}>Home
@@ -127,6 +139,28 @@ export const iconsDemo = {
}>Settings
`
},
+ {
+ name: 'Trailing Icon',
+ code: `
+
+ }>Home
+
+ }>Documents
+
+ }>Settings
+ `
+ },
+ {
+ name: 'Both Icons',
+ code: `
+
+ } trailingIcon={}>Home
+
+ } trailingIcon={}>Documents
+
+ } trailingIcon={}>Settings
+ `
+ },
{
name: 'Only Icon',
code: `
diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx
index 7e3528f67..1d7db91ec 100644
--- a/apps/www/src/content/docs/components/breadcrumb/index.mdx
+++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx
@@ -12,6 +12,7 @@ import {
ellipsisDemo,
dropdownDemo,
asDemo,
+ disabledDemo,
} from "./demo.ts";
@@ -42,7 +43,7 @@ Groups all parts of the breadcrumb navigation.
### Item
-Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`).
+Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). Use the `disabled` prop for non-clickable, visually muted items (e.g. loading or no access).
@@ -80,7 +81,7 @@ Use the `Breadcrumb.Ellipsis` component to truncate the breadcrumb trail when yo
### Icons
-Breadcrumb items can include icons either alongside text or as standalone elements.
+Breadcrumb items can include icons via `leadingIcon` (before the label) or `trailingIcon` (after the label), either alongside text or as standalone elements.
@@ -100,8 +101,15 @@ When a custom component is provided, the props are merged, with the custom compo
+### Disabled
+
+Use the `disabled` prop for non-clickable, visually muted items—for example, loading states or segments the user does not have access to. Disabled items render as a span with `aria-disabled="true"` and do not navigate.
+
+
+
## Accessibility
- Uses `nav` element with `aria-label="Breadcrumb"` for proper landmark identification
- Current page is indicated with `aria-current="page"`
-- Separator elements are hidden from screen readers with `aria-hidden`
+- Disabled items use `aria-disabled="true"`
+- Separator elements are decorative and use `role="presentation"` and `aria-hidden="true"` so screen readers skip them
diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts
index 6862d0c81..7b9aa8bb7 100644
--- a/apps/www/src/content/docs/components/breadcrumb/props.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/props.ts
@@ -7,15 +7,24 @@ export interface BreadcrumbItem {
/** URL for the item link */
href?: string;
- /** Optional icon element to display */
+ /** Optional icon element to display before the label */
leadingIcon?: ReactNode;
+ /** Optional icon element to display after the label */
+ trailingIcon?: ReactNode;
+
/**
* Whether the item is the current page
* @defaultValue false
*/
current?: boolean;
+ /**
+ * When true, the item is non-clickable and visually muted (e.g. loading or no access).
+ * @defaultValue false
+ */
+ disabled?: boolean;
+
/**
* Optional array of dropdown items
*
diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
index 95b9587d7..647d66098 100644
--- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
+++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
@@ -128,7 +128,56 @@ describe('Breadcrumb', () => {
expect(screen.getByText('Home')).toBeInTheDocument();
});
- it('applies current/active state', () => {
+ it('renders with trailing icon', () => {
+ render(
+
+ ▶}
+ >
+ Next
+
+
+ );
+
+ const icon = screen.getByTestId('trailing-icon');
+ expect(icon).toBeInTheDocument();
+ expect(icon.parentElement).toHaveClass(styles['breadcrumb-icon']);
+ expect(screen.getByText('Next')).toBeInTheDocument();
+ });
+
+ it('renders with both leading and trailing icons', () => {
+ const { container } = render(
+
+ L}
+ trailingIcon={T}
+ >
+ Label
+
+
+ );
+
+ const leading = screen.getByTestId('leading');
+ const trailing = screen.getByTestId('trailing');
+ const label = screen.getByText('Label');
+
+ expect(leading).toBeInTheDocument();
+ expect(trailing).toBeInTheDocument();
+ expect(label).toBeInTheDocument();
+ expect(leading.parentElement).toHaveClass(styles['breadcrumb-icon']);
+ expect(trailing.parentElement).toHaveClass(styles['breadcrumb-icon']);
+
+ const link = container.querySelector(`.${styles['breadcrumb-link']}`);
+ const iconWrappers = link?.querySelectorAll(
+ `.${styles['breadcrumb-icon']}`
+ );
+ expect(iconWrappers).toHaveLength(2);
+ expect(iconWrappers?.[0]).toContainElement(leading);
+ expect(iconWrappers?.[1]).toContainElement(trailing);
+ expect(link?.textContent).toMatch(/L\s*Label\s*T/);
+ });
+
+ it('applies current/active state and renders as span with aria-current', () => {
const { container } = render(
Current Page
@@ -136,7 +185,16 @@ describe('Breadcrumb', () => {
);
const link = container.querySelector('a');
- expect(link).toHaveClass(styles['breadcrumb-link-active']);
+ expect(link).not.toBeInTheDocument();
+
+ const span = container.querySelector(
+ `span.${styles['breadcrumb-link-active']}`
+ );
+ expect(span).toBeInTheDocument();
+ expect(span).toHaveClass(styles['breadcrumb-link']);
+ expect(span).toHaveClass(styles['breadcrumb-link-active']);
+ expect(span).toHaveAttribute('aria-current', 'page');
+ expect(span).toHaveTextContent('Current Page');
});
it('renders with custom element using as prop', () => {
@@ -191,6 +249,64 @@ describe('Breadcrumb', () => {
expect(link).toHaveAttribute('aria-label', 'Products');
expect(link).toHaveAttribute('data-testid', 'item');
});
+
+ it('renders as span with disabled styles when disabled', () => {
+ const { container } = render(
+
+ Loading…
+
+ );
+
+ const link = container.querySelector('a');
+ expect(link).not.toBeInTheDocument();
+
+ const span = container.querySelector(
+ `span.${styles['breadcrumb-link-disabled']}`
+ );
+ expect(span).toBeInTheDocument();
+ expect(span).toHaveClass(styles['breadcrumb-link']);
+ expect(span).toHaveClass(styles['breadcrumb-link-disabled']);
+ expect(span).toHaveAttribute('aria-disabled', 'true');
+ expect(span).toHaveTextContent('Loading…');
+ });
+
+ it('disabled item has no href and is not focusable as link', () => {
+ const { container } = render(
+
+
+ No access
+
+
+ );
+
+ const span = container.querySelector(
+ `span.${styles['breadcrumb-link-disabled']}`
+ );
+ expect(span).toBeInTheDocument();
+ expect(container.querySelector('a')).not.toBeInTheDocument();
+ });
+
+ it('disabled with dropdownItems renders as disabled span not dropdown', () => {
+ const items = [
+ { label: 'Option 1', onClick: vi.fn() },
+ { label: 'Option 2', onClick: vi.fn() }
+ ];
+ const { container } = render(
+
+
+ Categories
+
+
+ );
+
+ const span = container.querySelector(
+ `span.${styles['breadcrumb-link-disabled']}`
+ );
+ expect(span).toBeInTheDocument();
+ expect(span).toHaveTextContent('Categories');
+ fireEvent.click(span!);
+ expect(screen.queryByText('Option 1')).not.toBeInTheDocument();
+ });
});
describe('BreadcrumbItem with Dropdown', () => {
@@ -282,6 +398,22 @@ describe('Breadcrumb', () => {
);
expect(ref).toHaveBeenCalled();
});
+
+ it('has role="presentation" and aria-hidden="true" for screen readers', () => {
+ const { container } = render(
+
+ Home
+
+ Products
+
+ );
+
+ const separator = container.querySelector(
+ `.${styles['breadcrumb-separator']}`
+ );
+ expect(separator).toHaveAttribute('role', 'presentation');
+ expect(separator).toHaveAttribute('aria-hidden', 'true');
+ });
});
describe('BreadcrumbEllipsis', () => {
diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
index 0b3471ac2..3d6668d35 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
+++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
@@ -19,7 +19,10 @@ export interface BreadcrumbDropdownItem {
export interface BreadcrumbItemProps extends HTMLAttributes {
leadingIcon?: ReactNode;
+ trailingIcon?: ReactNode;
current?: boolean;
+ /** When true, the item is non-clickable and visually muted (e.g. loading or no access). */
+ disabled?: boolean;
dropdownItems?: BreadcrumbDropdownItem[];
href?: string;
as?: ReactElement;
@@ -35,7 +38,9 @@ export const BreadcrumbItem = forwardRef<
children,
className,
leadingIcon,
+ trailingIcon,
current,
+ disabled,
href,
dropdownItems,
...props
@@ -49,10 +54,13 @@ export const BreadcrumbItem = forwardRef<
{leadingIcon}
)}
{children && {children}}
+ {trailingIcon && (
+ {trailingIcon}
+ )}
>
);
- if (dropdownItems) {
+ if (dropdownItems && !disabled) {
return (
);
}
+ if (disabled) {
+ return (
+
+ }
+ className={cx(
+ styles['breadcrumb-link'],
+ styles['breadcrumb-link-disabled']
+ )}
+ aria-disabled='true'
+ >
+ {label}
+
+
+ );
+ }
+ if (current) {
+ return (
+
+ }
+ className={cx(
+ styles['breadcrumb-link'],
+ styles['breadcrumb-link-active']
+ )}
+ aria-current='page'
+ >
+ {label}
+
+
+ );
+ }
return (
{cloneElement(
renderedElement,
{
- className: cx(
- styles['breadcrumb-link'],
- current && styles['breadcrumb-link-active']
- ),
+ className: styles['breadcrumb-link'],
href,
...props,
...renderedElement.props
diff --git a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
index 52bc07c90..5901e36c3 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
+++ b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
@@ -2,7 +2,7 @@
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
-import { HTMLAttributes, forwardRef } from 'react';
+import { forwardRef, HTMLAttributes } from 'react';
import styles from './breadcrumb.module.css';
export interface BreadcrumbEllipsisProps
@@ -53,6 +53,8 @@ export const BreadcrumbSeparator = forwardRef<
{children}
diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css
index fa93de192..ad63a33ad 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb.module.css
+++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css
@@ -45,6 +45,17 @@
.breadcrumb-link-active {
color: var(--rs-color-foreground-base-primary);
font-weight: var(--rs-font-weight-medium);
+ cursor: default;
+}
+
+.breadcrumb-link-active:hover {
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.breadcrumb-link-disabled {
+ color: var(--rs-color-foreground-base-tertiary);
+ opacity: 0.5;
+ cursor: not-allowed;
pointer-events: none;
}