diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx
new file mode 100644
index 000000000..6e23acd95
--- /dev/null
+++ b/src/components/tabs/Tabs.spec.tsx
@@ -0,0 +1,121 @@
+import * as React from 'react';
+import {fireEvent, render, screen} from '@testing-library/react';
+import Tabs, {Tab} from './Tabs';
+
+const hiddenPanelClass = 'sg-tabs__panel--hidden';
+
+describe('', () => {
+ it('shows by default all tabs and only first panel', () => {
+ render(
+
+
+
+ First tab
+ Second tab
+
+
+ Content 1
+ Content 2
+
+ );
+ expect(screen.getByText('First tab')).toBeInTheDocument();
+ expect(screen.getByText('Second tab')).toBeInTheDocument();
+ expect(screen.getByText('Content 1')).toBeInTheDocument();
+ expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass);
+ });
+
+ it('shows panel corresponding to startIndex', () => {
+ render(
+
+
+
+ First tab
+ Second tab
+
+
+ Content 1
+ Content 2
+
+ );
+ expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass);
+ });
+
+ it('correctly handles tab change', () => {
+ const mockOnChange = jest.fn();
+
+ render(
+
+
+
+ First tab
+ Second tab
+
+
+ Content 1
+ Content 2
+
+ );
+ expect(screen.getByText('Content 1')).not.toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass);
+ const secondTab = screen.getByText('Second tab');
+
+ fireEvent.click(secondTab);
+ expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass);
+ expect(mockOnChange).toBeCalledWith(
+ screen.getByText('Second tab').parentElement
+ );
+ });
+
+ it('makes component controlled if correct props is passed', () => {
+ const mockActiveIndex = 2;
+ const mockOnChange = jest.fn();
+ const {rerender} = render(
+
+
+
+ First tab
+ Second tab
+ Third tab
+
+
+ Content 1
+ Content 2
+ Content 3
+
+ );
+
+ expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 3')).not.toHaveClass(hiddenPanelClass);
+
+ const secondTab = screen.getByText('Second tab');
+
+ fireEvent.click(secondTab);
+
+ expect(mockOnChange).not.toHaveBeenCalled();
+ expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 3')).not.toHaveClass(hiddenPanelClass);
+
+ rerender(
+
+
+
+ First tab
+ Second tab
+ Third tab
+
+
+ Content 1
+ Content 2
+ Content 3
+
+ );
+
+ expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass);
+ expect(screen.getByText('Content 3')).toHaveClass(hiddenPanelClass);
+ });
+});
diff --git a/src/components/tabs/Tabs.stories.mdx b/src/components/tabs/Tabs.stories.mdx
new file mode 100644
index 000000000..90db519af
--- /dev/null
+++ b/src/components/tabs/Tabs.stories.mdx
@@ -0,0 +1,209 @@
+import Button from '../buttons/Button';
+import Flex from '../flex/Flex';
+import Icon from '../icons/Icon';
+import Text from '../text/Text';
+import Checkbox from '../form-elements/checkbox/Checkbox';
+import PageHeader from 'blocks/PageHeader';
+import {useState} from 'react';
+import classnames from 'classnames';
+import {TabHeaderProps} from './components';
+import Tabs, {Tab, TabsProps} from './Tabs.tsx';
+import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs';
+import TabsA11y from './stories/Tabs.a11y.mdx';
+
+
+
+Tabs
+
+- [Stories](#stories)
+- [Accessibility](#accessibility)
+
+## Overview
+
+
+
+
+
+## Stories
+
+### External Control
+
+
+
+### Custom header
+
+
+
+## Accessibility
+
+
diff --git a/src/components/tabs/Tabs.tsx b/src/components/tabs/Tabs.tsx
new file mode 100644
index 000000000..134854960
--- /dev/null
+++ b/src/components/tabs/Tabs.tsx
@@ -0,0 +1,128 @@
+/* eslint-disable no-redeclare */
+/**
+ * We need to disable no-redeclare as redeclaration allows us to use
+ * TS overloading to type different usages scenarios
+ */
+import * as React from 'react';
+import type {PanelElement, TabElement, WithChildren} from './components';
+import {TabContext} from './hooks';
+
+export type TabsProps = WithChildren & {
+ onTabChange?: (currentActiveTab: TabElement) => void;
+ startIndex?: number;
+ activeIndex?: number;
+};
+
+export type Context = {
+ activeTab: TabElement;
+ activePanel: PanelElement;
+ setActiveIndex: (tab: TabElement) => void;
+ registerTab: (tab: TabElement) => void;
+ registerPanel: (panel: PanelElement) => void;
+ a11yHelpers: {
+ getTabHelpers: (tab: TabElement) => {
+ id: string;
+ controls: string;
+ };
+ getPanelHelpers: (panel: PanelElement) => {
+ id: string;
+ labelledBy: string;
+ };
+ };
+};
+
+/**
+ * Providing activeIndex turns Tabs into controlled component.
+ * Because of that providing `startIndex` doesn't make sense
+ * as component is controlled externally.
+ */
+function Tabs({
+ children,
+ onTabChange,
+ activeIndex,
+ ...rest
+}: Omit): JSX.Element;
+
+/**
+ * `startIndex` is exclusive to uncontrolled component, as
+ * by default component handles tab change itself.
+ * Because of that providing `activeIndex` to this component
+ * is a mistake.
+ */
+function Tabs({
+ children,
+ onTabChange,
+ startIndex,
+ ...rest
+}: Omit): JSX.Element;
+
+function Tabs({
+ children,
+ onTabChange = () => undefined,
+ startIndex = 0,
+ activeIndex,
+ ...rest
+}: TabsProps) {
+ const isControlledComponent = activeIndex !== undefined;
+ const [tabs, setTabs] = React.useState([]);
+ const [panels, setPanels] = React.useState([]);
+ const [currentSelectedIndex, setCurrentSelectedIndex] =
+ React.useState(startIndex);
+
+ const activeTab = tabs[currentSelectedIndex];
+ const activePanel = panels[currentSelectedIndex];
+ const setActiveIndex = (tab: TabElement) => {
+ if (isControlledComponent) {
+ return;
+ }
+ setCurrentSelectedIndex(tabs.indexOf(tab));
+ onTabChange(tab);
+ };
+
+ const a11yHelpers = {
+ getTabHelpers: (tab: TabElement) => {
+ const currentTabIndex = tabs.indexOf(tab);
+
+ return {
+ id: `tab-${currentTabIndex}`,
+ controls: `panel-${currentTabIndex}`,
+ };
+ },
+ getPanelHelpers: (panel: PanelElement) => {
+ const currentPanelIndex = panels.indexOf(panel);
+
+ return {
+ id: `panel-${currentPanelIndex}`,
+ labelledBy: `tab-${currentPanelIndex}`,
+ };
+ },
+ };
+
+ const initialContextState: Context = {
+ activeTab,
+ activePanel,
+ setActiveIndex,
+ registerTab: React.useCallback(
+ tab => setTabs(previousTabs => [...previousTabs, tab]),
+ []
+ ),
+ registerPanel: React.useCallback(
+ panel => setPanels(previousPanels => [...previousPanels, panel]),
+ []
+ ),
+ a11yHelpers,
+ };
+
+ if (isControlledComponent && currentSelectedIndex !== activeIndex) {
+ setCurrentSelectedIndex(activeIndex);
+ }
+
+ return (
+
+
+
+ );
+}
+
+export {Tabs as default};
+export {Tab} from './components';
diff --git a/src/components/tabs/_tabs.scss b/src/components/tabs/_tabs.scss
new file mode 100644
index 000000000..ccf667d84
--- /dev/null
+++ b/src/components/tabs/_tabs.scss
@@ -0,0 +1,83 @@
+@use 'sass:map';
+
+.sg-tabs {
+ &__header {
+ --border-color: var(--gray-20);
+ height: var(--list-height);
+ border-bottom: 2px solid var(--border-color);
+ }
+
+ &__list {
+ margin: 0;
+ padding: 0;
+ position: relative;
+ }
+
+ &__tab {
+ --inactive-text-color: var(--text-gray-50);
+ --active-text-color: var(--text-black);
+
+ padding: 0 map.get($sizesSetup, 's');
+ height: 100%;
+ border-radius: 1px;
+
+ &:hover {
+ cursor: pointer;
+ .tabText {
+ opacity: 0.7;
+ }
+ }
+
+ &:focus-visible {
+ box-shadow: 0px 0px 0px 2px var(--white),
+ 0px 0px 0px 4px var(--focusColor),
+ 0px 0px 0px 6px rgba(109, 131, 243, 0.3);
+ }
+
+ &--disabled {
+ pointer-events: none;
+ opacity: 0.45;
+ }
+
+ &-text {
+ transition: color $durationModerate1 $easingRegular;
+ color: var(--inactive-text-color);
+
+ &--active {
+ color: var(--active-text-color);
+ }
+ }
+ }
+
+ &__panel {
+ &--hidden {
+ display: none;
+ }
+ }
+
+ &__active-indicator {
+ /**
+ * [1] - We want indicator to cover header bottom border
+ * so we need manually to reposition it
+ */
+ position: absolute;
+ height: 2px;
+ left: 0;
+ width: 100%;
+
+ &--bottom {
+ bottom: -2px; // [1]
+ }
+
+ &--top {
+ top: -2px; // [1]
+ }
+
+ &--inner {
+ --color: var(--black);
+ background-color: var(--color);
+ width: 100%;
+ height: 100%;
+ }
+ }
+}
diff --git a/src/components/tabs/components/ActiveIndicator.tsx b/src/components/tabs/components/ActiveIndicator.tsx
new file mode 100644
index 000000000..0952ebbdf
--- /dev/null
+++ b/src/components/tabs/components/ActiveIndicator.tsx
@@ -0,0 +1,67 @@
+import * as React from 'react';
+import Transition, {TransitionEffectType} from '../../transition/Transition';
+import classnames from 'classnames';
+import {useTabsContext} from '../hooks';
+
+type StyleType = Partial<
+ React.CSSProperties & {
+ '--color': string;
+ }
+>;
+export type ActiveIndicatorProps = React.DetailedHTMLProps<
+ React.HTMLAttributes,
+ HTMLDivElement
+> & {
+ position?: 'top' | 'bottom';
+ style?: StyleType;
+ className?: string;
+};
+
+export const ActiveIndicator = ({
+ position = 'bottom',
+ className,
+ ...rest
+}: ActiveIndicatorProps) => {
+ const {activeTab} = useTabsContext();
+
+ if (!activeTab) {
+ return null;
+ }
+
+ const {x: activeTabX, width: activeTabWidth} =
+ activeTab.getBoundingClientRect();
+ const {x: offsetX, width: containerWidth} =
+ activeTab.offsetParent.getBoundingClientRect();
+
+ const activeTabToContainerRatio =
+ containerWidth > 0 ? activeTabWidth / containerWidth : 0;
+
+ const activeIndicatorEffect: TransitionEffectType = {
+ animate: {
+ transform: {
+ translateX: activeTabX - offsetX,
+ origin: 'left top',
+ scaleX: activeTabToContainerRatio,
+ },
+ duration: 'moderate2',
+ easing: 'entry',
+ },
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/tabs/components/Header.tsx b/src/components/tabs/components/Header.tsx
new file mode 100644
index 000000000..9d78b04a6
--- /dev/null
+++ b/src/components/tabs/components/Header.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import Flex, {FlexPropsType} from '../../flex/Flex';
+import classnames from 'classnames';
+import type {WithChildren} from './Tab';
+
+type StyleType = Partial<
+ React.CSSProperties & {
+ '--list-height': string;
+ '--border-color': string;
+ }
+>;
+
+export type TabHeaderProps = WithChildren &
+ FlexPropsType & {
+ height?: string;
+ style?: StyleType;
+ };
+
+export const Header = ({
+ children,
+ height = '48px',
+ className,
+ style = {'--list-height': height},
+ ...rest
+}: TabHeaderProps) => (
+
+ {children}
+
+);
diff --git a/src/components/tabs/components/List.tsx b/src/components/tabs/components/List.tsx
new file mode 100644
index 000000000..888b30583
--- /dev/null
+++ b/src/components/tabs/components/List.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import Flex, {FlexPropsType} from '../../flex/Flex';
+import classnames from 'classnames';
+import type {WithChildren} from './Tab';
+
+export type TabListProps = WithChildren & FlexPropsType;
+export const List = ({children, className, ...rest}: TabListProps) => (
+
+ {children}
+
+);
diff --git a/src/components/tabs/components/Panel.tsx b/src/components/tabs/components/Panel.tsx
new file mode 100644
index 000000000..d409b0438
--- /dev/null
+++ b/src/components/tabs/components/Panel.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import Flex, {FlexPropsType} from '../../flex/Flex';
+import classnames from 'classnames';
+import {useTabsContext} from '../hooks';
+import type {WithChildren} from './Tab';
+
+export type PanelElement = HTMLDivElement | undefined;
+export type TabPanelProps = WithChildren & FlexPropsType;
+
+export const Panel = ({children, ...rest}: TabPanelProps) => {
+ const {activePanel, registerPanel, a11yHelpers} = useTabsContext();
+ const panelRef = React.useRef();
+ const {id, labelledBy} = a11yHelpers.getPanelHelpers(panelRef.current);
+ const callbackRef = React.useCallback(
+ (panel: PanelElement | null) => {
+ if (panel) {
+ panelRef.current = panel;
+ registerPanel(panel);
+ }
+ },
+ [registerPanel]
+ );
+ const isActive = panelRef.current === activePanel;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/tabs/components/Tab.tsx b/src/components/tabs/components/Tab.tsx
new file mode 100644
index 000000000..9a33eac2f
--- /dev/null
+++ b/src/components/tabs/components/Tab.tsx
@@ -0,0 +1,73 @@
+import * as React from 'react';
+import Text from '../../text/Text';
+import Flex, {FlexPropsType} from '../../flex/Flex';
+import classnames from 'classnames';
+import {useTabsContext} from '../hooks';
+import {Header} from './Header';
+import {List} from './List';
+import {ActiveIndicator} from './ActiveIndicator';
+import {Panel} from './Panel';
+
+type StyleType = {
+ style?: Partial<
+ React.CSSProperties & {
+ '--inactive-text-color': string;
+ '--active-text-color': string;
+ }
+ >;
+};
+
+export type WithChildren = {children: React.ReactNode};
+export type TabElement = HTMLLIElement | undefined;
+export type TabProps = WithChildren & FlexPropsType & StyleType;
+
+export const Tab = ({children, className, disabled, ...rest}: TabProps) => {
+ const {activeTab, registerTab, setActiveIndex, a11yHelpers} =
+ useTabsContext();
+ const tabRef = React.useRef();
+ const callbackRef = React.useCallback(
+ (tab: TabElement | null) => {
+ if (tab) {
+ tabRef.current = tab;
+ registerTab(tab);
+ }
+ },
+ [registerTab]
+ );
+
+ const {id, controls} = a11yHelpers.getTabHelpers(tabRef.current);
+ const isActive = activeTab !== undefined && tabRef.current === activeTab;
+
+ return (
+ setActiveIndex(tabRef.current)}
+ ref={callbackRef}
+ className={classnames('sg-tabs__tab', className, {
+ 'sg-tabs__tab--disabled': disabled && !isActive,
+ })}
+ alignItems="center"
+ role="tab"
+ aria-selected={isActive}
+ aria-controls={controls}
+ suppressHydrationWarning
+ >
+
+ {children}
+
+
+ );
+};
+
+Tab.Header = Header;
+Tab.List = List;
+Tab.ActiveIndicator = ActiveIndicator;
+Tab.Panel = Panel;
diff --git a/src/components/tabs/components/index.tsx b/src/components/tabs/components/index.tsx
new file mode 100644
index 000000000..b5e6e279e
--- /dev/null
+++ b/src/components/tabs/components/index.tsx
@@ -0,0 +1,5 @@
+export * from './ActiveIndicator';
+export * from './Header';
+export * from './List';
+export * from './Panel';
+export * from './Tab';
diff --git a/src/components/tabs/hooks.ts b/src/components/tabs/hooks.ts
new file mode 100644
index 000000000..1a6528753
--- /dev/null
+++ b/src/components/tabs/hooks.ts
@@ -0,0 +1,13 @@
+import {createContext, useContext} from 'react';
+import type {Context} from './Tabs';
+
+export const TabContext = createContext({} as Context);
+
+export const useTabsContext = () => {
+ const tabsContext = useContext(TabContext);
+
+ if (!tabsContext) {
+ throw new Error('useTabContext must be used within TabContextProvider');
+ }
+ return tabsContext;
+};
diff --git a/src/components/tabs/stories/Tabs.a11y.mdx b/src/components/tabs/stories/Tabs.a11y.mdx
new file mode 100644
index 000000000..af00592ab
--- /dev/null
+++ b/src/components/tabs/stories/Tabs.a11y.mdx
@@ -0,0 +1,37 @@
+import rules, {
+ tabRules,
+ tabActiveIndicatorRules,
+ tabListRules,
+ tabPanelRules,
+ tabHeaderRules,
+} from './rules.a11y';
+
+### Rules
+
+#### Tabs
+
+
+
+#### Tab
+
+
+
+#### Tab.ActiveIndicator
+
+
+
+#### Tab.List
+
+
+
+#### Tab.Panel
+
+
+
+#### Tab.Header
+
+
+
+### Usage
+
+#### Code examples
diff --git a/src/components/tabs/stories/rules.a11y.ts b/src/components/tabs/stories/rules.a11y.ts
new file mode 100644
index 000000000..5c14c8848
--- /dev/null
+++ b/src/components/tabs/stories/rules.a11y.ts
@@ -0,0 +1,164 @@
+const rules = [
+ {
+ pattern: 'Can have an accessible name.',
+ comment:
+ 'Can be named by setting a value for title prop (defaults to icon type).',
+ status: '',
+ tests: '',
+ },
+];
+
+export const tabRules = [
+ {
+ pattern: 'Should have an accessible name.',
+ comment: `Name should be meaningful. aria-label can be used to provide a name.`,
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should have a role tab.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should have an associated tab panel.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should be tabbable and focusable when associated tab panel is active.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should have a color indicator with 4.5:1 contrast ratio to the background.',
+ comment: 'gray-50 against white: 4.37:1.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should have a color active indicator with 3:1 contrast ratio to the surrounding background.',
+ comment: 'Tab.ActiveIndicator should be used.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should have cursor: default.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should be activated on Space/Enter press and mouse click.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should be contained in, or owned by, an element with the role tablist.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should have the aria-selected attribute set to true when associated tab panel is active.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Can have the aria-controls attribute to reference the associated tab panel.',
+ status: '',
+ tests: '',
+ },
+];
+
+export const tabActiveIndicatorRules = [
+ {
+ pattern:
+ 'Should have a color active indicator with 3:1 contrast ratio to the surrounding background.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should be hidden from accessibility tree.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should respect prefers-reduced-motion.',
+ comment: `prefers-reduced-motion system setting is respected.`,
+ status: '',
+ tests: '',
+ },
+];
+
+export const tabListRules = [
+ {
+ pattern: 'Should have a role tablist.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should contain element with tab role.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should have aria-orientation set to horizontal.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should have an accessible name.',
+ comment: `Name should be meaningful. aria-label and aria-labelledby can be used to provide a name.`,
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should move focus between tabs on Arrow Left and Arrow Right press.',
+ status: '',
+ tests: '',
+ },
+];
+
+export const tabPanelRules = [
+ {
+ pattern: 'Should have a role tabpanel.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should have an accessible name connected with associated tab.',
+ comment: `aria-labelledby can be used to provide a name.`,
+ status: '',
+ tests: '',
+ },
+ {
+ pattern:
+ 'Should be focusable when the it does not contain any focusable elements.',
+ status: '',
+ tests: '',
+ },
+ {
+ pattern: 'Should have an associated tab.',
+ status: '',
+ tests: '',
+ },
+];
+
+export const tabHeaderRules = [
+ {
+ pattern: 'Should be only presentational.',
+ status: '',
+ tests: '',
+ },
+];
+
+export default rules;
diff --git a/src/index.ts b/src/index.ts
index ac6713d77..c135efa75 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -169,3 +169,5 @@ export type {SkipLinkPropsType} from './components/skip-link/SkipLink';
export {default as SkipLink} from './components/skip-link/SkipLink';
export type {ProgressBarPropsType} from './components/progress-bar/ProgressBar';
export {default as ProgressBar} from './components/progress-bar/ProgressBar';
+export type {TabsProps} from './components/tabs/Tabs';
+export {default as Tabs, Tab} from './components/tabs/Tabs';
diff --git a/src/sass/main.scss b/src/sass/main.scss
index aeab08ef1..9c191c3fc 100644
--- a/src/sass/main.scss
+++ b/src/sass/main.scss
@@ -67,3 +67,4 @@ $sgFontsPath: 'fonts/' !default;
@import '../components/transition/transition';
@import '../components/skip-link/skip-link';
@import '../components/progress-bar/progress-bar';
+@import '../components/tabs/tabs';