diff --git a/index.js b/index.js
index 2ecae29e4..af5e675a7 100644
--- a/index.js
+++ b/index.js
@@ -61,6 +61,13 @@ export {
expandAllFunction
} from './lib/Accordion';
+export {
+ Tabs,
+ Tab,
+ TabList,
+ TabPanel
+} from './lib/Tabs';
+
/* misc */
export { default as Icon } from './lib/Icon';
export { default as IconButton } from './lib/IconButton';
diff --git a/lib/Tabs/Tab.js b/lib/Tabs/Tab.js
new file mode 100644
index 000000000..83ad3472e
--- /dev/null
+++ b/lib/Tabs/Tab.js
@@ -0,0 +1,62 @@
+import React, { useContext, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import { TabsContext } from './TabsContext';
+
+import css from './Tabs.css';
+
+const Tab = (props) => {
+ const {
+ children,
+ index
+ } = props;
+
+ const thisTab = useRef(null);
+
+ const {
+ selectedTabIndex,
+ setSelectedTabIndex
+ } = useContext(TabsContext);
+
+ // Ensure the correct tab has focus
+ useEffect(() => {
+ if (selectedTabIndex === index) {
+ thisTab.current.focus();
+ }
+ // Having index as a dep makes no sense, it's never
+ // going to change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedTabIndex]);
+
+ const activeStyle = selectedTabIndex === index ? css.primary : css.default;
+ const finalStyles = [css.tab, activeStyle].join(' ');
+
+ return (
+ // Keyboard based interactivity with the tabs is handled in TabList
+ // so we don't need a onKey* handler here
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
+
setSelectedTabIndex(index)}
+ aria-selected={selectedTabIndex === index}
+ aria-controls={`tabpanel-${index}`}
+ className={finalStyles}
+ id={`tab-${index}`}
+ role="tab"
+ >
+ {children}
+
+ );
+};
+
+Tab.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.array,
+ PropTypes.string
+ ]),
+ index: PropTypes.number
+};
+
+export default Tab;
diff --git a/lib/Tabs/TabList.js b/lib/Tabs/TabList.js
new file mode 100644
index 000000000..0f65b8361
--- /dev/null
+++ b/lib/Tabs/TabList.js
@@ -0,0 +1,75 @@
+import React, { useContext, cloneElement } from 'react';
+import PropTypes from 'prop-types';
+
+import { TabsContext } from './TabsContext';
+
+import css from './Tabs.css';
+
+const TabList = (props) => {
+ const {
+ ariaLabel,
+ children
+ } = props;
+
+ const {
+ selectedTabIndex,
+ setSelectedTabIndex
+ } = useContext(TabsContext);
+
+ // Add the index to each child, which will allow us to ensure the current
+ // tab is styled correctly and has focus etc.
+ const childrenArray = Array.isArray(children) ? children : [children];
+ const childrenWithIndex = childrenArray.map((child, index) => cloneElement(child, { index, key: index }));
+
+ // Handle setting of the next index when navigating
+ // by keyboard
+ const calculateNextIndex = (action) => {
+ if (action === 'increase') {
+ const maxIndex = children.length - 1;
+ return selectedTabIndex < maxIndex ?
+ selectedTabIndex + 1 :
+ selectedTabIndex;
+ }
+ if (action === 'decrease') {
+ return selectedTabIndex > 0 ?
+ selectedTabIndex - 1 :
+ selectedTabIndex;
+ }
+ return selectedTabIndex;
+ };
+
+ // Handle the right and left cursor keys for navigating
+ // via keyboard.
+ const handleKeyDown = (e) => {
+ switch (e.keyCode) {
+ case 39: // Right arrow
+ setSelectedTabIndex(calculateNextIndex('increase'));
+ break;
+ case 37: // Left arrow
+ setSelectedTabIndex(calculateNextIndex('decrease'));
+ break;
+ default:
+ }
+ };
+
+ return (
+
+ );
+};
+
+TabList.propTypes = {
+ ariaLabel: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.array
+ ])
+};
+
+export default TabList;
diff --git a/lib/Tabs/TabPanel.js b/lib/Tabs/TabPanel.js
new file mode 100644
index 000000000..bf3235e9e
--- /dev/null
+++ b/lib/Tabs/TabPanel.js
@@ -0,0 +1,35 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+
+import { TabsContext } from './TabsContext';
+
+import css from './Tabs.css';
+
+const TabPanel = (props) => {
+ const {
+ children,
+ index
+ } = props;
+
+ const { selectedTabIndex } = useContext(TabsContext);
+
+ return selectedTabIndex === index && (
+
+ {children}
+
+ );
+};
+
+TabPanel.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.string
+ ]),
+ index: PropTypes.number
+};
+export default TabPanel;
diff --git a/lib/Tabs/Tabs.css b/lib/Tabs/Tabs.css
new file mode 100644
index 000000000..2912f2f3e
--- /dev/null
+++ b/lib/Tabs/Tabs.css
@@ -0,0 +1,42 @@
+@import '../variables.css';
+
+ul {
+ margin: 0;
+}
+
+.tabList {
+ display: flex;
+ list-style-type: none;
+ padding: 0;
+}
+
+.tab {
+ padding: 8px;
+ cursor: pointer;
+ text-align: center;
+ font-weight: var(--text-weight-button);
+ font-size: var(--font-size-medium);
+ transition: background-color 0.25s, color 0.25s, opacity 0.07s;
+ border: 1px solid var(--primary);
+ border-right: 0;
+ border-bottom: 0;
+}
+.tab:last-child {
+ border-right: 1px solid var(--primary);
+}
+.tab.default {
+ background-color: transparent;
+ color: var(--primary);
+}
+.tab.primary {
+ background-color: var(--primary);
+ color: #fff;
+}
+.tab:hover {
+ opacity: 0.9;
+}
+
+.tabPanel {
+ border: 1px solid var(--primary);
+ padding: 15px;
+}
diff --git a/lib/Tabs/Tabs.js b/lib/Tabs/Tabs.js
new file mode 100644
index 000000000..1672741fd
--- /dev/null
+++ b/lib/Tabs/Tabs.js
@@ -0,0 +1,39 @@
+import React, { cloneElement } from 'react';
+import PropTypes from 'prop-types';
+
+import { TabsContextProvider } from './TabsContext';
+
+const Tabs = (props) => {
+ const { children } = props;
+
+ const childrenArray = Array.isArray(children) ? children : [children];
+ const childIndexes = {};
+ const childrenWithIndex = childrenArray.flat().map((child, index) => {
+ // children can consist of & components,
+ // ensure that the children of different types get correct indexes
+ const current = childIndexes[child.type.name];
+ childIndexes[child.type.name] = current >= 0 ? current + 1 : 0;
+ return cloneElement(
+ child,
+ {
+ index: childIndexes[child.type.name],
+ key: index
+ }
+ );
+ });
+
+ return (
+
+ {childrenWithIndex}
+
+ );
+};
+
+Tabs.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.array
+ ])
+};
+
+export default Tabs;
diff --git a/lib/Tabs/TabsContext.js b/lib/Tabs/TabsContext.js
new file mode 100644
index 000000000..47f836650
--- /dev/null
+++ b/lib/Tabs/TabsContext.js
@@ -0,0 +1,31 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+const TabsContext = React.createContext();
+
+const TabsContextProvider = ({ children }) => {
+ const [selectedTabIndex, setSelectedTabIndex] = useState(0);
+
+ const defaultContext = {
+ selectedTabIndex,
+ setSelectedTabIndex
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+TabsContextProvider.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.array
+ ])
+};
+
+export {
+ TabsContext,
+ TabsContextProvider
+};
diff --git a/lib/Tabs/index.js b/lib/Tabs/index.js
new file mode 100644
index 000000000..232dca97a
--- /dev/null
+++ b/lib/Tabs/index.js
@@ -0,0 +1,4 @@
+export { default as Tabs } from './Tabs';
+export { default as TabList } from './TabList';
+export { default as Tab } from './Tab';
+export { default as TabPanel } from './TabPanel';
diff --git a/lib/Tabs/tests/Tabs-test.js b/lib/Tabs/tests/Tabs-test.js
new file mode 100644
index 000000000..948547f97
--- /dev/null
+++ b/lib/Tabs/tests/Tabs-test.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import {
+ describe,
+ beforeEach,
+ it,
+} from 'mocha';
+
+import {
+ TabList as TabListInteractor,
+ Tab as TabInteractor,
+ TabPanel as TabPanelInteractor,
+ including,
+ Keyboard
+} from '@folio/stripes-testing';
+
+import { mount } from '../../../tests/helpers';
+
+import Tabs from '../Tabs';
+import TabList from '../TabList';
+import Tab from '../Tab';
+import TabPanel from '../TabPanel';
+
+const doMount = () => {
+ return mount(
+
+
+ Tab 0
+ Tab 1
+ Tab 2
+
+ <>Panel 0>
+ <>Panel 1>
+ <>Panel 2>
+
+ );
+};
+
+describe('Tabs', () => {
+ beforeEach(async () => {
+ await doMount();
+ });
+ const tabList = TabListInteractor();
+ const tab = TabInteractor('Tab 1');
+ it('should render ul element', async () => {
+ await tabList.exists();
+ });
+ it('ul element should have aria-label attribute containing passed prop', async () => {
+ await tabList.has({ ariaLabel: 'My test aria label' });
+ });
+ it('renders correct number of tabs', async () => {
+ await tabList.has({ tabsLength: 3 });
+ });
+ it('clicking a tab displays the appropriate tab panel', async () => {
+ const tabPanel = TabPanelInteractor('Panel 1');
+ await tab.click();
+ await tabPanel.exists();
+ });
+ it('clicking a tab highlights it', async () => {
+ await tab.click();
+ await tab.has({ className: including('primary') });
+ });
+ it('clicking a tab gives it focus', async () => {
+ await tab.click();
+ await tab.has({ focused: true });
+ });
+ it('pressing right arrow when a tab has focus displays the next tab panel', async () => {
+ const tabPanel2 = await TabPanelInteractor('Panel 2');
+ await tab.click();
+ await Keyboard.arrowRight();
+ await tabPanel2.exists();
+ });
+ it('pressing left arrow when a tab has focus displays the previous tab panel', async () => {
+ const tabPanel0 = await TabPanelInteractor('Panel 0');
+ await tab.click();
+ await Keyboard.arrowLeft();
+ await tabPanel0.exists();
+ });
+ it('pressing left arrow when the first tab has focus displays does nothing', async () => {
+ const tab0 = TabInteractor('Tab 0');
+ const tabPanel0 = await TabPanelInteractor('Panel 0');
+ await tab0.click();
+ await Keyboard.arrowLeft();
+ await tab0.has({ focused: true });
+ await tabPanel0.exists();
+ });
+ it('pressing right arrow when the last tab has focus displays does nothing', async () => {
+ const tab2 = TabInteractor('Tab 2');
+ const tabPanel2 = await TabPanelInteractor('Panel 2');
+ await tab2.click();
+ await Keyboard.arrowRight();
+ await tab2.has({ focused: true });
+ await tabPanel2.exists();
+ });
+});