diff --git a/.storybook/withDarkMode.tsx b/.storybook/withDarkMode.tsx
index 7534b4ca..4f4b00e6 100644
--- a/.storybook/withDarkMode.tsx
+++ b/.storybook/withDarkMode.tsx
@@ -7,7 +7,7 @@ export const withDarkMode: Decorator = (Story) => {
// Use useLayoutEffect to apply changes BEFORE paint (prevents flicker)
useLayoutEffect(() => {
- // Set background colors (the addon handles the 'dark' class automatically)
+ // Set background colors before applying the wrapper-level dark class below.
const bg = isDark ? '#060C13' : '#f4f5f7';
document.documentElement.style.background = bg;
document.body.style.background = bg;
@@ -15,7 +15,7 @@ export const withDarkMode: Decorator = (Story) => {
return (
= {
+ title: 'Molecules/Breadcrumb',
+ component: Breadcrumb,
+ parameters: {
+ docs: {
+ autodocs: true
+ }
+ },
+ tags: ['autodocs']
+};
+
+export default meta;
+
+type Story = StoryObj
;
+
+const items: BreadcrumbItem[] = [
+ { title: 'Home', href: '/' },
+ { title: 'Components', href: '/components' },
+ { title: 'Breadcrumb', href: '/components/breadcrumb' }
+];
+
+const itemsWithContent: BreadcrumbItem[] = [
+ { title: 'Home', href: '/', startContent: 'house' },
+ { title: 'Library', href: '/library', endContent: 'chevron-right' },
+ {
+ title: 'Documentation',
+ href: '/docs',
+ startContent: ,
+ endContent:
+ },
+ { title: 'Breadcrumb' }
+];
+
+const collapsedItems: BreadcrumbItem[] = [
+ { title: 'Home', href: '/' },
+ { title: 'Library', href: '/library' },
+ { title: 'Components', href: '/components' },
+ { title: 'Navigation', href: '/components/navigation' },
+ { title: 'Breadcrumb' }
+];
+
+/**
+ * Default breadcrumb with the transparent, borderless container style.
+ */
+export const Default: Story = {
+ args: {
+ items
+ }
+};
+
+/**
+ * Size variants for the breadcrumb container and current item.
+ */
+export const Sizes: Story = {
+ render: () => (
+
+
+
+
+
+ )
+};
+
+/**
+ * Available visual variants for the breadcrumb container.
+ */
+export const Variants: Story = {
+ render: () => (
+
+
+
+
+
+
+ )
+};
+
+/**
+ * Start and end content stay inside the same clickable breadcrumb item.
+ */
+export const WithItemContent: Story = {
+ render: () => (
+ }
+ />
+ )
+};
+
+/**
+ * The last item stays non-interactive even when link styles are customized.
+ */
+export const CurrentItem: Story = {
+ render: () =>
+};
+
+/**
+ * Collapsed items use an accessible trigger with tooltip support.
+ */
+export const Collapsed: Story = {
+ args: {
+ items: collapsedItems,
+ maxItems: 3,
+ itemsBeforeCollapse: 1,
+ itemsAfterCollapse: 1,
+ separator: ,
+ showTooltip: true
+ }
+};
diff --git a/src/components/molecules/breadcrumb/Breadcrumb.test.tsx b/src/components/molecules/breadcrumb/Breadcrumb.test.tsx
new file mode 100644
index 00000000..8c452e02
--- /dev/null
+++ b/src/components/molecules/breadcrumb/Breadcrumb.test.tsx
@@ -0,0 +1,168 @@
+import { render, renderHook, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('lucide-react/dynamic.js', () => ({
+ // biome-ignore lint/style/useNamingConvention: must match library export name
+ DynamicIcon: ({ name, size }: { name: string; size: number }) => (
+
+ )
+}));
+
+import Breadcrumb from './Breadcrumb';
+import { Breadcrumb as BreadcrumbFromIndex } from './index';
+import type { BreadcrumbItem } from './types';
+import { breadcrumbBase } from './types';
+import { useBreadcrumb } from './useBreadcrumb';
+
+const items: BreadcrumbItem[] = [
+ { title: 'Home', href: '#home' },
+ { title: 'Components', href: '#components' },
+ { title: 'Navigation', href: '#components-navigation' },
+ { title: 'Breadcrumb' }
+];
+
+describe('useBreadcrumb — logic', () => {
+ it('does not collapse when the collapsed trigger would exceed maxItems', () => {
+ const { result } = renderHook(() =>
+ useBreadcrumb({
+ items,
+ maxItems: 3,
+ itemsBeforeCollapse: 2,
+ itemsAfterCollapse: 1
+ })
+ );
+
+ expect(result.current.processedItems).toHaveLength(items.length);
+ expect(result.current.processedItems.every((entry) => entry.type === 'item')).toBe(true);
+ });
+
+ it('tracks original indexes across collapsed entries', () => {
+ const { result } = renderHook(() =>
+ useBreadcrumb({
+ items,
+ maxItems: 3,
+ itemsBeforeCollapse: 1,
+ itemsAfterCollapse: 1
+ })
+ );
+
+ expect(result.current.processedItems).toMatchObject([
+ { type: 'item', originalIndex: 0 },
+ { type: 'collapsed', hiddenItemIndexes: [1, 2] },
+ { type: 'item', originalIndex: 3, isLast: true }
+ ]);
+ });
+
+ it('does not duplicate trailing items when itemsAfterCollapse is zero', () => {
+ const { result } = renderHook(() =>
+ useBreadcrumb({
+ items,
+ maxItems: 3,
+ itemsBeforeCollapse: 1,
+ itemsAfterCollapse: 0
+ })
+ );
+
+ expect(result.current.processedItems).toMatchObject([
+ { type: 'item', originalIndex: 0 },
+ { type: 'collapsed', hiddenItemIndexes: [1, 2, 3], isLast: true }
+ ]);
+ });
+});
+
+describe('Breadcrumb — component behavior', () => {
+ it('is exported from the molecule barrel', () => {
+ expect(BreadcrumbFromIndex).toBe(Breadcrumb);
+ });
+
+ it('marks the last item as the current page and keeps it non-interactive', () => {
+ render();
+
+ expect(screen.getByText('Breadcrumb').closest('[aria-current="page"]')).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Breadcrumb' })).not.toBeInTheDocument();
+ });
+
+ it('calls onItemClick with the original index for visible links', async () => {
+ const user = userEvent.setup();
+ const handleItemClick = vi.fn();
+
+ render();
+
+ await user.click(screen.getByRole('link', { name: 'Components' }));
+
+ expect(handleItemClick).toHaveBeenCalledWith(items[1], 1);
+ });
+
+ it('calls onItemClick with the original index for hidden collapsed links', async () => {
+ const user = userEvent.setup();
+ const handleItemClick = vi.fn();
+ const duplicatedItems: BreadcrumbItem[] = [
+ { title: 'Home', href: '#home' },
+ { title: 'Docs', href: '#docs' },
+ { title: 'Docs', href: '#docs-api' },
+ { title: 'API', href: '#api' },
+ { title: 'Current' }
+ ];
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Show 3 hidden breadcrumb items' }));
+
+ const hiddenLink = screen.getByRole('link', { name: 'API' });
+ expect(hiddenLink).toHaveClass('min-w-0');
+ await user.click(hiddenLink);
+
+ expect(handleItemClick).toHaveBeenCalledWith(duplicatedItems[3], 3);
+ });
+
+ it('renders string separators as text instead of icon names', () => {
+ render();
+
+ expect(screen.getAllByText('→')).toHaveLength(3);
+ expect(screen.queryByTestId('breadcrumb-icon')).not.toBeInTheDocument();
+ });
+
+ it('wraps a custom collapsed element with accessible trigger behavior', async () => {
+ const user = userEvent.setup();
+ const handleCollapsedClick = vi.fn();
+
+ render(
+ More}
+ onCollapsedClick={handleCollapsedClick}
+ />
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Show 2 hidden breadcrumb items' });
+ await user.click(trigger);
+
+ expect(handleCollapsedClick).toHaveBeenCalledWith([items[1], items[2]]);
+ expect(document.getElementById(trigger.getAttribute('aria-controls') ?? '')).toBeInTheDocument();
+ });
+
+ it('allows breadcrumb labels to shrink before truncating', () => {
+ render();
+
+ expect(screen.getByRole('link', { name: 'Components' })).toHaveClass('min-w-0', 'max-w-full');
+ expect(screen.getByText('Breadcrumb').closest('li')).toHaveClass('min-w-0');
+ });
+
+ it('uses the established design-system classes for sizing and important rounded variants', () => {
+ expect(breadcrumbBase({ size: 'xs' })).toContain('fs-xs');
+ expect(breadcrumbBase({ size: 'md' })).toContain('fs-base');
+ expect(breadcrumbBase({ variant: 'underlined' })).toContain('rounded-none!');
+ });
+});
diff --git a/src/components/molecules/breadcrumb/Breadcrumb.tsx b/src/components/molecules/breadcrumb/Breadcrumb.tsx
new file mode 100644
index 00000000..ced44a99
--- /dev/null
+++ b/src/components/molecules/breadcrumb/Breadcrumb.tsx
@@ -0,0 +1,219 @@
+import { DynamicIcon, type IconName } from 'lucide-react/dynamic.js';
+import { type FC, type ReactNode, useId, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { type BreadcrumbContent, type BreadcrumbItem, type BreadcrumbProps, breadcrumbBase } from './types';
+import { useBreadcrumb } from './useBreadcrumb';
+
+const breadcrumbTextSizeClass = {
+ xs: 'fs-xs',
+ sm: 'fs-small',
+ md: 'fs-base',
+ lg: 'fs-h6',
+ xl: 'fs-h5'
+} as const;
+
+const breadcrumbItemBaseClass =
+ 'inline-flex min-w-0 max-w-full items-center gap-1 border-b border-transparent font-medium text-text-light no-underline transition-[color,border-color,box-shadow] duration-200 ease-[ease] hover:border-brand-light-dark/80 hover:text-brand-light-dark hover:no-underline focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:text-text-dark dark:hover:border-brand-dark-light dark:hover:text-brand-dark-light dark:focus-visible:shadow-glow-focus-dark';
+
+const Breadcrumb: FC = ({
+ 'aria-label': ariaLabel,
+ collapsedElement,
+ containerClassName,
+ isNavigationDisabled = false,
+ items,
+ linkClassName,
+ onCollapsedClick,
+ onItemClick,
+ separatorClassName,
+ textClassName,
+ ...breadcrumbProps
+}) => {
+ const {
+ processedItems,
+ endContent,
+ hideSeparator,
+ iconCollapse,
+ iconSizes,
+ rounded,
+ separator,
+ showTooltip,
+ size,
+ startContent,
+ variant
+ } = useBreadcrumb({
+ items,
+ ...breadcrumbProps
+ });
+
+ const itemSizeClass = breadcrumbTextSizeClass[size ?? 'md'];
+ const collapsedMenuId = useId();
+ const [isCollapsedMenuOpen, setIsCollapsedMenuOpen] = useState(false);
+
+ const renderAdornment = (content?: BreadcrumbContent) => {
+ if (!content) {
+ return null;
+ }
+
+ if (typeof content === 'string') {
+ return ;
+ }
+
+ return {content};
+ };
+
+ const renderItemContent = (item: BreadcrumbItem) => (
+ <>
+ {renderAdornment(item.icon)}
+ {renderAdornment(item.startContent ?? startContent)}
+ {item.title}
+ {renderAdornment(item.endContent ?? endContent)}
+ >
+ );
+
+ const renderSeparator = (separatorContent: ReactNode) => {
+ if (typeof separatorContent === 'string') {
+ return {separatorContent};
+ }
+
+ return separatorContent;
+ };
+
+ const renderCollapsedItem = (hiddenItems: BreadcrumbItem[], hiddenItemIndexes: number[]) => {
+ const hiddenItemsText = hiddenItems.map((item) => item.title).join(', ');
+
+ return (
+ <>
+
+ {isCollapsedMenuOpen && (
+
+ )}
+ >
+ );
+ };
+
+ const renderBreadcrumbItem = (item: BreadcrumbItem, originalIndex: number, isLast: boolean) => {
+ const isInteractive = !isLast && !isNavigationDisabled && !item.disabled && Boolean(item.href);
+
+ if (isInteractive) {
+ return (
+ onItemClick?.(item, originalIndex)}
+ >
+ {renderItemContent(item)}
+
+ );
+ }
+
+ return (
+
+ {renderItemContent(item)}
+
+ );
+ };
+
+ return (
+
+ );
+};
+
+export default Breadcrumb;
diff --git a/src/components/molecules/breadcrumb/index.ts b/src/components/molecules/breadcrumb/index.ts
new file mode 100644
index 00000000..7d0da816
--- /dev/null
+++ b/src/components/molecules/breadcrumb/index.ts
@@ -0,0 +1,2 @@
+export { default as Breadcrumb } from './Breadcrumb';
+export * from './types';
diff --git a/src/components/molecules/breadcrumb/types.ts b/src/components/molecules/breadcrumb/types.ts
new file mode 100644
index 00000000..26c54fc2
--- /dev/null
+++ b/src/components/molecules/breadcrumb/types.ts
@@ -0,0 +1,96 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ReactElement, ReactNode } from 'react';
+import type { DynamicIconName, IconSizes } from '@/types';
+
+export const breadcrumbBase = cva(
+ 'flex max-w-full flex-row items-center gap-1.5 overflow-visible whitespace-nowrap bg-transparent font-sans transition-colors duration-200',
+ {
+ variants: {
+ size: {
+ xs: 'px-1 py-0.5 fs-xs',
+ sm: 'px-1.5 py-1 fs-small',
+ md: 'px-2 py-1.5 fs-base',
+ lg: 'px-2.5 py-2 fs-h6',
+ xl: 'px-3 py-2.5 fs-h5'
+ },
+ rounded: {
+ xs: 'rounded-xs',
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ xl: 'rounded-xl',
+ full: 'rounded-full',
+ none: 'rounded-none'
+ },
+ variant: {
+ regular: 'border-0 bg-transparent',
+ underlined: 'border-b-2 border-border-strong-light bg-transparent dark:border-border-strong-dark rounded-none!',
+ line: 'border-b border-border-light bg-transparent dark:border-border-dark rounded-none!',
+ bordered: 'border border-border-light bg-transparent dark:border-border-dark'
+ }
+ },
+ defaultVariants: {
+ size: 'md',
+ rounded: 'md',
+ variant: 'regular'
+ }
+ }
+);
+
+export type BreadcrumbVariants = VariantProps;
+export type BreadcrumbRounded = NonNullable;
+export type BreadcrumbContent = DynamicIconName | ReactElement;
+export type BreadcrumbTarget = '_blank' | '_self' | '_parent' | '_top';
+
+export type BreadcrumbItem = {
+ title: string;
+ href?: string;
+ target?: BreadcrumbTarget;
+ disabled?: boolean;
+ icon?: DynamicIconName;
+ startContent?: BreadcrumbContent;
+ endContent?: BreadcrumbContent;
+};
+
+export type BreadcrumbProps = BreadcrumbVariants & {
+ 'aria-label'?: string;
+ containerClassName?: string;
+ collapsedElement?: ReactNode;
+ endContent?: BreadcrumbContent;
+ hideSeparator?: boolean;
+ iconCollapse?: BreadcrumbContent;
+ iconSizes?: IconSizes;
+ isNavigationDisabled?: boolean;
+ items: BreadcrumbItem[];
+ itemsAfterCollapse?: number;
+ itemsBeforeCollapse?: number;
+ linkClassName?: string;
+ maxItem?: number;
+ maxItems?: number;
+ onCollapsedClick?: (hiddenItems: BreadcrumbItem[]) => void;
+ onItemClick?: (item: BreadcrumbItem, index: number) => void;
+ radius?: BreadcrumbRounded;
+ separator?: ReactNode;
+ separatorClassName?: string;
+ showTooltip?: boolean;
+ startContent?: BreadcrumbContent;
+ textClassName?: string;
+};
+
+export type VisibleBreadcrumbItem = {
+ type: 'item';
+ item: BreadcrumbItem;
+ originalIndex: number;
+};
+
+export type CollapsedBreadcrumbItem = {
+ type: 'collapsed';
+ hiddenItems: BreadcrumbItem[];
+ hiddenItemIndexes: number[];
+};
+
+export type BreadcrumbRenderItem = VisibleBreadcrumbItem | CollapsedBreadcrumbItem;
+
+export type ProcessedBreadcrumbItem = BreadcrumbRenderItem & {
+ isLast: boolean;
+};
diff --git a/src/components/molecules/breadcrumb/useBreadcrumb.ts b/src/components/molecules/breadcrumb/useBreadcrumb.ts
new file mode 100644
index 00000000..6677ac57
--- /dev/null
+++ b/src/components/molecules/breadcrumb/useBreadcrumb.ts
@@ -0,0 +1,92 @@
+import type { BreadcrumbItem, BreadcrumbProps, ProcessedBreadcrumbItem } from './types';
+
+const canCollapseItems = (
+ items: BreadcrumbItem[],
+ maxItems: number,
+ itemsBeforeCollapse: number,
+ itemsAfterCollapse: number
+) => {
+ if (maxItems === 0 || items.length <= maxItems || maxItems < 3) {
+ return false;
+ }
+
+ if (itemsBeforeCollapse + itemsAfterCollapse + 1 > maxItems) {
+ return false;
+ }
+
+ if (itemsBeforeCollapse > maxItems - 1 || itemsAfterCollapse > maxItems - 1) {
+ return false;
+ }
+
+ return true;
+};
+
+export const useBreadcrumb = ({
+ items,
+ variant = 'regular',
+ size = 'md',
+ iconSizes = 18,
+ rounded = 'md',
+ radius,
+ startContent,
+ endContent,
+ hideSeparator = false,
+ separator = '/',
+ maxItem = 0,
+ maxItems,
+ itemsBeforeCollapse = 1,
+ itemsAfterCollapse = 1,
+ iconCollapse = 'more-horizontal',
+ showTooltip = false
+}: BreadcrumbProps) => {
+ const resolvedRounded = radius ?? rounded;
+ const resolvedMaxItems = maxItems ?? maxItem;
+
+ const indexedItems = items.map((item, originalIndex) => ({ item, originalIndex }));
+
+ const hiddenEndIndex = itemsAfterCollapse > 0 ? items.length - itemsAfterCollapse : items.length;
+ const leadingEntries = indexedItems.slice(0, itemsBeforeCollapse);
+ const trailingEntries = itemsAfterCollapse > 0 ? indexedItems.slice(-itemsAfterCollapse) : [];
+ const hiddenEntries = canCollapseItems(items, resolvedMaxItems, itemsBeforeCollapse, itemsAfterCollapse)
+ ? indexedItems.slice(itemsBeforeCollapse, hiddenEndIndex)
+ : [];
+
+ const collapsedItems = hiddenEntries.length
+ ? [
+ ...leadingEntries.map(({ item, originalIndex }) => ({
+ type: 'item' as const,
+ item,
+ originalIndex
+ })),
+ {
+ type: 'collapsed' as const,
+ hiddenItems: hiddenEntries.map(({ item }) => item),
+ hiddenItemIndexes: hiddenEntries.map(({ originalIndex }) => originalIndex)
+ },
+ ...trailingEntries.map(({ item, originalIndex }) => ({
+ type: 'item' as const,
+ item,
+ originalIndex
+ }))
+ ]
+ : indexedItems.map(({ item, originalIndex }) => ({ type: 'item' as const, item, originalIndex }));
+
+ const processedItems: ProcessedBreadcrumbItem[] = collapsedItems.map((entry, index, array) => ({
+ ...entry,
+ isLast: index === array.length - 1
+ }));
+
+ return {
+ endContent,
+ hideSeparator,
+ iconCollapse,
+ iconSizes,
+ processedItems,
+ rounded: resolvedRounded,
+ separator,
+ showTooltip,
+ size,
+ startContent,
+ variant
+ };
+};
diff --git a/src/index.ts b/src/index.ts
index 5bfb3bce..d83bd3a3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,28 +1,29 @@
-import './styles/global.css';
-
-// ─── Atoms ───────────────────────────────────────────────────────────────────
-export { Avatar } from './components/atoms/avatar';
-export { Badge } from './components/atoms/badge';
-export { Button } from './components/atoms/button';
-export { Calendar } from './components/atoms/calendar';
-export { Chip } from './components/atoms/chip';
-export { Divider } from './components/atoms/divider';
-export { Dropdown } from './components/atoms/dropdown';
-export { Header } from './components/atoms/header';
-export { Icon } from './components/atoms/icon';
-export { IconButton } from './components/atoms/icon-button';
-export { Input } from './components/atoms/input';
-export { Link } from './components/atoms/link';
-export { Skeleton } from './components/atoms/skeleton';
-export { Spacer } from './components/atoms/spacer';
-export { Switch } from './components/atoms/switch';
-export { Text } from './components/atoms/text';
-
-// ─── Molecules ───────────────────────────────────────────────────────────────
-export { Accordion } from './components/molecules/accordion';
-export { Snippet } from './components/molecules/snippet';
-
-// ─── Organisms ───────────────────────────────────────────────────────────────
-export { Footer } from './components/organisms/footer';
-export { NavigationHeader } from './components/organisms/header';
-export { Modal } from './components/organisms/modal';
+import './styles/global.css';
+
+// ─── Atoms ───────────────────────────────────────────────────────────────────
+export { Avatar } from './components/atoms/avatar';
+export { Badge } from './components/atoms/badge';
+export { Button } from './components/atoms/button';
+export { Calendar } from './components/atoms/calendar';
+export { Chip } from './components/atoms/chip';
+export { Divider } from './components/atoms/divider';
+export { Dropdown } from './components/atoms/dropdown';
+export { Header } from './components/atoms/header';
+export { Icon } from './components/atoms/icon';
+export { IconButton } from './components/atoms/icon-button';
+export { Input } from './components/atoms/input';
+export { Link } from './components/atoms/link';
+export { Skeleton } from './components/atoms/skeleton';
+export { Spacer } from './components/atoms/spacer';
+export { Switch } from './components/atoms/switch';
+export { Text } from './components/atoms/text';
+
+// ─── Molecules ───────────────────────────────────────────────────────────────
+export { Accordion } from './components/molecules/accordion';
+export { Breadcrumb } from './components/molecules/breadcrumb';
+export { Snippet } from './components/molecules/snippet';
+
+// ─── Organisms ───────────────────────────────────────────────────────────────
+export { Footer } from './components/organisms/footer';
+export { NavigationHeader } from './components/organisms/header';
+export { Modal } from './components/organisms/modal';