diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx
index 4b0263308..2f9f94b16 100644
--- a/apps/www/src/app/examples/page.tsx
+++ b/apps/www/src/app/examples/page.tsx
@@ -3,6 +3,7 @@ import {
Amount,
Avatar,
AvatarGroup,
+ Breadcrumb,
Button,
Calendar,
Callout,
diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx
index 53e48e8d2..b59bbf2c0 100644
--- a/apps/www/src/components/demo/demo.tsx
+++ b/apps/www/src/components/demo/demo.tsx
@@ -18,6 +18,7 @@ import {
BellIcon,
FilterIcon,
OrganizationIcon,
+ ShoppingBagFilledIcon,
SidebarIcon
} from '@raystack/apsara/icons';
import dayjs from 'dayjs';
@@ -38,6 +39,7 @@ export default function Demo(props: DemoProps) {
...Apsara,
BellIcon,
FilterIcon,
+ ShoppingBagFilledIcon,
OrganizationIcon,
SidebarIcon,
DataTableDemo,
diff --git a/apps/www/src/components/playground/breadcrumb-examples.tsx b/apps/www/src/components/playground/breadcrumb-examples.tsx
index 86e4c8f7c..4dd76624a 100644
--- a/apps/www/src/components/playground/breadcrumb-examples.tsx
+++ b/apps/www/src/components/playground/breadcrumb-examples.tsx
@@ -13,13 +13,13 @@ export function BreadcrumbExamples() {
{
console.log('Clothes');
}
},
{
- label: 'Electronics',
+ children: 'Electronics',
onClick: () => {
console.log('Electronics');
}
diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts
index e2d5948d8..c9eaee7de 100644
--- a/apps/www/src/content/docs/components/breadcrumb/demo.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts
@@ -26,23 +26,30 @@ export const playground = {
export const sizeDemo = {
type: 'code',
- code: `
-
-
- Home
-
- Products
-
- Shoes
-
-
- Home
-
- Products
-
- Shoes
-
- `
+ tabs: [
+ {
+ name: 'Small',
+ code: `
+
+ Home
+
+ Products
+
+ Shoes
+ `
+ },
+ {
+ name: 'Medium',
+ code: `
+
+ Home
+
+ Products
+
+ Shoes
+ `
+ }
+ ]
};
export const separatorDemo = {
@@ -87,13 +94,30 @@ export const dropdownDemo = {
Category
{console.log('Option 1')}},
- { label: 'Option 2', onClick: () => {console.log('Option 2')}}
+ { children: 'Option 1', onClick: () => {console.log('Option 1')}},
+ { children: 'Option 2', onClick: () => {console.log('Option 2')}}
]}>Subcategory
Current Page
`
};
+
+export const dropdownLinksDemo = {
+ type: 'code',
+ code: `
+
+ Home
+
+ },
+ { children: 'Clothing', render: },
+ { children: 'Books', onClick: () => {console.log('Books')}}
+ ]}>Categories
+
+ Current
+ `
+};
+
export const asDemo = {
type: 'code',
code: `
@@ -106,29 +130,63 @@ 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
+
+ }>Documents
+
+ }>Settings
+ `
+ },
+ {
+ name: 'Trailing Icon',
+ code: `
+
+ }>Home
+
+ }>Documents
+
+ }>Settings
+ `
+ },
+ {
+ name: 'Both Icons',
code: `
- H>}>Home
+ } trailingIcon={}>Home
- D>}>Documents
+ } trailingIcon={}>Documents
- S>}>Settings
+ } trailingIcon={}>Settings
`
},
{
name: 'Only Icon',
code: `
- H>}/>
+ }/>
- D>}/>
+ }/>
- S>}/>
+ }/>
`
}
]
diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx
index 63a53ae4f..c5f22a2e3 100644
--- a/apps/www/src/content/docs/components/breadcrumb/index.mdx
+++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx
@@ -11,7 +11,9 @@ import {
iconsDemo,
ellipsisDemo,
dropdownDemo,
+ dropdownLinksDemo,
asDemo,
+ disabledDemo,
} from "./demo.ts";
@@ -42,7 +44,25 @@ Groups all parts of the breadcrumb navigation.
### Item
-Renders an individual breadcrumb link. Ref is forwarded to the rendered element when using `render` (not when using `dropdownItems`).
+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).
+
+Item elements expose data attributes for CSS state targeting so you can style current and disabled states without relying on internal class names:
+
+| Attribute | When present |
+|-----------|----------------|
+| `data-current="true"` | Item is the current page (`current` prop) |
+| `data-disabled="true"` | Item is disabled (`disabled` prop) |
+
+Example:
+
+```css
+[data-current="true"] {
+ color: var(--my-brand);
+}
+[data-disabled="true"] {
+ opacity: 0.6;
+}
+```
@@ -80,17 +100,18 @@ 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.
### Dropdown
-Breadcrumb items can include dropdown menus for additional navigation options. Specify the dropdown items using the `dropdownItems` prop.
+Breadcrumb items can include dropdown menus for additional navigation options. Specify them with the `dropdownItems` prop: each entry is the same props as `
` (e.g. `children` for the label, `onClick`, `disabled`, `render` for a link such as ``, etc.). You can also pass `key` for stable list keys.
**Note:** When `dropdownItems` is provided, the `render` and `href` props are ignored.
+
### Render
@@ -103,8 +124,15 @@ Use the `render` prop to render the breadcrumb item as a custom component. Pass
+### 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 ba334786f..35a817508 100644
--- a/apps/www/src/content/docs/components/breadcrumb/props.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/props.ts
@@ -1,4 +1,4 @@
-import { ReactElement, ReactEventHandler, ReactNode } from 'react';
+import { ReactElement, ReactNode } from 'react';
export interface BreadcrumbItem {
/** Text to display for the item */
@@ -7,9 +7,12 @@ 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
@@ -17,18 +20,22 @@ export interface BreadcrumbItem {
current?: boolean;
/**
- * Optional array of dropdown items
+ * When true, the item is non-clickable and visually muted (e.g. loading or no access).
+ * @defaultValue false
+ */
+ disabled?: boolean;
+
+ /**
+ * Optional array of dropdown entries; each object is passed to ``
+ * (e.g. `children`, `onClick`, `render` for a link, etc.), plus optional `key`
+ * for React list reconciliation (not forwarded to `Menu.Item`).
*
* When `dropdownItems` is provided, the `render` and `href` props are ignored.
*/
- dropdownItems?: {
- /** Optional stable key for list reconciliation. Falls back to index if omitted. */
+ dropdownItems?: (Record & {
key?: string;
- /** Text to display for the dropdown item */
- label: string;
- /** Callback function when a dropdown item is clicked */
- onClick?: ReactEventHandler;
- }[];
+ children?: ReactNode;
+ })[];
/**
* Render prop for polymorphism (Base UI `useRender`).
@@ -37,6 +44,9 @@ export interface BreadcrumbItem {
* Example: `render={}`
*/
render?: ReactElement;
+
+ /** Custom CSS class name applied to the list item wrapper */
+ className?: string;
}
export interface BreadcrumbProps {
diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
index 9f4282f1f..a016c01e3 100644
--- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
+++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
@@ -129,7 +129,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
@@ -142,6 +191,11 @@ describe('Breadcrumb', () => {
);
expect(currentEl).toBeInTheDocument();
expect(currentEl).toHaveAttribute('aria-current', 'page');
+ expect(currentEl).toHaveClass(styles['breadcrumb-link']);
+ expect(currentEl).toHaveClass(styles['breadcrumb-link-active']);
+ expect(currentEl).toHaveAttribute('data-current', 'true');
+ expect(currentEl).toHaveTextContent('Current Page');
+ expect(container.querySelector('a')).not.toBeInTheDocument();
});
it('renders with custom element using render prop', () => {
@@ -220,13 +274,72 @@ 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).toHaveAttribute('data-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 = [
+ { children: 'Option 1', onClick: vi.fn() },
+ { children: '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', () => {
it('renders dropdown trigger when dropdownItems provided', () => {
const items = [
- { label: 'Option 1', onClick: vi.fn() },
- { label: 'Option 2', onClick: vi.fn() }
+ { children: 'Option 1', onClick: vi.fn() },
+ { children: 'Option 2', onClick: vi.fn() }
];
render(
@@ -244,9 +357,9 @@ describe('Breadcrumb', () => {
it('renders dropdown items on click', () => {
const items = [
- { label: 'Electronics' },
- { label: 'Clothing' },
- { label: 'Books' }
+ { children: 'Electronics' },
+ { children: 'Clothing' },
+ { children: 'Books' }
];
render(
@@ -263,6 +376,41 @@ describe('Breadcrumb', () => {
expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('Books')).toBeInTheDocument();
});
+
+ it('renders dropdown items with href as links', () => {
+ render(
+
+
+ )
+ },
+ {
+ children: 'Same tab',
+ render:
+ }
+ ]}
+ >
+ Categories
+
+
+ );
+
+ fireEvent.click(screen.getByText('Categories'));
+
+ const newTabLink = screen.getByText('New tab');
+ expect(newTabLink.tagName).toBe('A');
+ expect(newTabLink).toHaveAttribute('href', '/page');
+ expect(newTabLink).toHaveAttribute('target', '_blank');
+ expect(newTabLink).toHaveAttribute('rel', 'noopener noreferrer');
+
+ const sameTabLink = screen.getByText('Same tab');
+ expect(sameTabLink.tagName).toBe('A');
+ expect(sameTabLink).toHaveAttribute('href', '/other');
+ });
});
describe('BreadcrumbSeparator', () => {
@@ -311,6 +459,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', () => {
@@ -417,9 +581,9 @@ describe('Breadcrumb', () => {
it('renders breadcrumb with icons and dropdown', () => {
const categories = [
- { label: 'Electronics' },
- { label: 'Clothing' },
- { label: 'Books' }
+ { children: 'Electronics' },
+ { children: 'Clothing' },
+ { children: 'Books' }
];
render(
diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
index cac155543..2f8d0b925 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
+++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx
@@ -3,19 +3,25 @@
import { mergeProps, useRender } from '@base-ui/react';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
-import React, { ReactNode } from 'react';
+import React, { ComponentProps, ReactNode } from 'react';
import { Menu } from '../menu';
import styles from './breadcrumb.module.css';
-export interface BreadcrumbDropdownItem {
+/**
+ * Each entry maps to ``. Use `children`, `render`, `onClick`,
+ * `disabled`, etc. - whatever `Menu.Item` supports.
+ */
+export type BreadcrumbDropdownItem = ComponentProps & {
+ /** Optional stable key for React list reconciliation (not passed to `Menu.Item`). */
key?: string;
- label: string;
- onClick?: React.MouseEventHandler;
-}
+};
export interface BreadcrumbItemProps extends useRender.ComponentProps<'a'> {
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[];
}
@@ -25,19 +31,27 @@ export const BreadcrumbItem = ({
children,
className,
leadingIcon,
+ trailingIcon,
current,
+ disabled,
href,
dropdownItems,
...props
}: BreadcrumbItemProps) => {
- const label = leadingIcon ? (
- <>
- {leadingIcon}
- {children != null && {children}}
- >
- ) : (
- children
- );
+ const label =
+ leadingIcon || trailingIcon ? (
+ <>
+ {leadingIcon && (
+ {leadingIcon}
+ )}
+ {children != null && {children}}
+ {trailingIcon && (
+ {trailingIcon}
+ )}
+ >
+ ) : (
+ children
+ );
const {
id,
@@ -61,7 +75,7 @@ export const BreadcrumbItem = ({
)
});
- if (dropdownItems) {
+ if (dropdownItems && !disabled) {
return (
);
}
- if (current) {
+
+ if (disabled || current) {
return (
}
className={cx(
styles['breadcrumb-link'],
- styles['breadcrumb-link-active']
+ disabled && styles['breadcrumb-link-disabled'],
+ current && styles['breadcrumb-link-active']
)}
- aria-current='page'
- {...props}
+ {...(disabled && {
+ 'aria-disabled': 'true',
+ 'data-disabled': 'true'
+ })}
+ {...(current && { 'aria-current': 'page', 'data-current': 'true' })}
>
{label}
);
}
+
return (
{linkElement}
);
diff --git a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
index 48f72a1a2..e599b9404 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
+++ b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
@@ -49,6 +49,8 @@ export const BreadcrumbSeparator = ({
{children}
diff --git a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx
index a348cdc13..739b2eb69 100644
--- a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx
+++ b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx
@@ -16,6 +16,9 @@ const breadcrumbVariants = cva(styles['breadcrumb'], {
}
});
+/**
+ * Breadcrumb root: renders a nav with an ordered list of items and separators.
+ */
export interface BreadcrumbProps
extends VariantProps,
ComponentProps<'nav'> {}
diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css
index fa93de192..47b21edc1 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;
}
@@ -76,10 +87,23 @@
}
.breadcrumb-dropdown-item {
+ display: block;
+ width: 100%;
+ padding: var(--rs-space-3);
cursor: pointer;
color: var(--rs-color-foreground-base-primary);
+ font-weight: var(--rs-font-weight-regular);
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ letter-spacing: var(--rs-letter-spacing-small);
+ text-decoration: none;
+ border: none;
+ background: none;
+ text-align: left;
+ box-sizing: border-box;
}
.breadcrumb-dropdown-item:hover {
background-color: var(--rs-color-background-base-primary-hover);
+ border-radius: var(--rs-radius-2);
}