From 5cdc6753d4461b8e531870bcd9f4724dc6782d80 Mon Sep 17 00:00:00 2001
From: Valerii Sidorenko <balepas@nebius.com>
Date: Thu, 13 Jun 2024 14:04:22 +0200
Subject: [PATCH] feat(Breadcrumbs): new component (#1497)

---
 .../lab/Breadcrumbs/BreadcrumbItem.tsx        |  96 ++++
 .../lab/Breadcrumbs/Breadcrumbs.scss          | 110 ++++
 .../lab/Breadcrumbs/Breadcrumbs.tsx           | 272 ++++++++++
 .../lab/Breadcrumbs/BreadcrumbsSeparator.tsx  |  16 +
 src/components/lab/Breadcrumbs/README.md      | 501 ++++++++++++++++++
 .../__stories__/Breadcrumbs.stories.tsx       | 138 +++++
 .../lab/Breadcrumbs/__stories__/Docs.mdx      |  39 ++
 .../__tests__/Breadcrumbs.test.tsx            | 284 ++++++++++
 src/components/lab/Breadcrumbs/i18n/en.json   |   4 +
 src/components/lab/Breadcrumbs/i18n/index.ts  |   8 +
 src/components/lab/Breadcrumbs/i18n/ru.json   |   4 +
 src/components/lab/Breadcrumbs/index.ts       |   1 +
 src/components/lab/Breadcrumbs/utils.ts       |  24 +
 src/components/types.ts                       |  29 +
 src/components/utils/filterDOMProps.ts        |  46 ++
 src/hooks/index.ts                            |   1 +
 src/hooks/useResizeObserver/README.md         |  18 +
 src/hooks/useResizeObserver/index.ts          |   1 +
 .../useResizeObserver/useResizeObserver.ts    |  34 ++
 src/unstable.ts                               |   9 +
 20 files changed, 1635 insertions(+)
 create mode 100644 src/components/lab/Breadcrumbs/BreadcrumbItem.tsx
 create mode 100644 src/components/lab/Breadcrumbs/Breadcrumbs.scss
 create mode 100644 src/components/lab/Breadcrumbs/Breadcrumbs.tsx
 create mode 100644 src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx
 create mode 100644 src/components/lab/Breadcrumbs/README.md
 create mode 100644 src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx
 create mode 100644 src/components/lab/Breadcrumbs/__stories__/Docs.mdx
 create mode 100644 src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
 create mode 100644 src/components/lab/Breadcrumbs/i18n/en.json
 create mode 100644 src/components/lab/Breadcrumbs/i18n/index.ts
 create mode 100644 src/components/lab/Breadcrumbs/i18n/ru.json
 create mode 100644 src/components/lab/Breadcrumbs/index.ts
 create mode 100644 src/components/lab/Breadcrumbs/utils.ts
 create mode 100644 src/components/utils/filterDOMProps.ts
 create mode 100644 src/hooks/useResizeObserver/README.md
 create mode 100644 src/hooks/useResizeObserver/index.ts
 create mode 100644 src/hooks/useResizeObserver/useResizeObserver.ts

diff --git a/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx
new file mode 100644
index 0000000000..e7c32c5f24
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import React from 'react';
+
+import type {Href, RouterOptions} from '../../types';
+import {filterDOMProps} from '../../utils/filterDOMProps';
+
+import type {BreadcrumbsItemProps} from './Breadcrumbs';
+import {b, shouldClientNavigate} from './utils';
+
+interface BreadcrumbProps extends BreadcrumbsItemProps {
+    onAction?: () => void;
+    current?: boolean;
+    itemType?: 'link' | 'menu';
+    disabled?: boolean;
+    navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void;
+}
+export function BreadcrumbItem(props: BreadcrumbProps) {
+    const Element = props.href ? 'a' : 'span';
+    const domProps = filterDOMProps(props, {labelable: true});
+
+    let title = props.title;
+    if (!title && typeof props.children === 'string') {
+        title = props.children;
+    }
+
+    const handleAction = (event: React.MouseEvent | React.KeyboardEvent) => {
+        if (props.disabled || props.current) {
+            event.preventDefault();
+            return;
+        }
+
+        if (typeof props.onAction === 'function') {
+            props.onAction();
+        }
+
+        const target = event.currentTarget;
+        if (typeof props.navigate === 'function' && target instanceof HTMLAnchorElement) {
+            if (props.href && !event.isDefaultPrevented() && shouldClientNavigate(target, event)) {
+                event.preventDefault();
+                props.navigate(props.href, props.routerOptions);
+            }
+        }
+    };
+
+    const isDisabled = props.disabled || props.current;
+    let linkProps: React.AnchorHTMLAttributes<HTMLElement> = {
+        title,
+        onClick: handleAction,
+        'aria-disabled': isDisabled ? true : undefined,
+    };
+    if (Element === 'a') {
+        linkProps.href = props.href;
+        linkProps.hrefLang = props.hrefLang;
+        linkProps.target = props.target;
+        linkProps.rel = props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel;
+        linkProps.download = props.download;
+        linkProps.ping = props.ping;
+        linkProps.referrerPolicy = props.referrerPolicy;
+    } else {
+        linkProps.role = 'link';
+        linkProps.tabIndex = isDisabled ? undefined : 0;
+        linkProps.onKeyDown = (event: React.KeyboardEvent) => {
+            if (event.key === 'Enter') {
+                handleAction(event);
+            }
+        };
+    }
+
+    if (props.current) {
+        linkProps['aria-current'] = 'page';
+    }
+
+    if (props.itemType === 'menu') {
+        linkProps = {};
+    }
+
+    return (
+        <Element
+            {...domProps}
+            {...linkProps}
+            className={
+                props.itemType === 'menu'
+                    ? b('menu')
+                    : b('link', {
+                          'is-current': props.current,
+                          'is-disabled': isDisabled && !props.current,
+                      })
+            }
+        >
+            {props.children}
+        </Element>
+    );
+}
+
+BreadcrumbItem.displayName = 'Breadcrumbs.Item';
diff --git a/src/components/lab/Breadcrumbs/Breadcrumbs.scss b/src/components/lab/Breadcrumbs/Breadcrumbs.scss
new file mode 100644
index 0000000000..11b32f5ed0
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/Breadcrumbs.scss
@@ -0,0 +1,110 @@
+@use '../../variables';
+@use '../../../../styles/mixins';
+
+$block: '.#{variables.$ns}breadcrumbs2';
+
+#{$block} {
+    display: flex;
+    flex-wrap: nowrap;
+    justify-content: flex-start;
+
+    list-style-type: none;
+    margin: 0;
+    padding: 0;
+
+    &__item {
+        position: relative;
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+
+        height: 24px;
+        white-space: nowrap;
+        color: var(--g-color-text-primary);
+
+        &:last-child {
+            font-weight: var(--g-text-accent-font-weight);
+            overflow: hidden;
+
+            #{$block}__link {
+                @include mixins.overflow-ellipsis();
+            }
+        }
+
+        &_calculating:last-child {
+            overflow: visible;
+        }
+    }
+
+    &__link {
+        cursor: default;
+        position: relative;
+        text-decoration: none;
+        outline: none;
+        touch-action: manipulation;
+        -webkit-tap-highlight-color: transparent;
+
+        height: 24px;
+        line-height: 24px;
+        vertical-align: middle;
+        border-radius: var(--g-focus-border-radius);
+
+        color: inherit;
+
+        &_is-disabled {
+            color: var(--g-color-text-hint);
+        }
+
+        &:not([aria-disabled]) {
+            cursor: pointer;
+
+            &:hover {
+                color: var(--g-color-text-link-hover);
+            }
+        }
+
+        &:focus-visible {
+            outline: 2px solid var(--g-color-line-focus);
+        }
+    }
+
+    &__divider {
+        display: flex;
+        align-items: center;
+        color: var(--g-color-text-secondary);
+        padding: 0 var(--g-spacing-2);
+    }
+
+    &__more-button {
+        --g-button-border-radius: var(--g-focus-border-radius);
+        --g-button-focus-outline-offset: -2px;
+    }
+
+    &__menu {
+        margin-inline: calc(-1 * var(--g-spacing-2));
+    }
+
+    &__item:first-child &__menu {
+        margin-inline-start: 0;
+    }
+
+    &__popup_staircase {
+        $menu: '.#{variables.$ns}menu';
+        $staircaseLength: 10;
+        #{$menu} {
+            #{$menu}__list-item {
+                #{$menu}__item[class] {
+                    padding-inline-start: 8px * $staircaseLength;
+                }
+            }
+
+            @for $i from 0 through $staircaseLength {
+                #{$menu}__list-item:nth-child(#{$i}) {
+                    #{$menu}__item[class] {
+                        padding-inline-start: 8px * $i;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/components/lab/Breadcrumbs/Breadcrumbs.tsx b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 0000000000..cc062d957c
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import React from 'react';
+
+import {useForkRef, useResizeObserver} from '../../../hooks';
+import {Button} from '../../Button';
+import {DropdownMenu} from '../../DropdownMenu';
+import type {PopupPlacement} from '../../Popup';
+import type {AriaLabelingProps, DOMProps, Href, Key, QAProps, RouterOptions} from '../../types';
+import {filterDOMProps} from '../../utils/filterDOMProps';
+
+import {BreadcrumbItem} from './BreadcrumbItem';
+import {BreadcrumbsSeparator} from './BreadcrumbsSeparator';
+import i18n from './i18n';
+import {b, shouldClientNavigate} from './utils';
+
+import './Breadcrumbs.scss';
+
+export interface BreadcrumbsItemProps {
+    children: React.ReactNode;
+    title?: string;
+    href?: Href;
+    hrefLang?: string;
+    target?: React.HTMLAttributeAnchorTarget;
+    rel?: string;
+    download?: boolean | string;
+    ping?: string;
+    referrerPolicy?: React.HTMLAttributeReferrerPolicy;
+    'aria-label'?: string;
+    routerOptions?: RouterOptions;
+}
+
+function Item(_props: BreadcrumbsItemProps): React.ReactNode {
+    return null;
+}
+
+export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps {
+    id?: string;
+    showRoot?: boolean;
+    separator?: React.ReactNode;
+    maxItems?: number;
+    popupStyle?: 'staircase';
+    popupPlacement?: PopupPlacement;
+    children: React.ReactElement<BreadcrumbsItemProps> | React.ReactElement<BreadcrumbsItemProps>[];
+    navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void;
+    disabled?: boolean;
+    onAction?: (key: Key) => void;
+}
+
+export const Breadcrumbs = React.forwardRef(function Breadcrumbs(
+    props: BreadcrumbsProps,
+    ref: React.Ref<HTMLOListElement>,
+) {
+    const listRef = React.useRef<HTMLOListElement>(null);
+    const containerRef = useForkRef(ref, listRef);
+
+    const items: React.ReactElement[] = [];
+    React.Children.forEach(props.children, (child, index) => {
+        if (React.isValidElement(child)) {
+            if (child.key === undefined || child.key === null) {
+                child = React.cloneElement(child, {key: index});
+            }
+            items.push(child);
+        }
+    });
+
+    const [visibleItemsCount, setVisibleItemsCount] = React.useState(items.length);
+    const [calculated, setCalculated] = React.useState(false);
+    const recalculate = (visibleItems: number) => {
+        const list = listRef.current;
+        if (!list) {
+            return;
+        }
+        const listItems = Array.from(list.children) as HTMLElement[];
+        if (listItems.length === 0) {
+            return;
+        }
+        const containerWidth = list.offsetWidth;
+        let newVisibleItemsCount = 0;
+        let calculatedWidth = 0;
+        let maxItems = props.maxItems || Infinity;
+
+        let rootWidth = 0;
+        if (props.showRoot) {
+            const item = listItems.shift();
+            if (item) {
+                rootWidth = item.scrollWidth;
+                calculatedWidth += rootWidth;
+            }
+            newVisibleItemsCount++;
+        }
+
+        const hasMenu = items.length > visibleItems;
+        if (hasMenu) {
+            const item = listItems.shift();
+            if (item) {
+                calculatedWidth += item.offsetWidth;
+            }
+            maxItems--;
+        }
+
+        if (props.showRoot && calculatedWidth >= containerWidth) {
+            calculatedWidth -= rootWidth;
+            newVisibleItemsCount--;
+        }
+
+        const lastItem = listItems.pop();
+        if (lastItem) {
+            calculatedWidth += Math.min(lastItem.offsetWidth, 200);
+            if (calculatedWidth < containerWidth) {
+                newVisibleItemsCount++;
+            }
+        }
+
+        for (let i = listItems.length - 1; i >= 0; i--) {
+            const item = listItems[i];
+            calculatedWidth += item.offsetWidth;
+            if (calculatedWidth >= containerWidth) {
+                break;
+            }
+            newVisibleItemsCount++;
+        }
+
+        newVisibleItemsCount = Math.max(Math.min(maxItems, newVisibleItemsCount), 1);
+        if (newVisibleItemsCount === visibleItemsCount) {
+            setCalculated(true);
+        } else {
+            setVisibleItemsCount(newVisibleItemsCount);
+        }
+    };
+
+    const handleResize = React.useCallback(() => {
+        setCalculated(false);
+        setVisibleItemsCount(items.length);
+    }, [items.length]);
+    useResizeObserver({
+        ref: listRef,
+        onResize: handleResize,
+    });
+
+    const lastChildren = React.useRef<typeof props.children | null>(null);
+    React.useLayoutEffect(() => {
+        if (calculated && props.children !== lastChildren.current) {
+            lastChildren.current = props.children;
+            setCalculated(false);
+            setVisibleItemsCount(items.length);
+        }
+    }, [calculated, items.length, props.children]);
+
+    React.useLayoutEffect(() => {
+        if (!calculated) {
+            recalculate(visibleItemsCount);
+        }
+    });
+
+    const {navigate} = props;
+    let contents = items;
+    if (items.length > visibleItemsCount) {
+        contents = [];
+        const breadcrumbs = [...items];
+        let endItems = visibleItemsCount;
+        if (props.showRoot && visibleItemsCount > 1) {
+            const rootItem = breadcrumbs.shift();
+            if (rootItem) {
+                contents.push(rootItem);
+            }
+            endItems--;
+        }
+        const hiddenItems = breadcrumbs.slice(0, -endItems);
+        const menuItem = (
+            <BreadcrumbItem itemType="menu">
+                <DropdownMenu
+                    items={hiddenItems.map((el, index) => {
+                        return {
+                            ...el.props,
+                            text: el.props.children,
+                            disabled: props.disabled,
+                            items: [],
+                            action: (event) => {
+                                if (typeof props.onAction === 'function') {
+                                    props.onAction(el.key ?? index);
+                                }
+
+                                // TODO: move this logic to DropdownMenu
+                                const target = event.currentTarget;
+                                if (
+                                    typeof navigate === 'function' &&
+                                    target instanceof HTMLAnchorElement
+                                ) {
+                                    if (el.props.href && shouldClientNavigate(target, event)) {
+                                        event.preventDefault();
+                                        navigate(el.props.href, el.props.routerOptions);
+                                    }
+                                }
+                            },
+                        };
+                    })}
+                    popupProps={{
+                        className: b('popup', {
+                            staircase: props.popupStyle === 'staircase',
+                        }),
+                        placement: props.popupPlacement,
+                    }}
+                    renderSwitcher={({onClick}) => (
+                        <Button
+                            title={i18n('label_more')}
+                            className={b('more-button')}
+                            onClick={onClick}
+                            size="s"
+                            view="flat"
+                            disabled={props.disabled}
+                        >
+                            <Button.Icon>...</Button.Icon>
+                        </Button>
+                    )}
+                />
+            </BreadcrumbItem>
+        );
+
+        contents.push(menuItem);
+        contents.push(...breadcrumbs.slice(-endItems));
+    }
+
+    const lastIndex = contents.length - 1;
+    const breadcrumbItems = contents.map((child, index) => {
+        const isCurrent = index === lastIndex;
+        const key = child.key ?? index;
+        const handleAction = () => {
+            if (typeof props.onAction === 'function') {
+                props.onAction(key);
+            }
+        };
+
+        return (
+            <li key={index} className={b('item', {calculating: !calculated})}>
+                <BreadcrumbItem
+                    {...child.props}
+                    key={key}
+                    current={isCurrent}
+                    disabled={props.disabled}
+                    onAction={handleAction}
+                    navigate={navigate}
+                >
+                    {child.props.children}
+                </BreadcrumbItem>
+                {isCurrent ? null : <BreadcrumbsSeparator separator={props.separator} />}
+            </li>
+        );
+    });
+    return (
+        <ol
+            ref={containerRef}
+            {...filterDOMProps(props, {labelable: true})}
+            data-qa={props.qa}
+            className={b(null, props.className)}
+            style={props.style}
+        >
+            {breadcrumbItems}
+        </ol>
+    );
+}) as unknown as BreadcrumbsComponent;
+
+type BreadcrumbsComponent = React.FunctionComponent<
+    BreadcrumbsProps & {ref?: React.Ref<HTMLElement>}
+> & {
+    Item: typeof Item;
+};
+
+Breadcrumbs.Item = Item;
+Breadcrumbs.displayName = 'Breadcrumbs';
+
+export {Item as BreadcrumbsItem};
diff --git a/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx b/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx
new file mode 100644
index 0000000000..299f19fd4e
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import type {BreadcrumbsProps} from './Breadcrumbs';
+import {b} from './utils';
+
+type Props = Pick<BreadcrumbsProps, 'separator'>;
+
+export function BreadcrumbsSeparator({separator}: Props) {
+    return (
+        <div aria-hidden={true} className={b('divider')}>
+            {separator ?? '/'}
+        </div>
+    );
+}
+
+BreadcrumbsSeparator.displayName = 'Breadcrumbs.Separator';
diff --git a/src/components/lab/Breadcrumbs/README.md b/src/components/lab/Breadcrumbs/README.md
new file mode 100644
index 0000000000..b3e3683234
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/README.md
@@ -0,0 +1,501 @@
+<!--GITHUB_BLOCK-->
+
+# Breadcrumbs
+
+<!--/GITHUB_BLOCK-->
+
+```tsx
+import {unstable_Breadcrumbs as Breadcrumbs} from '@gravity-ui/uikit/unstable';
+```
+
+`Breadcrumbs` is a navigation element that shows the current location of a page within a website’s hierarchy. It provides links that allow users to return to higher levels in the hierarchy, making it easier to navigate a site with multiple layers. Breadcrumbs are especially useful for large websites and applications with a hierarchical organization of pages.
+
+## Example
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Breadcrumbs>
+    <Breadcrumbs.Item>Region</Breadcrumbs.Item>
+    <Breadcrumbs.Item>Country</Breadcrumbs.Item>
+    <Breadcrumbs.Item>City</Breadcrumbs.Item>
+    <Breadcrumbs.Item>District</Breadcrumbs.Item>
+    <Breadcrumbs.Item>Street</Breadcrumbs.Item>
+</Breadcrumbs>
+`}
+>
+    <UIKit.Breadcrumbs>
+        <UIKit.Breadcrumbs.Item>Region</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>Country</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>City</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>District</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>Street</UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<Breadcrumbs>
+  <Breadcrumbs.Item>Region</Breadcrumbs.Item>
+  <Breadcrumbs.Item>Country</Breadcrumbs.Item>
+  <Breadcrumbs.Item>City</Breadcrumbs.Item>
+  <Breadcrumbs.Item>District</Breadcrumbs.Item>
+  <Breadcrumbs.Item>Street</Breadcrumbs.Item>
+</Breadcrumbs>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsExample />
+
+<!--/GITHUB_BLOCK-->
+
+### Events
+
+Use the `onAction` prop as a callback to handle click events on items.
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Breadcrumbs onAction={(id) => alert(id)}>
+    <Breadcrumbs.Item key={1}>Region</Breadcrumbs.Item>
+    <Breadcrumbs.Item key={2}>Country</Breadcrumbs.Item>
+    <Breadcrumbs.Item key={3}>City</Breadcrumbs.Item>
+    <Breadcrumbs.Item key={4}>District</Breadcrumbs.Item>
+    <Breadcrumbs.Item key={5}>Street</Breadcrumbs.Item>
+</Breadcrumbs>
+`}
+>
+    <UIKit.Breadcrumbs onAction={(id) => alert(id)}>
+        <UIKit.Breadcrumbs.Item key={1}>Region</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key={2}>Country</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key={3}>City</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key={4}>District</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key={5}>Street</UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+const [currentId, setCurrentId] = React.useState();
+const items = [
+    {id: 1, label: 'Region'},
+    {id: 2, label: 'Country'},
+    {id: 3, label: 'City'},
+    {id: 4, label: 'District'},
+    {id: 5, label: 'Street'},
+]
+<div>
+    <Breadcrumbs onAction={setCurrentId}>
+        {items.map((i) => <Breadcrumbs.Item key={i.id}>{i.label}</Breadcrumbs.Item>)}
+    </Breadcrumbs>
+    <p>You clicked item ID: {currentId}</p>
+</div>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsEvents />
+
+<!--/GITHUB_BLOCK-->
+
+### Links
+
+In Breadcrumbs, clicking an item normally triggers `onAction`. But you can also make them links to other pages or websites. To do that, add the href property to the `<Breadcrumbs.Item>` component.
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Breadcrumbs>
+    <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
+    <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
+    <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item>
+</Breadcrumbs>
+`}
+>
+    <UIKit.Breadcrumbs>
+        <UIKit.Breadcrumbs.Item href="/">Home</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item href="/components">Components</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<Breadcrumbs>
+  <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
+  <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
+  <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item>
+</Breadcrumbs>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsLinks />
+
+<!--/GITHUB_BLOCK-->
+
+### Root context
+
+To help users understand the overall structure, some applications always show the starting point (root item) of the Breadcrumbs, even when other items are hidden due to space limitations.
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Box overflow="hidden" width={200}>
+    <Breadcrumbs showRoot>
+        <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item>
+        <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item>
+        <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item>
+        <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item>
+        <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item>
+    </Breadcrumbs>
+</Box>
+`}
+>
+<UIKit.Box overflow="hidden" width={200}>
+    <UIKit.Breadcrumbs>
+        <UIKit.Breadcrumbs.Item key="home">Home</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key="trendy">Trendy</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key="2020 assets">March 2020 Assets</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key="winter">Winter</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item key="holiday">Holiday</UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</UIKit.Box>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<Box overflow="hidden" width={200}>
+  <Breadcrumbs showRoot>
+    <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item>
+    <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item>
+    <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item>
+    <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item>
+    <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item>
+  </Breadcrumbs>
+</Box>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsRootContext />
+
+<!--/GITHUB_BLOCK-->
+
+### Separator
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Breadcrumbs separator=">">
+    <Breadcrumbs.Item>Region</Breadcrumbs.Item>
+    <Breadcrumbs.Item>Country</Breadcrumbs.Item>
+    <Breadcrumbs.Item>City</Breadcrumbs.Item>
+    <Breadcrumbs.Item>District</Breadcrumbs.Item>
+    <Breadcrumbs.Item>Street</Breadcrumbs.Item>
+</Breadcrumbs>
+`}
+>
+    <UIKit.Breadcrumbs separator=">">
+        <UIKit.Breadcrumbs.Item>Region</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>Country</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>City</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>District</UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>Street</UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<Breadcrumbs separator="›">
+  {breadcrumbs}
+</Breadcrumbs>
+<Breadcrumbs separator="—">
+  {breadcrumbs}
+</Breadcrumbs>
+<Breadcrumbs separator={<ChevronRight />}>
+  {breadcrumbs}
+</Breadcrumbs>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsSeparator />
+
+<!--/GITHUB_BLOCK-->
+
+### Breadcrumbs with icons
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<Breadcrumbs>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <House /> uikit
+    </Flex>
+  </Breadcrumbs.Item>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <Flame /> components
+    </Flex>
+  </Breadcrumbs.Item>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <Rocket style={{minWidth: 16}} />
+      <Text ellipsis variant="inherit">
+        Breadcrumbs
+      </Text>
+    </Flex>
+  </Breadcrumbs.Item>
+</Breadcrumbs>
+`}
+>
+    <UIKit.Breadcrumbs>
+        <UIKit.Breadcrumbs.Item>
+            <UIKit.Flex alignItems="center" gap={1}>
+                <UIKit.Icon data={() => (
+                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M12.5 12.618c.307-.275.5-.674.5-1.118V6.977a1.5 1.5 0 0 0-.585-1.189l-3.5-2.692a1.5 1.5 0 0 0-1.83 0l-3.5 2.692A1.5 1.5 0 0 0 3 6.978V11.5A1.496 1.496 0 0 0 4.493 13H5V9.5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2V13h.507c.381-.002.73-.146.993-.382Zm2-1.118a3 3 0 0 1-3 3h-7a3 3 0 0 1-3-3V6.977A3 3 0 0 1 2.67 4.6l3.5-2.692a3 3 0 0 1 3.66 0l3.5 2.692a3.003 3.003 0 0 1 1.17 2.378V11.5Zm-5-2A.5.5 0 0 0 9 9H7a.5.5 0 0 0-.5.5V13h3V9.5Z" clip-rule="evenodd"/></svg>
+                )} /> uikit
+            </UIKit.Flex>
+        </UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>
+            <UIKit.Flex alignItems="center" gap={1}>
+                <UIKit.Icon data={() => (
+                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fillRule="evenodd" d="m6.452 6.864 1.13-2.173a31.715 31.715 0 0 1 1.872-3.095c.964 1.045 1.906 2.3 2.612 3.622.748 1.402 1.184 2.789 1.184 4.032 0 1.427-.904 2.83-2.153 3.613.058-.265.09-.553.09-.863 0-1.255-.674-2.336-1.143-2.963a8.82 8.82 0 0 0-1.01-1.125l-.024-.02-.008-.008L9 7.88l-.001-.001C8.996 7.88 8.996 7.878 8 9a7.03 7.03 0 0 0 .984 1.133c.37.534.704 1.2.704 1.867 0 .77-.313 1.276-.618 1.587-.159.162-.279.38-.314.6a.786.786 0 0 0 0 .264.694.694 0 0 0 .06.182c.113.225.343.37.594.35 2.836-.235 5.34-2.87 5.34-5.733 0-3.149-2.177-6.538-4.357-8.845A1.313 1.313 0 0 0 9.435 0 1.32 1.32 0 0 0 8.35.556 33.486 33.486 0 0 0 6.25 4l-.955-1.337a.986.986 0 0 0-1.589-.018C2.62 4.123 1.25 6.249 1.25 9.25c0 2.863 2.504 5.498 5.34 5.733.25.02.481-.125.593-.35a.672.672 0 0 0 .06-.182.786.786 0 0 0 .001-.263 1.145 1.145 0 0 0-.314-.601c-.305-.31-.617-.817-.617-1.587 0-.666.333-1.333.703-1.867l.09-.128C7.544 9.405 8 9 8 9l-.997-1.12H7l-.003.003-.008.007-.024.021-.073.07a8.827 8.827 0 0 0-.937 1.056c-.47.626-1.143 1.707-1.143 2.962 0 .31.033.598.09.863C3.654 12.08 2.75 10.677 2.75 9.25c0-2.171.847-3.812 1.745-5.126l.534.748 1.423 1.992ZM8 9l.997-1.121L8 6.993l-.997.886L8 9Z" clipRule="evenodd" /></svg>
+                )} /> components
+            </UIKit.Flex>
+        </UIKit.Breadcrumbs.Item>
+        <UIKit.Breadcrumbs.Item>
+            <UIKit.Flex alignItems="center" gap={1}>
+                <UIKit.Icon data={() => (
+                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill="currentColor" fill-rule="evenodd" d="M15.37 1.268a.75.75 0 0 0-.62-.634 7.745 7.745 0 0 0-7.516 3.055l-.156.212-2.59.112a.75.75 0 0 0-.433.16L.696 6.827a.75.75 0 0 0 .206 1.292L4.25 9.352c.673.273 1.13.56 1.484.913.352.353.64.811.913 1.484l1.234 3.35a.75.75 0 0 0 1.292.205l2.652-3.36a.75.75 0 0 0 .16-.431l.113-2.591.212-.156a7.745 7.745 0 0 0 3.058-7.498ZM4.794 5.501l1.144-.05-1.69 2.302-1.572-.58 2.118-1.672Zm4.032 7.822-.58-1.572 2.302-1.69-.05 1.145-1.672 2.117Zm5.127-11.277a6.246 6.246 0 0 0-5.511 2.531l-2.78 3.786c.425.237.8.51 1.132.842.332.332.606.707.842 1.133l3.786-2.78a6.246 6.246 0 0 0 2.53-5.512ZM2.378 13.952a5.36 5.36 0 0 1-.377.053 5.52 5.52 0 0 1 .05-.366c.104-.59.294-1.014.527-1.247.244-.244.694-.274 1.004.036.31.31.281.76.036 1.005-.223.223-.644.413-1.24.519ZM.48 15.069a7.796 7.796 0 0 1 .025-1.18c.082-.838.33-1.876 1.012-2.557.853-.854 2.253-.838 3.126.035.873.874.89 2.273.036 3.126-1.082 1.082-3.112 1.068-3.735 1.036a.487.487 0 0 1-.319-.145.486.486 0 0 1-.145-.316Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg>
+                )} />
+                <UIKit.Text varian="inherit" ellipsis>
+                    Breadcrumbs
+                </UIKit.Text>
+            </UIKit.Flex>
+        </UIKit.Breadcrumbs.Item>
+    </UIKit.Breadcrumbs>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<Breadcrumbs>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <House /> uikit
+    </Flex>
+  </Breadcrumbs.Item>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <Flame /> components
+    </Flex>
+  </Breadcrumbs.Item>
+  <Breadcrumbs.Item>
+    <Flex alignItems="center" gap={1}>
+      <Rocket style={{minWidth: 16}} />
+      <Text ellipsis variant="inherit">
+        Breadcrumbs
+      </Text>
+    </Flex>
+  </Breadcrumbs.Item>
+</Breadcrumbs>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsWithIcons />
+
+<!--/GITHUB_BLOCK-->
+
+### Integration with routers
+
+`Breadcrumbs` component accepts navigate function received from your router for performing a client side navigation programmatically.
+The following example shows the general pattern.
+
+```jsx
+function Header() {
+  const navigate = useNavigateFromYourRouter();
+
+  return (
+    <header>
+      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
+    </header>
+  );
+}
+```
+
+#### React Router v5
+
+```jsx
+import {useHistory} from 'react-router-dom';
+import {Breadcrumbs} from '@gravity-ui/uikit';
+
+function Header() {
+  const history = useHistory();
+
+  return (
+    <header>
+      <Breadcrumbs navigate={history.push}>{/*...*/}</Breadcrumbs>
+    </header>
+  );
+}
+```
+
+#### React Router v6
+
+```jsx
+import {useNavigate} from 'react-router-dom';
+import {Breadcrumbs} from '@gravity-ui/uikit';
+
+function Header() {
+  const navigate = useNavigate();
+
+  return (
+    <header>
+      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
+    </header>
+  );
+}
+```
+
+#### Next.js
+
+`App router`
+
+```jsx
+'use client';
+
+import {useRouter} from 'next/navigation';
+import {Breadcrumbs} from '@gravity-ui/uikit';
+
+function Header() {
+  const router = useRouter();
+
+  return (
+    <header>
+      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
+    </header>
+  );
+}
+```
+
+`Pages router`
+
+```jsx
+import {useRouter} from 'next/router';
+import {Breadcrumbs} from '@gravity-ui/uikit';
+
+function Header() {
+  const router = useRouter();
+
+  return (
+    <header>
+      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
+    </header>
+  );
+}
+```
+
+### Landmarks
+
+When breadcrumbs are used as a main navigation element for a page, they can be placed in a [navigation landmark](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/navigation.html). Landmarks help assistive technology users quickly find major sections of a page. Place breadcrumbs inside a `<nav>` element with an aria-label to create a navigation landmark.
+
+<!--LANDING_BLOCK
+
+<ExampleBlock
+    code={`
+<nav aria-label="Breadcrumbs">
+  <Breadcrumbs>
+      <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item>
+  </Breadcrumbs>
+</nav>
+`}
+>
+    <nav aria-label="Breadcrumbs">
+        <UIKit.Breadcrumbs>
+            <UIKit.Breadcrumbs.Item href="/">Home</UIKit.Breadcrumbs.Item>
+            <UIKit.Breadcrumbs.Item href="/components">Components</UIKit.Breadcrumbs.Item>
+            <UIKit.Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</UIKit.Breadcrumbs.Item>
+        </UIKit.Breadcrumbs>
+    </nav>
+</ExampleBlock>
+
+LANDING_BLOCK-->
+
+<!--GITHUB_BLOCK-->
+
+```jsx
+<nav aria-label="Breadcrumbs">
+  <Breadcrumbs>
+    <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
+    <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
+    <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item>
+  </Breadcrumbs>
+</nav>
+```
+
+<!-- Storybook example -->
+
+<BreadcrumbsLinks />
+
+<!--/GITHUB_BLOCK-->
+
+## Properties
+
+| Name             | Description                                                           | Type                                       | Default |
+| :--------------- | :-------------------------------------------------------------------- | :----------------------------------------- | :------ |
+| children         | Breadcrumb items.                                                     | `React.ReactElement<BreadcrumbsItemProps>` |         |
+| disabled         | Whether the Breadcrumbs are disabled.                                 | `boolean`                                  |         |
+| showRoot         | Whether to always show the root item if the items are collapsed.      | `boolean`                                  |         |
+| popupPlacement   | Style of collapsed item popup.                                        | `PopupPlacement`                           |         |
+| popupStyle       | Style of collapsed item popup.                                        | `"staircase"`                              |         |
+| qa               | HTML `data-qa` attribute, used in tests.                              | `string`                                   |         |
+| separator        | Custom separator node.                                                | `React.ReactNode`                          | "/"     |
+| action           | `click` event handler.                                                | `(id: Key) => void`                        |         |
+| navigate         | client side navigation.                                               | `(href: string) => void`                   |         |
+| id               | The element's unique identifier.                                      | `string`                                   |         |
+| className        | CSS class name for the element.                                       | `string`                                   |         |
+| style            | Sets inline style for the element.                                    | `CSSProperties`                            |         |
+| aria-label       | Defines a string value that labels the current element.               | `string`                                   |         |
+| aria-labelledby  | Identifies the element (or elements) that labels the current element. | `string`                                   |         |
+| aria-describedby | Identifies the element (or elements) that describes the object.       | `string`                                   |         |
+
+### BreadcrumbsItemProps
+
+| Name       | Description                                                        | Type                              | Default |
+| :--------- | :----------------------------------------------------------------- | :-------------------------------- | :------ |
+| children   | Breadcrumb content.                                                | `string`                          |         |
+| title      | A string representation of the item's contents.                    | `string`                          |         |
+| aria-label | An accessibility label for this item.                              | `string`                          |         |
+| href       | A URL to link to.                                                  | `string`                          |         |
+| target     | The target window for the link.                                    | `React.HTMLAttributeAnchorTarget` |         |
+| rel        | The relationship between the linked resource and the current page. | `string`                          |         |
diff --git a/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx b/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx
new file mode 100644
index 0000000000..59b90c69c3
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+
+import {ChevronRight, Flame, House, Rocket} from '@gravity-ui/icons';
+import type {Meta, StoryObj} from '@storybook/react';
+
+import {Text} from '../../../Text';
+import {Box, Flex} from '../../../layout';
+import type {Key} from '../../../types';
+import {Breadcrumbs} from '../Breadcrumbs';
+
+const meta: Meta<typeof Breadcrumbs> = {
+    title: 'Lab/Breadcrumbs',
+    component: Breadcrumbs,
+};
+
+export default meta;
+
+type Story = StoryObj<typeof Breadcrumbs>;
+
+export const Default = {
+    render: (args) => (
+        <Breadcrumbs {...args}>
+            <Breadcrumbs.Item>Region</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Country</Breadcrumbs.Item>
+            <Breadcrumbs.Item>City</Breadcrumbs.Item>
+            <Breadcrumbs.Item>District</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Street</Breadcrumbs.Item>
+        </Breadcrumbs>
+    ),
+} satisfies Story;
+
+export const Events = {
+    render: function BreadcrumbsEvents(props) {
+        const [currentId, setCurrentId] = React.useState<Key>();
+        const items = [
+            {id: 1, label: 'Region'},
+            {id: 2, label: 'Country'},
+            {id: 3, label: 'City'},
+            {id: 4, label: 'District'},
+            {id: 5, label: 'Street'},
+        ];
+        return (
+            <div>
+                <Breadcrumbs {...props} onAction={setCurrentId}>
+                    {items.map((i) => (
+                        <Breadcrumbs.Item key={i.id}>{i.label}</Breadcrumbs.Item>
+                    ))}
+                </Breadcrumbs>
+                <p>You clicked item ID: {currentId}</p>
+            </div>
+        );
+    },
+} satisfies Story;
+
+export const Links = {
+    render: (args) => (
+        <Breadcrumbs {...args}>
+            <Breadcrumbs.Item href="https://gravity-ui.com" target="_blank">
+                Home
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item href="https://gravity-ui.com/components" target="_blank">
+                Components
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item
+                href="https://gravity-ui.com/components/uikit/breadcrumbs"
+                target="_blank"
+            >
+                Breadcrumbs
+            </Breadcrumbs.Item>
+        </Breadcrumbs>
+    ),
+} satisfies Story;
+
+export const RootContext = {
+    render: (args) => (
+        <Box overflow="hidden" width={200} style={{padding: 2}}>
+            <Breadcrumbs {...args} showRoot>
+                <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item>
+                <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item>
+                <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item>
+                <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item>
+                <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item>
+            </Breadcrumbs>
+        </Box>
+    ),
+} satisfies Story;
+
+export const Separator = {
+    render: (args) => {
+        const breadcrumbs = [
+            <Breadcrumbs.Item key={1}>uikit</Breadcrumbs.Item>,
+            <Breadcrumbs.Item key={2}>components</Breadcrumbs.Item>,
+            <Breadcrumbs.Item key={3}>Breadcrumbs</Breadcrumbs.Item>,
+        ];
+        return (
+            <div>
+                <Breadcrumbs {...args} separator="›">
+                    {breadcrumbs}
+                </Breadcrumbs>
+                <Breadcrumbs {...args} separator="—">
+                    {breadcrumbs}
+                </Breadcrumbs>
+                <Breadcrumbs {...args} separator={<ChevronRight />}>
+                    {breadcrumbs}
+                </Breadcrumbs>
+            </div>
+        );
+    },
+} satisfies Story;
+
+export const WithIcons = {
+    render: (args) => (
+        <Breadcrumbs {...args}>
+            <Breadcrumbs.Item>
+                <Flex alignItems="center" gap={1}>
+                    <House /> uikit
+                </Flex>
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item>
+                <Flex alignItems="center" gap={1}>
+                    <Flame /> components
+                </Flex>
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item>
+                <Flex alignItems="center" gap={1}>
+                    <Rocket style={{minWidth: 16}} />
+                    <Text ellipsis variant="inherit">
+                        Breadcrumbs
+                    </Text>
+                </Flex>
+            </Breadcrumbs.Item>
+        </Breadcrumbs>
+    ),
+} satisfies Story;
+
+export const Landmarks = {
+    render: (args) => <nav aria-label="Breadcrumb">{Links.render(args)}</nav>,
+} satisfies Story;
diff --git a/src/components/lab/Breadcrumbs/__stories__/Docs.mdx b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx
new file mode 100644
index 0000000000..12225aef2b
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx
@@ -0,0 +1,39 @@
+import {
+    Meta,
+    Markdown,
+    Canvas,
+    AnchorMdx,
+    CodeOrSourceMdx,
+    HeadersMdx,
+} from '@storybook/addon-docs';
+import * as Stories from './Breadcrumbs.stories';
+import Readme from '../README.md?raw';
+
+export const BreadcrumbsExample = () => <Canvas of={Stories.Default} sourceState="none" />;
+export const BreadcrumbsEvents = () => <Canvas of={Stories.Events} sourceState="none" />;
+export const BreadcrumbsLinks = () => <Canvas of={Stories.Links} sourceState="none" />;
+export const BreadcrumbsRootContext = () => <Canvas of={Stories.RootContext} sourceState="none" />;
+export const BreadcrumbsSeparator = () => <Canvas of={Stories.Separator} sourceState="none" />;
+export const BreadcrumbsWithIcons = () => <Canvas of={Stories.WithIcons} sourceState="none" />;
+export const BreadcrumbsLandmarks = () => <Canvas of={Stories.Landmarks} sourceState="none" />;
+
+<Meta of={Stories} />
+
+<Markdown
+    options={{
+        overrides: {
+            code: CodeOrSourceMdx,
+            a: AnchorMdx,
+            ...HeadersMdx,
+            BreadcrumbsExample,
+            BreadcrumbsEvents,
+            BreadcrumbsLinks,
+            BreadcrumbsRootContext,
+            BreadcrumbsSeparator,
+            BreadcrumbsWithIcons,
+            BreadcrumbsLandmarks,
+        },
+    }}
+>
+    {Readme}
+</Markdown>
diff --git a/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
new file mode 100644
index 0000000000..f44ec07f73
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
@@ -0,0 +1,284 @@
+import React from 'react';
+
+import {userEvent} from '@testing-library/user-event';
+
+import {render, screen, within} from '../../../../../test-utils/utils';
+import {Breadcrumbs} from '../Breadcrumbs';
+
+beforeEach(() => {
+    jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
+        this: Element,
+    ) {
+        if (this instanceof HTMLOListElement) {
+            return 500;
+        }
+        return 100;
+    });
+});
+
+it('handles multiple items', () => {
+    render(
+        <Breadcrumbs>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const item1 = screen.getByText('Folder 1');
+    expect(item1.tabIndex).toBe(0);
+    expect(item1).not.toHaveAttribute('aria-current');
+    const item2 = screen.getByText('Folder 2');
+    expect(item2.tabIndex).toBe(0);
+    expect(item2).not.toHaveAttribute('aria-current');
+    const item3 = screen.getByText('Folder 3');
+    expect(item3.tabIndex).toBe(-1);
+    expect(item3).toHaveAttribute('aria-current', 'page');
+});
+
+it('should handle forward ref', function () {
+    let ref: React.RefObject<any> | undefined;
+    const Component = () => {
+        ref = React.useRef();
+        return (
+            <Breadcrumbs ref={ref} aria-label="breadcrumbs-test">
+                <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            </Breadcrumbs>
+        );
+    };
+    render(<Component />);
+    const breadcrumb = screen.getByLabelText('breadcrumbs-test');
+    expect(breadcrumb).toBe(ref?.current);
+});
+
+it('shows four items with no menu', () => {
+    render(
+        <Breadcrumbs maxItems={4}>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const {children} = screen.getByRole('list');
+    expect(within(children[0] as HTMLElement).queryByRole('button')).toBeNull();
+    expect(screen.getByText('Folder 1')).toBeTruthy();
+    expect(screen.getByText('Folder 2')).toBeTruthy();
+    expect(screen.getByText('Folder 3')).toBeTruthy();
+    expect(screen.getByText('Folder 4')).toBeTruthy();
+});
+
+it('shows a maximum of 3 items', () => {
+    render(
+        <Breadcrumbs maxItems={3}>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const {children} = screen.getByRole('list');
+    expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy();
+    expect(() => screen.getByText('Folder 1')).toThrow();
+    expect(() => screen.getByText('Folder 2')).toThrow();
+    expect(screen.getByText('Folder 3')).toBeTruthy();
+    expect(screen.getByText('Folder 4')).toBeTruthy();
+});
+
+it('shows a maximum of 3 items with showRoot', () => {
+    render(
+        <Breadcrumbs maxItems={3} showRoot>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const {children} = screen.getByRole('list');
+    expect(screen.getByText('Folder 1')).toBeTruthy();
+    expect(within(children[1] as HTMLElement).getByRole('button')).toBeTruthy();
+    expect(() => screen.getByText('Folder 2')).toThrow();
+    expect(() => screen.getByText('Folder 3')).toThrow();
+    expect(screen.getByText('Folder 4')).toBeTruthy();
+});
+
+it('shows less than 4 items if they do not fit', () => {
+    jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
+        this: Element,
+    ) {
+        if (this instanceof HTMLUListElement) {
+            return 300;
+        }
+
+        return 100;
+    });
+
+    render(
+        <Breadcrumbs maxItems={4}>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 5</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+
+    const {children} = screen.getByRole('list');
+    expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy();
+    expect(() => screen.getByText('Folder 1')).toThrow();
+    expect(() => screen.getByText('Folder 2')).toThrow();
+    expect(() => screen.getByText('Folder 3')).toThrow();
+    expect(() => screen.getByText('Folder 4')).toThrow();
+    expect(screen.getByText('Folder 5')).toBeTruthy();
+});
+
+it('collapses root item if it does not fit', () => {
+    jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
+        this: Element,
+    ) {
+        if (this instanceof HTMLUListElement) {
+            return 300;
+        }
+
+        return 100;
+    });
+
+    render(
+        <Breadcrumbs showRoot>
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item>
+            <Breadcrumbs.Item>Folder 5</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+
+    const {children} = screen.getByRole('list');
+    expect(() => screen.getByText('Folder 1')).toThrow();
+    expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy();
+    expect(() => screen.getByText('Folder 2')).toThrow();
+    expect(() => screen.getByText('Folder 3')).toThrow();
+    expect(() => screen.getByText('Folder 4')).toThrow();
+    expect(screen.getByText('Folder 5')).toBeTruthy();
+});
+
+it('supports aria-label', function () {
+    render(
+        <Breadcrumbs aria-label="Test">
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const breadcrumbs = screen.getByRole('list');
+    expect(breadcrumbs).toHaveAttribute('aria-label', 'Test');
+});
+
+it('supports aria-labelledby', function () {
+    render(
+        <React.Fragment>
+            <span id="test">Test</span>
+            <Breadcrumbs aria-labelledby="test">
+                <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            </Breadcrumbs>
+        </React.Fragment>,
+    );
+    const breadcrumbs = screen.getByRole('list');
+    expect(breadcrumbs).toHaveAttribute('aria-labelledby', 'test');
+});
+
+it('supports aria-describedby', function () {
+    render(
+        <React.Fragment>
+            <span id="test">Test</span>
+            <Breadcrumbs aria-describedby="test">
+                <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+            </Breadcrumbs>
+        </React.Fragment>,
+    );
+    const breadcrumbs = screen.getByRole('list');
+    expect(breadcrumbs).toHaveAttribute('aria-describedby', 'test');
+});
+
+it('supports custom props', function () {
+    render(
+        <Breadcrumbs data-testid="test">
+            <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+    const breadcrumbs = screen.getByRole('list');
+    expect(breadcrumbs).toHaveAttribute('data-testid', 'test');
+});
+
+it('should support links', async function () {
+    render(
+        <Breadcrumbs>
+            <Breadcrumbs.Item href="https://example.com">Example.com</Breadcrumbs.Item>
+            <Breadcrumbs.Item href="https://example.com/foo">Foo</Breadcrumbs.Item>
+            <Breadcrumbs.Item href="https://example.com/foo/bar">Bar</Breadcrumbs.Item>
+            <Breadcrumbs.Item href="https://example.com/foo/bar/baz">Baz</Breadcrumbs.Item>
+            <Breadcrumbs.Item href="https://example.com/foo/bar/baz/qux">Qux</Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+
+    const links = screen.getAllByRole('link');
+    expect(links).toHaveLength(3);
+    expect(links[0]).toHaveAttribute('href', 'https://example.com/foo/bar');
+    expect(links[1]).toHaveAttribute('href', 'https://example.com/foo/bar/baz');
+    expect(links[2]).toHaveAttribute('href', 'https://example.com/foo/bar/baz/qux');
+
+    const menuButton = screen.getByRole('button');
+    await userEvent.click(menuButton);
+
+    const menu = screen.getByRole('menu');
+    const items = within(menu).getAllByRole('menuitem');
+    expect(items).toHaveLength(2);
+    expect(items[0].tagName).toBe('A');
+    expect(items[0]).toHaveAttribute('href', 'https://example.com');
+    expect(items[1].tagName).toBe('A');
+    expect(items[1]).toHaveAttribute('href', 'https://example.com/foo');
+});
+
+it('should support RouterProvider', async () => {
+    /*
+        declare module '@gravity-ui/uikit' {
+            interface RouterConfig {
+                routerOptions: {
+                    foo: string;
+                };
+            }
+        }
+    */
+    const navigate = jest.fn();
+    render(
+        <Breadcrumbs navigate={navigate}>
+            <Breadcrumbs.Item href="/" routerOptions={{foo: 'bar'} as any}>
+                Example.com
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item href="/foo" routerOptions={{foo: 'foo'} as any}>
+                Foo
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item href="/foo/bar" routerOptions={{foo: 'bar'} as any}>
+                Bar
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item href="/foo/bar/baz" routerOptions={{foo: 'bar'} as any}>
+                Baz
+            </Breadcrumbs.Item>
+            <Breadcrumbs.Item href="/foo/bar/baz/qux" routerOptions={{foo: 'bar'} as any}>
+                Qux
+            </Breadcrumbs.Item>
+        </Breadcrumbs>,
+    );
+
+    const links = screen.getAllByRole('link');
+    expect(links[0]).toHaveAttribute('href', '/foo/bar');
+    await userEvent.click(links[0]);
+    expect(navigate).toHaveBeenCalledWith('/foo/bar', {foo: 'bar'});
+    navigate.mockReset();
+
+    const menuButton = screen.getByRole('button');
+    await userEvent.click(menuButton);
+
+    const menu = screen.getByRole('menu');
+    const items = within(menu).getAllByRole('menuitem');
+    expect(items[1]).toHaveAttribute('href', '/foo');
+    await userEvent.click(items[1]);
+    expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'foo'});
+});
diff --git a/src/components/lab/Breadcrumbs/i18n/en.json b/src/components/lab/Breadcrumbs/i18n/en.json
new file mode 100644
index 0000000000..34d12c4a1a
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/i18n/en.json
@@ -0,0 +1,4 @@
+{
+  "breadcrumbs": "Breadcrumbs",
+  "label_more": "Show more"
+}
diff --git a/src/components/lab/Breadcrumbs/i18n/index.ts b/src/components/lab/Breadcrumbs/i18n/index.ts
new file mode 100644
index 0000000000..c69f26ae7e
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/i18n/index.ts
@@ -0,0 +1,8 @@
+import {addComponentKeysets} from '../../../utils/addComponentKeysets';
+
+import en from './en.json';
+import ru from './ru.json';
+
+const COMPONENT = 'lab/Breadcrumbs';
+
+export default addComponentKeysets({en, ru}, COMPONENT);
diff --git a/src/components/lab/Breadcrumbs/i18n/ru.json b/src/components/lab/Breadcrumbs/i18n/ru.json
new file mode 100644
index 0000000000..4f06a5a545
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/i18n/ru.json
@@ -0,0 +1,4 @@
+{
+  "breadcrumbs": "Навигация",
+  "label_more": "Показать больше"
+}
diff --git a/src/components/lab/Breadcrumbs/index.ts b/src/components/lab/Breadcrumbs/index.ts
new file mode 100644
index 0000000000..ce977548b1
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './Breadcrumbs';
diff --git a/src/components/lab/Breadcrumbs/utils.ts b/src/components/lab/Breadcrumbs/utils.ts
new file mode 100644
index 0000000000..c1ee9352d2
--- /dev/null
+++ b/src/components/lab/Breadcrumbs/utils.ts
@@ -0,0 +1,24 @@
+import {block} from '../../utils/cn';
+
+interface Modifiers {
+    metaKey?: boolean;
+    ctrlKey?: boolean;
+    altKey?: boolean;
+    shiftKey?: boolean;
+}
+export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) {
+    // Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe.
+    const target = link.getAttribute('target');
+    return (
+        link.href &&
+        (!target || target === '_self') &&
+        link.origin === location.origin &&
+        !link.hasAttribute('download') &&
+        !modifiers.metaKey && // open in new tab (mac)
+        !modifiers.ctrlKey && // open in new tab (windows)
+        !modifiers.altKey && // download
+        !modifiers.shiftKey
+    );
+}
+
+export const b = block('breadcrumbs2');
diff --git a/src/components/types.ts b/src/components/types.ts
index 7fb4f4d49a..a4ce1ff1ca 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -63,3 +63,32 @@ export interface ControlGroupProps<ValueType extends string = string> {
     'aria-label'?: string;
     'aria-labelledby'?: string;
 }
+
+export type Key = string | number;
+
+export interface RouterConfig {}
+
+export type Href = RouterConfig extends {href: infer H} ? H : string;
+export type RouterOptions = RouterConfig extends {routerOptions: infer O} ? O : never;
+
+export interface AriaLabelingProps {
+    /**
+     * Defines a string value that labels the current element.
+     */
+    'aria-label'?: string;
+
+    /**
+     * Identifies the element (or elements) that labels the current element.
+     */
+    'aria-labelledby'?: string;
+
+    /**
+     * Identifies the element (or elements) that describes the object.
+     */
+    'aria-describedby'?: string;
+
+    /**
+     * Identifies the element (or elements) that provide a detailed, extended description for the object.
+     */
+    'aria-details'?: string;
+}
diff --git a/src/components/utils/filterDOMProps.ts b/src/components/utils/filterDOMProps.ts
new file mode 100644
index 0000000000..3121065071
--- /dev/null
+++ b/src/components/utils/filterDOMProps.ts
@@ -0,0 +1,46 @@
+import type {AriaLabelingProps} from '../types';
+
+const DOMPropNames = new Set(['id']);
+
+const labelablePropNames = new Set([
+    'aria-label',
+    'aria-labelledby',
+    'aria-describedby',
+    'aria-details',
+]);
+
+interface Options {
+    /**
+     * If labelling associated aria properties should be included in the filter.
+     */
+    labelable?: boolean;
+    /**
+     * A Set of other property names that should be included in the filter.
+     */
+    propNames?: Set<string>;
+}
+
+const propRe = /^(data-.*)$/;
+
+export function filterDOMProps(
+    props: {id?: string} & AriaLabelingProps,
+    options: Options = {},
+): {id?: string} & AriaLabelingProps {
+    const {labelable, propNames} = options;
+    const filteredProps = {};
+
+    for (const prop in props) {
+        if (
+            Object.prototype.hasOwnProperty.call(props, prop) &&
+            (DOMPropNames.has(prop) ||
+                (labelable && labelablePropNames.has(prop)) ||
+                propNames?.has(prop) ||
+                propRe.test(prop))
+        ) {
+            // @ts-expect-error
+            filteredProps[prop] = props[prop];
+        }
+    }
+
+    return filteredProps;
+}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 9a98690749..c19b579ae2 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -9,6 +9,7 @@ export * from './useIntersection';
 export * from './useListNavigation';
 export * from './useOutsideClick';
 export * from './usePortalContainer';
+export * from './useResizeObserver';
 export * from './useSelect';
 export * from './useTimeout';
 export * from './useViewportSize';
diff --git a/src/hooks/useResizeObserver/README.md b/src/hooks/useResizeObserver/README.md
new file mode 100644
index 0000000000..39ed376917
--- /dev/null
+++ b/src/hooks/useResizeObserver/README.md
@@ -0,0 +1,18 @@
+<!--GITHUB_BLOCK-->
+
+# useResizeObserver
+
+<!--/GITHUB_BLOCK-->
+
+```tsx
+import {useResizeObserver} from '@gravity-ui/uikit';
+```
+
+Custom hook that observes the change of the size of an element using the [ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
+
+## Properties
+
+| Name    | Description                                               |       Type        | Default |
+| :------ | :-------------------------------------------------------- | :---------------: | :-----: |
+| ref     | Ref-link to target of observation                         | `React.RefObject` |         |
+| handler | Callback when a size of the observation target is changed |   `() => void`    |         |
diff --git a/src/hooks/useResizeObserver/index.ts b/src/hooks/useResizeObserver/index.ts
new file mode 100644
index 0000000000..6404a2459d
--- /dev/null
+++ b/src/hooks/useResizeObserver/index.ts
@@ -0,0 +1 @@
+export {useResizeObserver} from './useResizeObserver';
diff --git a/src/hooks/useResizeObserver/useResizeObserver.ts b/src/hooks/useResizeObserver/useResizeObserver.ts
new file mode 100644
index 0000000000..c95193d741
--- /dev/null
+++ b/src/hooks/useResizeObserver/useResizeObserver.ts
@@ -0,0 +1,34 @@
+import React from 'react';
+
+interface UseResizeObserverProps<T> {
+    ref: React.RefObject<T | null | undefined> | undefined;
+    onResize: () => void;
+}
+
+export function useResizeObserver<T extends Element>({ref, onResize}: UseResizeObserverProps<T>) {
+    React.useEffect(() => {
+        const element = ref?.current;
+        if (!element) {
+            return undefined;
+        }
+
+        if (typeof window.ResizeObserver === 'undefined') {
+            window.addEventListener('resize', onResize, false);
+            return () => {
+                window.removeEventListener('resize', onResize, false);
+            };
+        }
+
+        const observer = new ResizeObserver((entries) => {
+            if (!entries.length) {
+                return;
+            }
+            onResize();
+        });
+
+        observer.observe(element);
+        return () => {
+            observer.disconnect();
+        };
+    }, [ref, onResize]);
+}
diff --git a/src/unstable.ts b/src/unstable.ts
index 27133ee61c..1d5149a1de 100644
--- a/src/unstable.ts
+++ b/src/unstable.ts
@@ -24,3 +24,12 @@ export {
     TreeList as unstable_TreeList,
     type TreeListProps as unstable_TreeListProps,
 } from './components/TreeList';
+
+export {
+    Breadcrumbs as unstable_Breadcrumbs,
+    BreadcrumbsItem as unstable_BreadcrumbsItem,
+} from './components/lab/Breadcrumbs';
+export type {
+    BreadcrumbsProps as unstable_BreadcrumbsProps,
+    BreadcrumbsItemProps as unstable_BreadcrumbsItemProps,
+} from './components/lab/Breadcrumbs';