Repository: elastic/kibana
Pull request #213739: [Discover Tabs] Responsive tab size and scroll actions
Author: login: jughosta
Labels: name: release_note:skip
description: Skip the PR/issue when compiling release notes
name: backport:skip
description: This PR does not require backporting
name: Team:DataDiscovery
description: Discover, search (data plugin and KQL), data views, saved searches. For ES|QL, use Team:ES|QL. t//
name: Project:OneDiscover
description: Enrich Discover with contextual awareness
name: v9.1.0
Created: 2025-03-10T13:42:39Z | Merged: 2025-03-13T12:42:14Z

- Closes https://github.com/elastic/kibana/issues/210514

## Summary

This PR:
- updates style of tab name overflow
- automatically recalculates what the max tab size can be used
- if tabs don't fit the available width, arrow buttons will appear to help with scrolling left and right
- adds max tab limit and hides "+" button if it's reached
- introduces `unifiedTabs` page object for creating functional tests

![Mar-07-2025 17-47-12](https://github.com/user-attachments/assets/f1547086-1c8e-4e47-9d2e-35954403ec24)

## Testing

Two options are possible:

1. start Storybook with `yarn storybook unified_tabs`  and navigate to `http://localhost:9001`.
2. start Kibana with `yarn start --run-examples`. Then navigate to the Unified Tabs example plugin `http://localhost:5601/app/unifiedTabsExamples`.

### Checklist

- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios



Diff:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f770742111d81..7b64b21c84c5b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1128,6 +1128,7 @@ x-pack/test_serverless/api_integration/test_suites/common/platform_security @ela
 /test/examples/partial_results @elastic/kibana-data-discovery
 /test/examples/search @elastic/kibana-data-discovery
 /test/examples/unified_field_list_examples @elastic/kibana-data-discovery
+/test/examples/unified_tabs_examples @elastic/kibana-data-discovery
 /test/functional/apps/context @elastic/kibana-data-discovery
 /test/functional/apps/discover @elastic/kibana-data-discovery
 /test/functional/apps/management/ccs_compatibility/_data_views_ccs.ts @elastic/kibana-data-discovery
@@ -1700,6 +1701,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
 /test/functional/services/data_grid.ts @elastic/appex-qa
 /test/functional/services/combo_box.ts @elastic/appex-qa
 /test/functional/page_objects/unified_field_list.ts @elastic/appex-qa
+/test/functional/page_objects/unified_tabs.ts @elastic/appex-qa
 /test/functional/page_objects/time_picker.ts @elastic/appex-qa
 /test/functional/page_objects/index.ts @elastic/appex-qa
 /test/functional/page_objects/header_page.ts @elastic/appex-qa
diff --git a/examples/unified_tabs_examples/public/example_app.tsx b/examples/unified_tabs_examples/public/example_app.tsx
index 1bd3ceb950376..67ea36438869a 100644
--- a/examples/unified_tabs_examples/public/example_app.tsx
+++ b/examples/unified_tabs_examples/public/example_app.tsx
@@ -24,12 +24,10 @@ import type { AppMountParameters } from '@kbn/core-application-browser';
 import type { DataView } from '@kbn/data-views-plugin/public';
 import type { DataViewField } from '@kbn/data-views-plugin/public';
 import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
-import { UnifiedTabs } from '@kbn/unified-tabs';
+import { type TabItem, UnifiedTabs, useNewTabProps } from '@kbn/unified-tabs';
 import { PLUGIN_ID, PLUGIN_NAME } from '../common';
 import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
 
-let TMP_COUNTER = 0;
-
 interface UnifiedTabsExampleAppProps {
   services: FieldListSidebarProps['services'];
   setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
@@ -44,6 +42,10 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
   const { IndexPatternSelect } = unifiedSearch.ui;
   const [dataView, setDataView] = useState<DataView | null>();
   const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
+  const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
+  const [initialItems] = useState<TabItem[]>(() =>
+    Array.from({ length: 7 }, () => getNewTabDefaultProps())
+  );
 
   const onAddFieldToWorkspace = useCallback(
     (field: DataViewField) => {
@@ -95,20 +97,10 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
         {dataView ? (
           <div className="eui-fullHeight">
             <UnifiedTabs
-              initialItems={[
-                {
-                  id: 'tab_initial',
-                  label: 'Initial tab',
-                },
-              ]}
+              initialItems={initialItems}
+              maxItemsCount={20}
               onChanged={() => {}}
-              createItem={() => {
-                TMP_COUNTER += 1;
-                return {
-                  id: `tab_${TMP_COUNTER}`,
-                  label: `Tab ${TMP_COUNTER}`,
-                };
-              }}
+              createItem={getNewTabDefaultProps}
               renderContent={({ label }) => {
                 return (
                   <EuiFlexGroup direction="column" gutterSize="none">
diff --git a/src/platform/packages/shared/kbn-unified-tabs/index.ts b/src/platform/packages/shared/kbn-unified-tabs/index.ts
index d4a44151200f9..fba192bcda73b 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/index.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/index.ts
@@ -12,3 +12,4 @@ export {
   TabbedContent as UnifiedTabs,
   type TabbedContentProps as UnifiedTabsProps,
 } from './src/components/tabbed_content';
+export { useNewTabProps } from './src/hooks/use_new_tab_props';
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tab.stories.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tab.stories.tsx
index 4701aa203f7f3..0cd246354428b 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tab.stories.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tab.stories.tsx
@@ -12,6 +12,7 @@ import type { ComponentStory } from '@storybook/react';
 import { action } from '@storybook/addon-actions';
 import { Tab, type TabProps } from '../tab';
 import { STORYBOOK_TITLE } from './storybook_constants';
+import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
 
 const asyncAction =
   (name: string) =>
@@ -29,9 +30,16 @@ export default {
   },
 };
 
+const tabsSizeConfig = {
+  isScrollable: false,
+  regularTabMaxWidth: MAX_TAB_WIDTH,
+  regularTabMinWidth: MIN_TAB_WIDTH,
+};
+
 const TabTemplate: ComponentStory<React.FC<TabProps>> = (args) => (
   <Tab
     {...args}
+    tabsSizeConfig={tabsSizeConfig}
     onLabelEdited={asyncAction('onLabelEdited')}
     onSelect={asyncAction('onSelect')}
     onClose={asyncAction('onClose')}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx
index cfe170a5a94a2..fea93e9b0093b 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx
@@ -11,10 +11,9 @@ import React from 'react';
 import type { ComponentStory } from '@storybook/react';
 import { action } from '@storybook/addon-actions';
 import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
+import { useNewTabProps } from '../../hooks/use_new_tab_props';
 import { STORYBOOK_TITLE } from './storybook_constants';
 
-let TMP_COUNTER = 0;
-
 export default {
   title: `${STORYBOOK_TITLE}/Tabs`,
   parameters: {
@@ -25,22 +24,22 @@ export default {
   },
 };
 
-const TabbedContentTemplate: ComponentStory<React.FC<TabbedContentProps>> = (args) => (
-  <TabbedContent
-    {...args}
-    createItem={() => {
-      TMP_COUNTER += 1;
-      return {
-        id: `tab_${TMP_COUNTER}`,
-        label: `Tab ${TMP_COUNTER}`,
-      };
-    }}
-    onChanged={action('onClosed')}
-    renderContent={(item) => (
-      <div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
-    )}
-  />
-);
+const TabbedContentTemplate: ComponentStory<React.FC<TabbedContentProps>> = (args) => {
+  const { getNewTabDefaultProps } = useNewTabProps({
+    numberOfInitialItems: args.initialItems.length,
+  });
+
+  return (
+    <TabbedContent
+      {...args}
+      createItem={getNewTabDefaultProps}
+      onChanged={action('onClosed')}
+      renderContent={(item) => (
+        <div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
+      )}
+    />
+  );
+};
 
 export const Default = TabbedContentTemplate.bind({});
 Default.args = {
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/edit_tab_label.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/edit_tab_label.tsx
index d8c919deeea03..31c7de2bdfd68 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/edit_tab_label.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/edit_tab_label.tsx
@@ -11,6 +11,7 @@ import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
 import { EuiFieldText, keys, useEuiTheme } from '@elastic/eui';
 import { css } from '@emotion/react';
 import type { TabItem } from '../../types';
+import { MAX_TAB_LABEL_LENGTH } from '../../constants';
 
 enum SubmitState {
   initial = 'initial',
@@ -89,6 +90,7 @@ export const EditTabLabel: React.FC<EditTabLabelProps> = ({ item, onLabelEdited,
       `}
       compressed
       value={value}
+      maxLength={MAX_TAB_LABEL_LENGTH}
       isLoading={submitState === SubmitState.submitting}
       isInvalid={submitState === SubmitState.error || !value.trim()}
       onChange={onChange}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
index 02c5b39492316..e1a0c5a56e797 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx
@@ -11,6 +11,7 @@ import React from 'react';
 import { render, screen, fireEvent } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { Tab } from './tab';
+import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
 
 const tabItem = {
   id: 'test-id',
@@ -20,6 +21,12 @@ const tabItem = {
 const tabContentId = 'test-content-id';
 const tabButtonTestSubj = `unifiedTabs_selectTabBtn_${tabItem.id}`;
 
+const tabsSizeConfig = {
+  isScrollable: false,
+  regularTabMaxWidth: MAX_TAB_WIDTH,
+  regularTabMinWidth: MIN_TAB_WIDTH,
+};
+
 describe('Tab', () => {
   it('renders tab', async () => {
     const onLabelEdited = jest.fn();
@@ -29,6 +36,7 @@ describe('Tab', () => {
     render(
       <Tab
         tabContentId={tabContentId}
+        tabsSizeConfig={tabsSizeConfig}
         item={tabItem}
         isSelected={false}
         onLabelEdited={onLabelEdited}
@@ -70,6 +78,7 @@ describe('Tab', () => {
     render(
       <Tab
         tabContentId={tabContentId}
+        tabsSizeConfig={tabsSizeConfig}
         item={tabItem}
         isSelected={false}
         getTabMenuItems={getTabMenuItems}
@@ -98,6 +107,7 @@ describe('Tab', () => {
     render(
       <Tab
         tabContentId={tabContentId}
+        tabsSizeConfig={tabsSizeConfig}
         item={tabItem}
         isSelected={false}
         onLabelEdited={onLabelEdited}
@@ -130,6 +140,7 @@ describe('Tab', () => {
     render(
       <Tab
         tabContentId={tabContentId}
+        tabsSizeConfig={tabsSizeConfig}
         item={tabItem}
         isSelected={false}
         onLabelEdited={onLabelEdited}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
index 1d8bb233bc373..d286ce2990891 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx
@@ -21,12 +21,13 @@ import {
 import { TabMenu } from '../tab_menu';
 import { EditTabLabel, type EditTabLabelProps } from './edit_tab_label';
 import { getTabAttributes } from '../../utils/get_tab_attributes';
-import type { TabItem, GetTabMenuItems } from '../../types';
+import type { TabItem, TabsSizeConfig, GetTabMenuItems } from '../../types';
 
 export interface TabProps {
   item: TabItem;
   isSelected: boolean;
   tabContentId: string;
+  tabsSizeConfig: TabsSizeConfig;
   getTabMenuItems?: GetTabMenuItems;
   onLabelEdited: EditTabLabelProps['onLabelEdited'];
   onSelect: (item: TabItem) => Promise<void>;
@@ -37,6 +38,7 @@ export const Tab: React.FC<TabProps> = ({
   item,
   isSelected,
   tabContentId,
+  tabsSizeConfig,
   getTabMenuItems,
   onLabelEdited,
   onSelect,
@@ -56,29 +58,29 @@ export const Tab: React.FC<TabProps> = ({
   });
 
   const onSelectEvent = useCallback(
-    (event: MouseEvent<HTMLElement>) => {
+    async (event: MouseEvent<HTMLElement>) => {
       event.stopPropagation();
 
       if (!isSelected) {
-        onSelect(item);
+        await onSelect(item);
       }
     },
     [onSelect, item, isSelected]
   );
 
   const onCloseEvent = useCallback(
-    (event: MouseEvent<HTMLButtonElement>) => {
+    async (event: MouseEvent<HTMLButtonElement>) => {
       event.stopPropagation();
-      onClose?.(item);
+      await onClose?.(item);
     },
     [onClose, item]
   );
 
   const onClickEvent = useCallback(
-    (event: MouseEvent<HTMLDivElement>) => {
+    async (event: MouseEvent<HTMLDivElement>) => {
       if (event.currentTarget === containerRef.current) {
         // if user presses on the space around the buttons, we should still trigger the onSelectEvent
-        onSelectEvent(event);
+        await onSelectEvent(event);
       }
     },
     [onSelectEvent]
@@ -91,63 +93,69 @@ export const Tab: React.FC<TabProps> = ({
       role="tab"
       aria-selected={isSelected}
       alignItems="center"
-      css={getTabContainerCss(euiTheme, isSelected)}
+      direction="row"
+      css={getTabContainerCss(euiTheme, tabsSizeConfig, isSelected)}
       data-test-subj={tabContainerDataTestSubj}
       responsive={false}
       gutterSize="none"
       onClick={onClickEvent}
     >
-      {isInlineEditActive ? (
-        <div css={getTabButtonCss(euiTheme)}>
+      <div css={getTabContentCss()}>
+        {isInlineEditActive ? (
           <EditTabLabel
             item={item}
             onLabelEdited={onLabelEdited}
             onExit={() => setIsInlineEditActive(false)}
           />
-        </div>
-      ) : (
-        <>
-          <button
-            aria-label={tabButtonAriaLabel}
-            css={getTabButtonCss(euiTheme)}
-            className="unifiedTabs__tabBtn"
-            data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
-            type="button"
-            onClick={onSelectEvent}
-            onDoubleClick={() => setIsInlineEditActive(true)}
-          >
-            <EuiText color="inherit" size="s" className="eui-textTruncate">
-              {item.label}
-            </EuiText>
-          </button>
-          <EuiFlexItem grow={false} className="unifiedTabs__tabActions">
-            <EuiFlexGroup responsive={false} direction="row" gutterSize="none">
-              {!!getTabMenuItems && (
-                <EuiFlexItem grow={false} className="unifiedTabs__tabMenuBtn">
-                  <TabMenu item={item} getTabMenuItems={getTabMenuItems} />
-                </EuiFlexItem>
-              )}
-              {!!onClose && (
-                <EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
-                  <EuiButtonIcon
-                    aria-label={closeButtonLabel}
-                    title={closeButtonLabel}
-                    color="text"
-                    data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
-                    iconType="cross"
-                    onClick={onCloseEvent}
-                  />
-                </EuiFlexItem>
-              )}
-            </EuiFlexGroup>
-          </EuiFlexItem>
-        </>
-      )}
+        ) : (
+          <>
+            <button
+              aria-label={tabButtonAriaLabel}
+              css={getTabButtonCss(euiTheme)}
+              className="unifiedTabs__tabBtn"
+              data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
+              title={item.label}
+              type="button"
+              onClick={onSelectEvent}
+              onDoubleClick={() => setIsInlineEditActive(true)}
+            >
+              <EuiText color="inherit" size="s" css={getTabLabelCss(euiTheme)}>
+                {item.label}
+              </EuiText>
+            </button>
+            <div className="unifiedTabs__tabActions">
+              <EuiFlexGroup responsive={false} direction="row" gutterSize="none">
+                {!!getTabMenuItems && (
+                  <EuiFlexItem grow={false} className="unifiedTabs__tabMenuBtn">
+                    <TabMenu item={item} getTabMenuItems={getTabMenuItems} />
+                  </EuiFlexItem>
+                )}
+                {!!onClose && (
+                  <EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
+                    <EuiButtonIcon
+                      aria-label={closeButtonLabel}
+                      title={closeButtonLabel}
+                      color="text"
+                      data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
+                      iconType="cross"
+                      onClick={onCloseEvent}
+                    />
+                  </EuiFlexItem>
+                )}
+              </EuiFlexGroup>
+            </div>
+          </>
+        )}
+      </div>
     </EuiFlexGroup>
   );
 };
 
-function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
+function getTabContainerCss(
+  euiTheme: EuiThemeComputed,
+  tabsSizeConfig: TabsSizeConfig,
+  isSelected: boolean
+) {
   // TODO: remove the usage of deprecated colors
 
   return css`
@@ -156,14 +164,17 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
     border-color: ${euiTheme.colors.lightShade};
     height: ${euiTheme.size.xl};
     padding-inline: ${euiTheme.size.xs};
-    min-width: 96px;
-    max-width: 280px;
+    min-width: ${tabsSizeConfig.regularTabMinWidth}px;
+    max-width: ${tabsSizeConfig.regularTabMaxWidth}px;
 
     background-color: ${isSelected ? euiTheme.colors.emptyShade : euiTheme.colors.lightestShade};
     color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText};
     transition: background-color ${euiTheme.animation.fast};
 
     .unifiedTabs__tabActions {
+      position: absolute;
+      top: 0;
+      right: 0;
       opacity: 0;
       transition: opacity ${euiTheme.animation.fast};
     }
@@ -173,6 +184,10 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
       .unifiedTabs__tabActions {
         opacity: 1;
       }
+
+      .unifiedTabs__tabBtn {
+        width: calc(100% - ${euiTheme.size.l} * 2);
+      }
     }
 
     ${isSelected
@@ -190,12 +205,17 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
   `;
 }
 
+function getTabContentCss() {
+  return css`
+    position: relative;
+    width: 100%;
+  `;
+}
+
 function getTabButtonCss(euiTheme: EuiThemeComputed) {
   return css`
     width: 100%;
-    min-height: 100%;
-    min-width: 0;
-    flex-grow: 1;
+    height: ${euiTheme.size.l};
     padding-inline: ${euiTheme.size.xs};
     text-align: left;
     color: inherit;
@@ -204,3 +224,17 @@ function getTabButtonCss(euiTheme: EuiThemeComputed) {
     background: transparent;
   `;
 }
+
+function getTabLabelCss(euiTheme: EuiThemeComputed) {
+  return css`
+    padding-right: ${euiTheme.size.s};
+    white-space: nowrap;
+    mask-image: linear-gradient(
+      to right,
+      rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+      rgba(255, 0, 0, 0.1) 100%
+    );
+    transform: translateZ(0);
+    overflow: hidden;
+  `;
+}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
index 8f4cdc393774b..c20bb59744fe5 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx
@@ -9,7 +9,7 @@
 
 import React, { useCallback, useMemo, useState } from 'react';
 import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
-import { TabsBar } from '../tabs_bar';
+import { TabsBar, type TabsBarProps } from '../tabs_bar';
 import { getTabAttributes } from '../../utils/get_tab_attributes';
 import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items';
 import {
@@ -23,7 +23,7 @@ import {
 } from '../../utils/manage_tabs';
 import { TabItem } from '../../types';
 
-export interface TabbedContentProps {
+export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
   initialItems: TabItem[];
   initialSelectedItemId?: string;
   'data-test-subj'?: string;
@@ -40,6 +40,7 @@ export interface TabbedContentState {
 export const TabbedContent: React.FC<TabbedContentProps> = ({
   initialItems,
   initialSelectedItemId,
+  maxItemsCount,
   renderContent,
   createItem,
   onChanged,
@@ -94,8 +95,8 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
 
   const onAdd = useCallback(async () => {
     const newItem = createItem();
-    changeState((prevState) => addTab(prevState, newItem));
-  }, [changeState, createItem]);
+    changeState((prevState) => addTab(prevState, newItem, maxItemsCount));
+  }, [changeState, createItem, maxItemsCount]);
 
   const getTabMenuItems = useMemo(() => {
     return getTabMenuItemsFn({
@@ -122,6 +123,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
         <TabsBar
           items={items}
           selectedItem={selectedItem}
+          maxItemsCount={maxItemsCount}
           tabContentId={tabContentId}
           getTabMenuItems={getTabMenuItems}
           onAdd={onAdd}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
index 5c682431ae133..479181f529b4c 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx
@@ -7,12 +7,18 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-import React from 'react';
+import React, { useEffect, useRef, useState } from 'react';
 import { i18n } from '@kbn/i18n';
 import { css } from '@emotion/react';
 import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
 import { Tab, type TabProps } from '../tab';
 import type { TabItem } from '../../types';
+import { getTabIdAttribute } from '../../utils/get_tab_attributes';
+import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
+
+const growingFlexItemCss = css`
+  min-width: 0;
+`;
 
 export type TabsBarProps = Pick<
   TabProps,
@@ -20,12 +26,14 @@ export type TabsBarProps = Pick<
 > & {
   items: TabItem[];
   selectedItem: TabItem | null;
+  maxItemsCount?: number;
   onAdd: () => Promise<void>;
 };
 
 export const TabsBar: React.FC<TabsBarProps> = ({
   items,
   selectedItem,
+  maxItemsCount,
   tabContentId,
   getTabMenuItems,
   onAdd,
@@ -34,48 +42,98 @@ export const TabsBar: React.FC<TabsBarProps> = ({
   onClose,
 }) => {
   const { euiTheme } = useEuiTheme();
+  const [tabsContainerWithPlusElement, setTabsContainerWithPlusElement] =
+    useState<HTMLDivElement | null>(null);
+  const [tabsContainerElement, setTabsContainerElement] = useState<HTMLDivElement | null>(null);
+  const tabsContainerRef = useRef<HTMLDivElement | null>(null);
+  tabsContainerRef.current = tabsContainerElement;
+  const hasReachedMaxItemsCount = maxItemsCount ? items.length >= maxItemsCount : false;
 
   const addButtonLabel = i18n.translate('unifiedTabs.createTabButton', {
     defaultMessage: 'New session',
   });
 
+  const { tabsSizeConfig, scrollRightButton, scrollLeftButton, tabsContainerCss } =
+    useResponsiveTabs({
+      items,
+      hasReachedMaxItemsCount,
+      tabsContainerWithPlusElement,
+      tabsContainerElement,
+    });
+
+  useEffect(() => {
+    if (selectedItem && tabsContainerRef.current) {
+      const selectedTab = tabsContainerRef.current.querySelector(
+        `#${getTabIdAttribute(selectedItem)}`
+      );
+      if (selectedTab) {
+        selectedTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+      }
+    }
+  }, [selectedItem]);
+
   return (
     <EuiFlexGroup
       role="tablist"
       data-test-subj="unifiedTabs_tabsBar"
       responsive={false}
       alignItems="center"
-      gutterSize="none"
-      className="eui-scrollBar"
+      gutterSize="s"
       css={css`
         background-color: ${euiTheme.colors.lightestShade};
-        overflow-x: auto;
+        padding-right: ${euiTheme.size.xs};
       `}
     >
-      {items.map((item) => (
-        <EuiFlexItem key={item.id} grow={false}>
-          <Tab
-            item={item}
-            isSelected={selectedItem?.id === item.id}
-            tabContentId={tabContentId}
-            getTabMenuItems={getTabMenuItems}
-            onLabelEdited={onLabelEdited}
-            onSelect={onSelect}
-            onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
-          />
-        </EuiFlexItem>
-      ))}
+      <EuiFlexItem ref={setTabsContainerWithPlusElement} grow css={growingFlexItemCss}>
+        <EuiFlexGroup direction="row" gutterSize="s" alignItems="center" responsive={false}>
+          <EuiFlexItem grow={false} css={growingFlexItemCss}>
+            <EuiFlexGroup
+              ref={setTabsContainerElement}
+              direction="row"
+              gutterSize="none"
+              alignItems="center"
+              responsive={false}
+              css={tabsContainerCss}
+            >
+              {items.map((item) => (
+                <EuiFlexItem key={item.id} grow={false}>
+                  <Tab
+                    item={item}
+                    isSelected={selectedItem?.id === item.id}
+                    tabContentId={tabContentId}
+                    tabsSizeConfig={tabsSizeConfig}
+                    getTabMenuItems={getTabMenuItems}
+                    onLabelEdited={onLabelEdited}
+                    onSelect={onSelect}
+                    onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
+                  />
+                </EuiFlexItem>
+              ))}
+            </EuiFlexGroup>
+          </EuiFlexItem>
+          {!!scrollLeftButton && <EuiFlexItem grow={false}>{scrollLeftButton}</EuiFlexItem>}
+          {!!scrollRightButton && <EuiFlexItem grow={false}>{scrollRightButton}</EuiFlexItem>}
+          {!hasReachedMaxItemsCount && (
+            <EuiFlexItem grow={false}>
+              <EuiButtonIcon
+                data-test-subj="unifiedTabs_tabsBar_newTabBtn"
+                iconType="plus"
+                color="text"
+                aria-label={addButtonLabel}
+                title={addButtonLabel}
+                onClick={onAdd}
+              />
+            </EuiFlexItem>
+          )}
+        </EuiFlexGroup>
+      </EuiFlexItem>
       <EuiFlexItem grow={false}>
         <EuiButtonIcon
-          data-test-subj="unifiedTabs_tabsBar_newTabBtn"
-          iconType="plus"
+          iconType="boxesVertical"
           color="text"
-          css={css`
-            margin-inline: ${euiTheme.size.s};
-          `}
-          aria-label={addButtonLabel}
-          title={addButtonLabel}
-          onClick={onAdd}
+          aria-label="Tabs menu placeholder"
+          title="Tabs menu placeholder"
+          onClick={() => alert('TODO: Implement tabs menu')}
         />
       </EuiFlexItem>
     </EuiFlexGroup>
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/constants.ts b/src/platform/packages/shared/kbn-unified-tabs/src/constants.ts
new file mode 100644
index 0000000000000..d7b60d29e4402
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/constants.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+export const MAX_TAB_LABEL_LENGTH = 500;
+
+export const MAX_TAB_WIDTH = 280;
+export const MIN_TAB_WIDTH = 96;
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.test.ts b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.test.ts
new file mode 100644
index 0000000000000..5ae5b81d251a4
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.test.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { renderHook } from '@testing-library/react';
+import { useNewTabProps } from './use_new_tab_props';
+
+describe('useNewTabProps', () => {
+  it('returns a function that returns a new tab props', () => {
+    const { result } = renderHook(() => useNewTabProps({ numberOfInitialItems: 0 }));
+    const getNewTabDefaultProps = result.current.getNewTabDefaultProps;
+
+    const tab1 = getNewTabDefaultProps();
+    const tab2 = getNewTabDefaultProps();
+
+    expect(tab1).toEqual({
+      id: expect.any(String),
+      label: 'Untitled session 1',
+    });
+
+    expect(tab2).toEqual({
+      id: expect.any(String),
+      label: 'Untitled session 2',
+    });
+
+    expect(tab1.id).not.toEqual(tab2.id);
+  });
+
+  it('starts from the specified index', () => {
+    const { result } = renderHook(() => useNewTabProps({ numberOfInitialItems: 5 }));
+    const getNewTabDefaultProps = result.current.getNewTabDefaultProps;
+
+    expect(getNewTabDefaultProps()).toEqual({
+      id: expect.any(String),
+      label: 'Untitled session 6',
+    });
+
+    expect(getNewTabDefaultProps()).toEqual({
+      id: expect.any(String),
+      label: 'Untitled session 7',
+    });
+  });
+});
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.ts b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.ts
new file mode 100644
index 0000000000000..426292443561e
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_new_tab_props.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { useCallback, useRef } from 'react';
+import { i18n } from '@kbn/i18n';
+import { v4 as uuidv4 } from 'uuid';
+import type { TabItem } from '../types';
+
+export const useNewTabProps = ({ numberOfInitialItems }: { numberOfInitialItems: number }) => {
+  const counterRef = useRef<number>(numberOfInitialItems);
+
+  const getNewTabDefaultProps = useCallback((): Pick<TabItem, 'id' | 'label'> => {
+    counterRef.current += 1;
+    return getNewTabPropsForIndex(counterRef.current);
+  }, []);
+
+  return {
+    getNewTabDefaultProps,
+  };
+};
+
+export function getNewTabPropsForIndex(index: number): Pick<TabItem, 'id' | 'label'> {
+  return {
+    id: uuidv4(),
+    label: i18n.translate('unifiedTabs.defaultNewTabLabel', {
+      defaultMessage: 'Untitled session {counter}',
+      values: { counter: index },
+    }),
+  };
+}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx
new file mode 100644
index 0000000000000..d6e9b996453f6
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx
@@ -0,0 +1,186 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { EuiButtonIcon, useEuiTheme, useResizeObserver } from '@elastic/eui';
+import { throttle } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { css } from '@emotion/react';
+import useEvent from 'react-use/lib/useEvent';
+import type { TabItem } from '../types';
+import { calculateResponsiveTabs } from '../utils/calculate_responsive_tabs';
+
+const SCROLL_STEP = 200;
+
+interface ScrollState {
+  isScrollableLeft: boolean;
+  isScrollableRight: boolean;
+}
+
+export interface UseResponsiveTabsProps {
+  items: TabItem[];
+  hasReachedMaxItemsCount: boolean;
+  tabsContainerWithPlusElement: Element | null;
+  tabsContainerElement: Element | null;
+}
+
+export const useResponsiveTabs = ({
+  items,
+  hasReachedMaxItemsCount,
+  tabsContainerWithPlusElement,
+  tabsContainerElement,
+}: UseResponsiveTabsProps) => {
+  const { euiTheme } = useEuiTheme();
+  const dimensions = useResizeObserver(tabsContainerWithPlusElement);
+  const tabsSizeConfig = useMemo(
+    () =>
+      calculateResponsiveTabs({ items, containerWidth: dimensions.width, hasReachedMaxItemsCount }),
+    [items, dimensions.width, hasReachedMaxItemsCount]
+  );
+
+  const [scrollState, setScrollState] = useState<ScrollState>();
+
+  const scrollLeftButtonLabel = i18n.translate('unifiedTabs.scrollLeftButton', {
+    defaultMessage: 'Scroll left',
+  });
+
+  const scrollRightButtonLabel = i18n.translate('unifiedTabs.scrollRightButton', {
+    defaultMessage: 'Scroll right',
+  });
+
+  const onScroll = useCallback(() => {
+    setScrollState(calculateScrollState(tabsContainerElement));
+  }, [tabsContainerElement, setScrollState]);
+
+  const onScrollThrottled = useMemo(
+    () => throttle(onScroll, 200, { leading: false, trailing: true }),
+    [onScroll]
+  );
+
+  useEvent('scroll', onScrollThrottled, tabsContainerElement);
+
+  useEffect(() => {
+    onScrollThrottled();
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeft = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.max(tabsContainerElement.scrollLeft - SCROLL_STEP, 0);
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollRight = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.min(
+        tabsContainerElement.scrollLeft + SCROLL_STEP,
+        tabsContainerElement.scrollWidth
+      );
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeftButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollLeftBtn"
+          iconType="arrowLeft"
+          color="text"
+          disabled={scrollState?.isScrollableLeft === false}
+          aria-label={scrollLeftButtonLabel}
+          title={scrollLeftButtonLabel}
+          onClick={scrollLeft}
+        />
+      ) : null,
+    [scrollLeftButtonLabel, scrollLeft, tabsSizeConfig.isScrollable, scrollState?.isScrollableLeft]
+  );
+
+  const scrollRightButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollRightBtn"
+          iconType="arrowRight"
+          color="text"
+          disabled={scrollState?.isScrollableRight === false}
+          aria-label={scrollRightButtonLabel}
+          title={scrollRightButtonLabel}
+          onClick={scrollRight}
+        />
+      ) : null,
+    [
+      scrollRightButtonLabel,
+      scrollRight,
+      tabsSizeConfig.isScrollable,
+      scrollState?.isScrollableRight,
+    ]
+  );
+
+  const tabsContainerCss = useMemo(() => {
+    let overflowGradient = '';
+    if (scrollState?.isScrollableLeft && scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s},
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    } else if (scrollState?.isScrollableLeft) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s}
+        );
+      `;
+    } else if (scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    }
+
+    return css`
+      overflow-x: auto;
+      max-width: 100%;
+      user-select: none;
+      scrollbar-width: none; // hide the scrollbar
+      scroll-behavior: smooth;
+      &::-webkit-scrollbar {
+        display: none;
+      }
+      transform: translateZ(0);
+      ${overflowGradient}
+    `;
+  }, [scrollState, euiTheme.size.s]);
+
+  return {
+    tabsSizeConfig,
+    scrollLeftButton,
+    scrollRightButton,
+    tabsContainerCss,
+  };
+};
+
+function calculateScrollState(tabsContainerElement: Element | null): ScrollState | undefined {
+  if (tabsContainerElement) {
+    const { scrollLeft, scrollWidth, clientWidth } = tabsContainerElement;
+    const isScrollableLeft = scrollLeft > 0;
+    const isScrollableRight = scrollLeft + clientWidth < scrollWidth;
+
+    return { isScrollableLeft, isScrollableRight };
+  }
+}
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
index f1d0d35fca5fa..bbd4546c440cb 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts
@@ -18,6 +18,14 @@ export interface TabMenuItemWithClick {
   label: string;
   onClick: () => void;
 }
+
+export interface TabsSizeConfig {
+  isScrollable: boolean;
+  regularTabMaxWidth: number;
+  regularTabMinWidth: number;
+  // TODO: extend with possibly different sizes for pinned tabs
+}
+
 export type TabMenuItem = TabMenuItemWithClick | 'divider';
 
 export type GetTabMenuItems = (item: TabItem) => TabMenuItem[];
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts
new file mode 100644
index 0000000000000..9f0b047ab34c6
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { calculateResponsiveTabs } from './calculate_responsive_tabs';
+import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
+import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../constants';
+
+const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
+
+describe('calculateResponsiveTabs', () => {
+  it('renders a single tab without limitation', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items: [items[0]], containerWidth: 1500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: MAX_TAB_WIDTH,
+      regularTabMinWidth: MIN_TAB_WIDTH,
+    });
+  });
+
+  it('allows the larger the tab width', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 1500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: MAX_TAB_WIDTH,
+      regularTabMinWidth: MIN_TAB_WIDTH,
+    });
+  });
+
+  it('reduces the tab width if not enough space', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 1000 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: 193.6,
+      regularTabMinWidth: MIN_TAB_WIDTH,
+    });
+  });
+
+  it('reduces the tab width to the minimum if not enough space', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items, containerWidth: 500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: true,
+      regularTabMaxWidth: MIN_TAB_WIDTH,
+      regularTabMinWidth: MIN_TAB_WIDTH,
+    });
+  });
+
+  it('returns reasonable sizes even when the available space is unknown', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({
+      items: [items[0]],
+      containerWidth: undefined,
+    });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: MAX_TAB_WIDTH,
+      regularTabMinWidth: MIN_TAB_WIDTH,
+    });
+  });
+});
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts
new file mode 100644
index 0000000000000..747f41e340086
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { TabItem, TabsSizeConfig } from '../types';
+import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../constants';
+
+const PLUS_BUTTON_SPACE = 24 + 8; // button width + gap
+
+interface GetTabsSizeConfigProps {
+  items: TabItem[];
+  containerWidth: number | undefined;
+  hasReachedMaxItemsCount?: boolean;
+}
+
+export const calculateResponsiveTabs = ({
+  items,
+  containerWidth,
+  hasReachedMaxItemsCount,
+}: GetTabsSizeConfigProps): TabsSizeConfig => {
+  const availableContainerWidth =
+    (containerWidth || window.innerWidth) - (hasReachedMaxItemsCount ? 0 : PLUS_BUTTON_SPACE);
+
+  let calculatedTabWidth =
+    items.length > 0 ? availableContainerWidth / items.length : MAX_TAB_WIDTH;
+
+  if (calculatedTabWidth > MAX_TAB_WIDTH) {
+    calculatedTabWidth = MAX_TAB_WIDTH;
+  } else if (calculatedTabWidth < MIN_TAB_WIDTH) {
+    calculatedTabWidth = MIN_TAB_WIDTH;
+  }
+
+  const numberOfVisibleItems = Math.floor(availableContainerWidth / calculatedTabWidth);
+
+  return {
+    isScrollable: items.length > numberOfVisibleItems,
+    regularTabMaxWidth: calculatedTabWidth,
+    regularTabMinWidth: MIN_TAB_WIDTH,
+  };
+};
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_attributes.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_attributes.ts
index 3f6a158bf1474..15b0237155435 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_attributes.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_attributes.ts
@@ -9,9 +9,13 @@
 
 import { TabItem } from '../types';
 
+export const getTabIdAttribute = (item: TabItem) => {
+  return `tab-${item.id}`;
+};
+
 export const getTabAttributes = (item: TabItem, tabContentId: string) => {
   return {
-    id: `tab-${item.id}`,
+    id: getTabIdAttribute(item),
     'aria-controls': tabContentId,
   };
 };
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts
index cc2f57157d2e9..f95cdfb2b0bb7 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts
@@ -55,14 +55,25 @@ describe('manage_tabs', () => {
 
   describe('addTab', () => {
     it('adds a tab', () => {
+      const maxItemsCount = 100;
       const newItem = { id: 'tab-5', label: 'Tab 5' };
       const prevState = { items, selectedItem: items[0] };
-      const nextState = addTab(prevState, newItem);
+      const nextState = addTab(prevState, newItem, maxItemsCount);
 
       expect(nextState.items).not.toBe(items);
       expect(nextState.items).toEqual([...items, newItem]);
       expect(nextState.selectedItem).toBe(newItem);
     });
+
+    it('should not add a tab if limit is reached', () => {
+      const maxItemsCount = items.length;
+      const newItem = { id: 'tab-5', label: 'Tab 5' };
+      const prevState = { items, selectedItem: items[0] };
+      const nextState = addTab(prevState, newItem, maxItemsCount);
+
+      expect(nextState.items).toBe(items);
+      expect(nextState.selectedItem).toBe(items[0]);
+    });
   });
 
   describe('selectTab', () => {
diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts
index 8de744e928836..7f42a4fd6a788 100644
--- a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts
+++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts
@@ -22,7 +22,18 @@ export const isLastTab = ({ items }: TabsState, item: TabItem): boolean => {
   return items[items.length - 1].id === item.id;
 };
 
-export const addTab = ({ items }: TabsState, item: TabItem): TabsState => {
+export const addTab = (
+  { items, selectedItem }: TabsState,
+  item: TabItem,
+  maxItemsCount?: number
+): TabsState => {
+  if (maxItemsCount && items.length >= maxItemsCount) {
+    return {
+      items,
+      selectedItem,
+    };
+  }
+
   return {
     items: [...items, item],
     selectedItem: item,
diff --git a/test/examples/config.js b/test/examples/config.js
index 0142bcf0df169..eabf9e6890557 100644
--- a/test/examples/config.js
+++ b/test/examples/config.js
@@ -31,6 +31,7 @@ export default async function ({ readConfigFile }) {
       require.resolve('./search'),
       require.resolve('./content_management'),
       require.resolve('./unified_field_list_examples'),
+      require.resolve('./unified_tabs_examples'),
       require.resolve('./discover_customization_examples'),
       require.resolve('./error_boundary'),
       require.resolve('./response_stream'),
diff --git a/test/examples/unified_tabs_examples/index.ts b/test/examples/unified_tabs_examples/index.ts
new file mode 100644
index 0000000000000..5a21b2f4b6082
--- /dev/null
+++ b/test/examples/unified_tabs_examples/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { FtrProviderContext } from '../../functional/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ loadTestFile }: FtrProviderContext) {
+  describe('Unified Tabs Examples', () => {
+    loadTestFile(require.resolve('./manage_tabs'));
+  });
+}
diff --git a/test/examples/unified_tabs_examples/manage_tabs.ts b/test/examples/unified_tabs_examples/manage_tabs.ts
new file mode 100644
index 0000000000000..c04006b0dfc94
--- /dev/null
+++ b/test/examples/unified_tabs_examples/manage_tabs.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import expect from '@kbn/expect';
+import type { FtrProviderContext } from '../../functional/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default ({ getService, getPageObjects }: FtrProviderContext) => {
+  const { common, header, unifiedTabs } = getPageObjects(['common', 'header', 'unifiedTabs']);
+  const esArchiver = getService('esArchiver');
+  const kibanaServer = getService('kibanaServer');
+  const browser = getService('browser');
+
+  describe('Managing Unified Tabs', () => {
+    before(async () => {
+      await browser.setWindowSize(1200, 800);
+      await kibanaServer.savedObjects.cleanStandardList();
+      await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json');
+      await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
+      await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' });
+      await common.navigateToApp('unifiedTabsExamples');
+      await header.waitUntilLoadingHasFinished();
+    });
+
+    after(async () => {
+      await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
+      await kibanaServer.savedObjects.cleanStandardList();
+    });
+
+    it('should show tabs in a responsive way', async () => {
+      expect(await unifiedTabs.getNumberOfTabs()).to.be(7);
+      expect(await unifiedTabs.isScrollable()).to.be(false);
+      expect((await unifiedTabs.getTabWidths()).every((width) => width > 140)).to.be(true);
+      await unifiedTabs.createNewTab();
+      await unifiedTabs.createNewTab();
+      await unifiedTabs.createNewTab();
+      expect((await unifiedTabs.getTabWidths()).every((width) => width < 140 && width > 96)).to.be(
+        true
+      );
+      await unifiedTabs.createNewTab();
+      await unifiedTabs.createNewTab();
+      expect(await unifiedTabs.getNumberOfTabs()).to.be(12);
+      await unifiedTabs.waitForScrollButtons();
+      expect((await unifiedTabs.getTabWidths()).every((width) => width === 96)).to.be(true);
+    });
+  });
+};
diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts
index e5a0a619b4c34..76f50fb3794ba 100644
--- a/test/functional/page_objects/index.ts
+++ b/test/functional/page_objects/index.ts
@@ -35,6 +35,7 @@ import { DashboardPageControls } from './dashboard_page_controls';
 import { DashboardPageLinks } from './dashboard_page_links';
 import { UnifiedSearchPageObject } from './unified_search_page';
 import { UnifiedFieldListPageObject } from './unified_field_list';
+import { UnifiedTabsPageObject } from './unified_tabs';
 import { FilesManagementPageObject } from './files_management';
 import { AnnotationEditorPageObject } from './annotation_library_editor_page';
 import { SolutionNavigationProvider } from './solution_navigation';
@@ -73,6 +74,7 @@ export const pageObjects = {
   indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject,
   unifiedSearch: UnifiedSearchPageObject,
   unifiedFieldList: UnifiedFieldListPageObject,
+  unifiedTabs: UnifiedTabsPageObject,
   filesManagement: FilesManagementPageObject,
   spaceSettings: SpaceSettingsPageObject,
 };
diff --git a/test/functional/page_objects/unified_tabs.ts b/test/functional/page_objects/unified_tabs.ts
new file mode 100644
index 0000000000000..db1f1d50cc80c
--- /dev/null
+++ b/test/functional/page_objects/unified_tabs.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { FtrService } from '../ftr_provider_context';
+
+export class UnifiedTabsPageObject extends FtrService {
+  private readonly retry = this.ctx.getService('retry');
+  private readonly testSubjects = this.ctx.getService('testSubjects');
+  private readonly find = this.ctx.getService('find');
+
+  public async getTabElements() {
+    return await this.find.allByCssSelector('[data-test-subj^="unifiedTabs_tab_"]');
+  }
+
+  public async getTabWidths() {
+    const tabElements = await this.getTabElements();
+    return await Promise.all(
+      tabElements.map(async (tabElement) => {
+        return (await tabElement.getSize()).width;
+      })
+    );
+  }
+
+  public async getNumberOfTabs() {
+    const numberOfTabs = await this.getTabElements();
+    return numberOfTabs.length;
+  }
+
+  public async createNewTab() {
+    const numberOfTabs = await this.getNumberOfTabs();
+    await this.testSubjects.click('unifiedTabs_tabsBar_newTabBtn');
+    await this.retry.waitFor('the new tab to appear', async () => {
+      const newNumberOfTabs = await this.getNumberOfTabs();
+      return newNumberOfTabs === numberOfTabs + 1;
+    });
+  }
+
+  public async isScrollable() {
+    return (
+      (await this.testSubjects.exists('unifiedTabs_tabsBar_scrollLeftBtn')) &&
+      (await this.testSubjects.exists('unifiedTabs_tabsBar_scrollRightBtn'))
+    );
+  }
+
+  public async canScrollMoreLeft() {
+    const scrollLeftBtn = await this.testSubjects.find('unifiedTabs_tabsBar_scrollLeftBtn');
+    return !(await scrollLeftBtn.getAttribute('disabled'));
+  }
+
+  public async canScrollMoreRight() {
+    const scrollRightBtn = await this.testSubjects.find('unifiedTabs_tabsBar_scrollRightBtn');
+    return !(await scrollRightBtn.getAttribute('disabled'));
+  }
+
+  public async waitForScrollButtons() {
+    await this.retry.waitFor('scroll buttons to get ready', async () => {
+      return (
+        (await this.isScrollable()) &&
+        ((await this.canScrollMoreLeft()) || (await this.canScrollMoreRight()))
+      );
+    });
+  }
+}


Reviews:
user: login: pheyos
body: `test/examples/config.js` and `test/functional/page_objects/index.ts` changes LGTM
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T08:23:27Z
user: login: akowalska622
body: LGTM, one possible typo found and one small question (no hard opinions on that though), so approving to unblock
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:00:27Z
user: login: pheyos
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T09:35:00Z
user: login: jughosta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:50:07Z
user: login: jughosta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:50:09Z

Review comments:
diff_hunk: @@ -0,0 +1,186 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { EuiButtonIcon, useEuiTheme, useResizeObserver } from '@elastic/eui';
+import { throttle } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { css } from '@emotion/react';
+import useEvent from 'react-use/lib/useEvent';
+import type { TabItem } from '../types';
+import { calculateResponsiveTabs } from '../utils/calculate_responsive_tabs';
+
+const SCROLL_STEP = 200;
+
+interface ScrollState {
+  isScrollableLeft: boolean;
+  isScrollableRight: boolean;
+}
+
+export interface UseResponsiveTabsProps {
+  items: TabItem[];
+  hasReachedMaxItemsCount: boolean;
+  tabsContainerWithPlusElement: Element | null;
+  tabsContainerElement: Element | null;
+}
+
+export const useResponsiveTabs = ({
+  items,
+  hasReachedMaxItemsCount,
+  tabsContainerWithPlusElement,
+  tabsContainerElement,
+}: UseResponsiveTabsProps) => {
+  const { euiTheme } = useEuiTheme();
+  const dimensions = useResizeObserver(tabsContainerWithPlusElement);
+  const tabsSizeConfig = useMemo(
+    () =>
+      calculateResponsiveTabs({ items, containerWidth: dimensions.width, hasReachedMaxItemsCount }),
+    [items, dimensions.width, hasReachedMaxItemsCount]
+  );
+
+  const [scrollState, setScrollState] = useState<ScrollState>();
+
+  const scrollLeftButtonLabel = i18n.translate('unifiedTabs.scrollLeftButton', {
+    defaultMessage: 'Scroll left',
+  });
+
+  const scrollRightButtonLabel = i18n.translate('unifiedTabs.scrollRightButton', {
+    defaultMessage: 'Scroll right',
+  });
+
+  const onScroll = useCallback(() => {
+    setScrollState(calculateScrollState(tabsContainerElement));
+  }, [tabsContainerElement, setScrollState]);
+
+  const onScrollThrottled = useMemo(
+    () => throttle(onScroll, 200, { leading: false, trailing: true }),
+    [onScroll]
+  );
+
+  useEvent('scroll', onScrollThrottled, tabsContainerElement);
+
+  useEffect(() => {
+    onScrollThrottled();
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeft = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.max(tabsContainerElement.scrollLeft - SCROLL_STEP, 0);
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollRight = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.min(
+        tabsContainerElement.scrollLeft + SCROLL_STEP,
+        tabsContainerElement.scrollWidth
+      );
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeftButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollLeftBtn"
+          iconType="arrowLeft"
+          color="text"
+          disabled={scrollState?.isScrollableLeft === false}
+          aria-label={scrollLeftButtonLabel}
+          title={scrollLeftButtonLabel}
+          onClick={scrollLeft}
+        />
+      ) : null,
+    [scrollLeftButtonLabel, scrollLeft, tabsSizeConfig.isScrollable, scrollState?.isScrollableLeft]
+  );
+
+  const scrollRightButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollRightBtn"
+          iconType="arrowRight"
+          color="text"
+          disabled={scrollState?.isScrollableRight === false}
+          aria-label={scrollRightButtonLabel}
+          title={scrollRightButtonLabel}
+          onClick={scrollRight}
+        />
+      ) : null,
+    [
+      scrollRightButtonLabel,
+      scrollRight,
+      tabsSizeConfig.isScrollable,
+      scrollState?.isScrollableRight,
+    ]
+  );
+
+  const tabsContainerCss = useMemo(() => {
+    let overflowGradient = '';
+    if (scrollState?.isScrollableLeft && scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s},
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    } else if (scrollState?.isScrollableLeft) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s}
+        );
+      `;
+    } else if (scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    }
+
+    return css`
+      overflow-x: auto;
+      max-width: 100%;
+      user-select: none;
+      scrollbar-width: none; // hide the scrollbar
+      scroll-behavior: smooth;
+      &:::-webkit-scrollbar {
path: src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx
user: login: akowalska622
body: Looks like there's one too many `:`?
created_at: 2025-03-13T08:55:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { calculateResponsiveTabs } from './calculate_responsive_tabs';
+import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
+
+const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
+
+describe('calculateResponsiveTabs', () => {
+  it('renders a single tab without limitation', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items: [items[0]], containerWidth: 1500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: 280,
+      regularTabMinWidth: 96,
path: src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts
user: login: akowalska622
body: What do you think about extracting it to const? Most values repeat quite often within the test suite
created_at: 2025-03-13T08:57:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { calculateResponsiveTabs } from './calculate_responsive_tabs';
+import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
+
+const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
+
+describe('calculateResponsiveTabs', () => {
+  it('renders a single tab without limitation', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items: [items[0]], containerWidth: 1500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: 280,
+      regularTabMinWidth: 96,
path: src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts
user: login: akowalska622
body: Or importing it from `calculate_responsive_tabs.ts`
created_at: 2025-03-13T08:58:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { calculateResponsiveTabs } from './calculate_responsive_tabs';
+import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
+
+const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
+
+describe('calculateResponsiveTabs', () => {
+  it('renders a single tab without limitation', () => {
+    const tabsSizeConfig = calculateResponsiveTabs({ items: [items[0]], containerWidth: 1500 });
+
+    expect(tabsSizeConfig).toEqual({
+      isScrollable: false,
+      regularTabMaxWidth: 280,
+      regularTabMinWidth: 96,
path: src/platform/packages/shared/kbn-unified-tabs/src/utils/calculate_responsive_tabs.test.ts
user: login: jughosta
body: Addressed in https://github.com/elastic/kibana/pull/213739/commits/f25ee3cc3674f70058707c6939469e47802e0a5b
Thanks!
created_at: 2025-03-13T09:50:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,186 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { EuiButtonIcon, useEuiTheme, useResizeObserver } from '@elastic/eui';
+import { throttle } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { css } from '@emotion/react';
+import useEvent from 'react-use/lib/useEvent';
+import type { TabItem } from '../types';
+import { calculateResponsiveTabs } from '../utils/calculate_responsive_tabs';
+
+const SCROLL_STEP = 200;
+
+interface ScrollState {
+  isScrollableLeft: boolean;
+  isScrollableRight: boolean;
+}
+
+export interface UseResponsiveTabsProps {
+  items: TabItem[];
+  hasReachedMaxItemsCount: boolean;
+  tabsContainerWithPlusElement: Element | null;
+  tabsContainerElement: Element | null;
+}
+
+export const useResponsiveTabs = ({
+  items,
+  hasReachedMaxItemsCount,
+  tabsContainerWithPlusElement,
+  tabsContainerElement,
+}: UseResponsiveTabsProps) => {
+  const { euiTheme } = useEuiTheme();
+  const dimensions = useResizeObserver(tabsContainerWithPlusElement);
+  const tabsSizeConfig = useMemo(
+    () =>
+      calculateResponsiveTabs({ items, containerWidth: dimensions.width, hasReachedMaxItemsCount }),
+    [items, dimensions.width, hasReachedMaxItemsCount]
+  );
+
+  const [scrollState, setScrollState] = useState<ScrollState>();
+
+  const scrollLeftButtonLabel = i18n.translate('unifiedTabs.scrollLeftButton', {
+    defaultMessage: 'Scroll left',
+  });
+
+  const scrollRightButtonLabel = i18n.translate('unifiedTabs.scrollRightButton', {
+    defaultMessage: 'Scroll right',
+  });
+
+  const onScroll = useCallback(() => {
+    setScrollState(calculateScrollState(tabsContainerElement));
+  }, [tabsContainerElement, setScrollState]);
+
+  const onScrollThrottled = useMemo(
+    () => throttle(onScroll, 200, { leading: false, trailing: true }),
+    [onScroll]
+  );
+
+  useEvent('scroll', onScrollThrottled, tabsContainerElement);
+
+  useEffect(() => {
+    onScrollThrottled();
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeft = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.max(tabsContainerElement.scrollLeft - SCROLL_STEP, 0);
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollRight = useCallback(() => {
+    if (tabsContainerElement) {
+      tabsContainerElement.scrollLeft = Math.min(
+        tabsContainerElement.scrollLeft + SCROLL_STEP,
+        tabsContainerElement.scrollWidth
+      );
+      onScrollThrottled();
+    }
+  }, [tabsContainerElement, onScrollThrottled]);
+
+  const scrollLeftButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollLeftBtn"
+          iconType="arrowLeft"
+          color="text"
+          disabled={scrollState?.isScrollableLeft === false}
+          aria-label={scrollLeftButtonLabel}
+          title={scrollLeftButtonLabel}
+          onClick={scrollLeft}
+        />
+      ) : null,
+    [scrollLeftButtonLabel, scrollLeft, tabsSizeConfig.isScrollable, scrollState?.isScrollableLeft]
+  );
+
+  const scrollRightButton = useMemo(
+    () =>
+      tabsSizeConfig.isScrollable ? (
+        <EuiButtonIcon
+          data-test-subj="unifiedTabs_tabsBar_scrollRightBtn"
+          iconType="arrowRight"
+          color="text"
+          disabled={scrollState?.isScrollableRight === false}
+          aria-label={scrollRightButtonLabel}
+          title={scrollRightButtonLabel}
+          onClick={scrollRight}
+        />
+      ) : null,
+    [
+      scrollRightButtonLabel,
+      scrollRight,
+      tabsSizeConfig.isScrollable,
+      scrollState?.isScrollableRight,
+    ]
+  );
+
+  const tabsContainerCss = useMemo(() => {
+    let overflowGradient = '';
+    if (scrollState?.isScrollableLeft && scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s},
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    } else if (scrollState?.isScrollableLeft) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgba(255, 0, 0, 0.1) 0%,
+          rgb(255, 0, 0) ${euiTheme.size.s}
+        );
+      `;
+    } else if (scrollState?.isScrollableRight) {
+      overflowGradient = `
+        mask-image: linear-gradient(
+          to right,
+          rgb(255, 0, 0) calc(100% - ${euiTheme.size.s}),
+          rgba(255, 0, 0, 0.1) 100%
+        );
+      `;
+    }
+
+    return css`
+      overflow-x: auto;
+      max-width: 100%;
+      user-select: none;
+      scrollbar-width: none; // hide the scrollbar
+      scroll-behavior: smooth;
+      &:::-webkit-scrollbar {
path: src/platform/packages/shared/kbn-unified-tabs/src/hooks/use_responsive_tabs.tsx
user: login: jughosta
body: Updated via https://github.com/elastic/kibana/pull/213739/commits/f25ee3cc3674f70058707c6939469e47802e0a5b
Thanks!
created_at: 2025-03-13T09:50:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: elasticmachine
created_at: 2025-03-11T07:19:38Z
body: Pinging @elastic/kibana-data-discovery (Team:DataDiscovery)
author_association: CONTRIBUTOR
user: login: elasticmachine
created_at: 2025-03-13T12:42:34Z
body: ## :yellow_heart: Build succeeded, but was flaky
* [Buildkite Build](https://buildkite.com/elastic/kibana-pull-request/builds/283594)
* Commit: f25ee3cc3674f70058707c6939469e47802e0a5b
* [Storybooks Preview](https://ci-artifacts.kibana.dev/storybooks/pr-213739)

### Failed CI Steps
* [FTR Configs #8](https://buildkite.com/elastic/kibana-pull-request/builds/283594#01958f05-fe94-4cbc-8c85-751d858a5f63)
* [FTR Configs #60](https://buildkite.com/elastic/kibana-pull-request/builds/283594#01958f05-feb5-49dc-91d2-191599e128e4)

### Metrics [[docs](https://www.elastic.co/guide/en/kibana/master/ci-metrics.html)]
#### Public APIs missing comments
> Total count of every public API that lacks a comment. Target amount is 0. Run `node scripts/build_api_docs --plugin [yourplugin] --stats comments` for more detailed information.


| id | [before](https://github.com/elastic/kibana/commit/9fb25a155c9d88b0b23ac4264c6d38bead9f544b) | [after](https://github.com/elastic/kibana/commit/f25ee3cc3674f70058707c6939469e47802e0a5b) | diff |
| --- | --- | --- | --- |
| `@kbn/unified-tabs` | 14 | 17 | +3 |

#### Public APIs missing exports
> Total count of every type that is part of your API that should be exported but is not. This will cause broken links in the API documentation system. Target amount is 0. `Run node scripts/build_api_docs --plugin [yourplugin] --stats exports` for more detailed information.


| id | [before](https://github.com/elastic/kibana/commit/9fb25a155c9d88b0b23ac4264c6d38bead9f544b) | [after](https://github.com/elastic/kibana/commit/f25ee3cc3674f70058707c6939469e47802e0a5b) | diff |
| --- | --- | --- | --- |
| `@kbn/unified-tabs` | 1 | 2 | +1 |

<details><summary>Unknown metric groups</summary>

#### API count
| id | [before](https://github.com/elastic/kibana/commit/9fb25a155c9d88b0b23ac4264c6d38bead9f544b) | [after](https://github.com/elastic/kibana/commit/f25ee3cc3674f70058707c6939469e47802e0a5b) | diff |
| --- | --- | --- | --- |
| `@kbn/unified-tabs` | 14 | 17 | +3 |

#### ESLint disabled line counts
| id | [before](https://github.com/elastic/kibana/commit/9fb25a155c9d88b0b23ac4264c6d38bead9f544b) | [after](https://github.com/elastic/kibana/commit/f25ee3cc3674f70058707c6939469e47802e0a5b) | diff |
| --- | --- | --- | --- |
| `@kbn/test-suites-src` | 57 | 59 | +2 |

#### Total ESLint disabled count
| id | [before](https://github.com/elastic/kibana/commit/9fb25a155c9d88b0b23ac4264c6d38bead9f544b) | [after](https://github.com/elastic/kibana/commit/f25ee3cc3674f70058707c6939469e47802e0a5b) | diff |
| --- | --- | --- | --- |
| `@kbn/test-suites-src` | 63 | 65 | +2 |


</details>


### History
* :yellow_heart: [Build #283234](https://buildkite.com/elastic/kibana-pull-request/builds/283234) was flaky 91cc0c3a1b446e4072e2365e36fc9f71ca517931
* :green_heart: [Build #282466](https://buildkite.com/elastic/kibana-pull-request/builds/282466) succeeded b6517721394e7521667dbebdc0a669475eda5245
* :broken_heart: [Build #282432](https://buildkite.com/elastic/kibana-pull-request/builds/282432) failed a1921248ee09613924bd0fc6744f7b3fb794709a
* :broken_heart: [Build #282401](https://buildkite.com/elastic/kibana-pull-request/builds/282401) failed 4d8f86cd1ac41416a4ad04cfdc59fed2a7358d92
* :broken_heart: [Build #282230](https://buildkite.com/elastic/kibana-pull-request/builds/282230) failed b886f14edabc23bd2366d8e420a5562504ea9596

cc @jughosta

<!--buildkite-pr-comment-kibana-pull-request
{"builds":[{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/283594","number":283594,"commit":"f25ee3cc3674f70058707c6939469e47802e0a5b"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/283234","number":283234,"commit":"91cc0c3a1b446e4072e2365e36fc9f71ca517931"},{"buildStatus":{"state":"passed","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282466","number":282466,"commit":"b6517721394e7521667dbebdc0a669475eda5245"},{"buildStatus":{"state":"failed","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282432","number":282432,"commit":"a1921248ee09613924bd0fc6744f7b3fb794709a"},{"buildStatus":{"state":"failed","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282401","number":282401,"commit":"4d8f86cd1ac41416a4ad04cfdc59fed2a7358d92"},{"buildStatus":{"state":"failed","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282230","number":282230,"commit":"b886f14edabc23bd2366d8e420a5562504ea9596"}],"number":283594}
buildkite-pr-comment-->
author_association: CONTRIBUTOR