Tabs
Tabs organize groups of related content and let users navigate between them.
'use client';
import * as React from 'react';
import { useTheme } from '@mui/system';
import { Tabs } from '@base_ui/react/Tabs';
export default function UnstyledTabsIntroduction() {
return (
<React.Fragment>
<Tabs.Root defaultValue={0}>
<Tabs.List className="CustomTabsListIntroduction" aria-label="Settings">
<Tabs.Tab className="CustomTabIntroduction" value={0}>
My account
</Tabs.Tab>
<Tabs.Tab className="CustomTabIntroduction" value={1}>
Profile
</Tabs.Tab>
<Tabs.Tab className="CustomTabIntroduction" value={2}>
Language
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel className="CustomTabPanelIntroduction" value={0}>
My account page
</Tabs.Panel>
<Tabs.Panel className="CustomTabPanelIntroduction" value={1}>
Profile page
</Tabs.Panel>
<Tabs.Panel className="CustomTabPanelIntroduction" value={2}>
Language page
</Tabs.Panel>
</Tabs.Root>
<Styles />
</React.Fragment>
);
}
const cyan = {
50: '#E9F8FC',
100: '#BDEBF4',
200: '#99D8E5',
300: '#66BACC',
400: '#1F94AD',
500: '#0D5463',
600: '#094855',
700: '#063C47',
800: '#043039',
900: '#022127',
};
const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
function useIsDarkMode() {
const theme = useTheme();
return theme.palette.mode === 'dark';
}
function Styles() {
// Replace this with your app logic for determining dark mode
const isDarkMode = useIsDarkMode();
return (
<style>
{`
.CustomTabsListIntroduction {
min-width: 400px;
background-color: ${cyan[500]};
border-radius: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
align-content: space-between;
box-shadow: 0px 4px 6px ${isDarkMode ? grey[900] : grey[200]};
}
.CustomTabIntroduction {
font-family: 'IBM Plex Sans', sans-serif;
color: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
background-color: transparent;
width: 100%;
padding: 10px 12px;
margin: 6px;
border: none;
border-radius: 7px;
display: flex;
justify-content: center;
}
.CustomTabIntroduction:hover {
background-color: ${cyan[400]};
}
.CustomTabIntroduction:focus {
color: #fff;
outline: 3px solid ${cyan[200]};
}
.CustomTabIntroduction[data-selected] {
background-color: #fff;
color: ${cyan[600]};
}
.CustomTabIntroduction[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.CustomTabPanelIntroduction {
width: 100%;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.875rem;
padding: 20px 12px;
background: ${isDarkMode ? grey[900] : '#fff'};
border: 1px solid ${isDarkMode ? grey[700] : grey[200]};
border-radius: 12px;
opacity: 0.6;
}
`}
</style>
);
}
Installation
Base UI components are all available as a single package.
npm install @base_ui/react
Once you have the package installed, import the component.
import { Tabs } from '@base_ui/react/Tabs';
Anatomy
Tabs are implemented using a collection of related components:
<Tabs.Root />
is a top-level component that wraps the Tabs List and Tab Panel components so that tabs and their panels can communicate with one another.<Tabs.Tab />
is the tab element itself. Clicking on a tab displays its corresponding panel.<Tabs.List />
is the container that houses the tabs. Responsible for handling focus and keyboard navigation between tabs.<Tabs.Panel />
is the card that hosts the content associated with a tab.<Tabs.Indicator />
is an optional active tab indicator.
Specifying values
By default, Tab components and their corresponding panels are zero-indexed.
The first tab has a value
of 0
, the second tab has a value
of 1
, and so on.
Activating a tab opens the panel with the same value
, corresponding to the order in which each component is nested within its container.
Though not required, you can add the value
prop to the Tab and Tab Panel to control how these components are associated.
Indicator
Though it's optional, the Tabs.Indicator
component can be added to implement a visual indicator for the active tab.
To help with styling—in particular animating its position—some CSS variables are provided.
--active-tab-top
is the distance inpx
between the top of the active tab and the top of theTabs.Panel
's bounding box.--active-tab-bottom
is the distance inpx
between the bottom of the active tab and the bottom of theTabs.Panel
's bounding box.--active-tab-left
is the distance inpx
between the left-hand edge of the active tab and the left-hand edge of theTabs.Panel
's bounding box.--active-tab-right
is the distance inpx
between the right-hand edge of the active tab and the right-hand edge of theTabs.Panel
's bounding box.--active-tab-width
is the width inpx
of the active tab's bounding box.--active-tab-height
is the height inpx
of the active tab's bounding box.
Additionally, the Indicator has the data-activation-direction
attribute representing the relation of the selected tab to the previously selected one.
Its value is one of the following:
left
when the active tab is to the left of the previously active tab (only applied whenorientation=horizontal
).right
when the active tab is to the right of the previously active tab (only applied whenorientation=horizontal
).top
when the active tab is above the previously active tab (only applied whenorientation=vertical
).bottom
when the active tab is below the previously active tab (only applied whenorientation=vertical
).none
when there is no previously selected tab.
This example uses the CSS variables and data attributes described above to create an "elastic" movement effect.
'use client';
import * as React from 'react';
import { css, styled } from '@mui/system';
import { Tabs as BaseTabs } from '@base_ui/react/Tabs';
export default function IndicatorBubble() {
return (
<Tabs>
<TabsList>
<Tab>Code</Tab>
<Tab>Issues</Tab>
<Tab>Pull Requests</Tab>
<Tab>Discussions</Tab>
<Tab>Actions</Tab>
<Indicator />
</TabsList>
</Tabs>
);
}
const blue = {
50: '#F0F7FF',
100: '#C2E0FF',
200: '#80BFFF',
300: '#66B2FF',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
800: '#004C99',
900: '#003A75',
};
const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
const Tabs = styled(BaseTabs.Root)`
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 16px;
&[data-orientation='vertical'] {
flex-direction: row;
justify-content: center;
align-items: stretch;
}
`;
const TabsList = styled(BaseTabs.List)(
({ theme }) => css`
background-color: ${blue[500]};
border-radius: 12px;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-evenly;
box-shadow: 0 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
position: relative;
&[data-orientation='vertical'] {
flex-direction: column;
}
`,
);
const Indicator = styled(BaseTabs.Indicator)`
position: absolute;
inset: var(--active-tab-top) var(--active-tab-right) var(--active-tab-bottom)
var(--active-tab-left);
background: ${blue[800]};
border-radius: 8px;
z-index: 0;
box-shadow: 0 0 0 0 ${blue[200]};
outline-width: 0;
&[data-activation-direction='right'] {
transition:
left 0.6s 0.1s,
right 0.3s,
top 0.3s,
bottom 0.3s,
box-shadow 0.2s;
}
&[data-activation-direction='left'] {
transition:
left 0.3s,
right 0.6s 0.1s,
top 0.3s,
bottom 0.3s,
box-shadow 0.2s;
}
*:has(:focus-visible) > & {
box-shadow: 0 0 0 2px ${blue[200]};
}
`;
const Tab = styled(BaseTabs.Tab)`
font-family: 'IBM Plex Sans', sans-serif;
color: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
background-color: transparent;
white-space: nowrap;
flex: 1 1 auto;
padding: 10px 12px;
margin: 6px;
border: none;
border-radius: 7px;
display: flex;
justify-content: center;
position: relative;
z-index: 1;
&:focus-visible {
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
`;
The next example shows a differently shaped Indicator with a simpler movement.
As the transition is independent of direction, the data-activation-direction
attribute is not used for styling.
'use client';
import * as React from 'react';
import { css, styled } from '@mui/system';
import { Tabs as BaseTabs } from '@base_ui/react/Tabs';
export default function IndicatorUnderline() {
return (
<Tabs>
<TabsList>
<Tab>Code</Tab>
<Tab>Issues</Tab>
<Tab>Pull Requests</Tab>
<Tab>Discussions</Tab>
<Tab>Actions</Tab>
<Indicator />
</TabsList>
</Tabs>
);
}
const blue = {
50: '#F0F7FF',
100: '#C2E0FF',
200: '#80BFFF',
300: '#66B2FF',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
800: '#004C99',
900: '#003A75',
};
const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
const Tabs = styled(BaseTabs.Root)`
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 16px;
&[data-orientation='vertical'] {
flex-direction: row;
justify-content: center;
align-items: stretch;
}
`;
const TabsList = styled(BaseTabs.List)(
({ theme }) => css`
background-color: ${blue[500]};
border-radius: 12px;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-evenly;
box-shadow: 0 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
position: relative;
&[data-orientation='vertical'] {
flex-direction: column;
}
`,
);
const Indicator = styled(BaseTabs.Indicator)`
position: absolute;
left: calc(var(--active-tab-left) + 4px);
right: calc(var(--active-tab-right) + 4px);
bottom: calc(var(--active-tab-bottom) + 2px);
height: 4px;
background: ${blue[800]};
border-radius: 8px;
z-index: 0;
box-shadow: 0 0 0 0 ${blue[200]};
outline-width: 0;
transition:
left 0.3s,
right 0.3s,
box-shadow 0.2s;
*:has(:focus-visible) > & {
box-shadow: 0 0 0 2px ${blue[200]};
}
`;
const Tab = styled(BaseTabs.Tab)`
font-family: 'IBM Plex Sans', sans-serif;
color: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
background-color: transparent;
white-space: nowrap;
flex: 1 1 auto;
padding: 10px 12px;
margin: 6px;
border: none;
border-radius: 7px;
display: flex;
justify-content: center;
position: relative;
z-index: 1;
&:focus-visible {
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
`;
Server rendering
The Indicator's rendering depends on React effects and cannot be done on the server. This means that if you're using server-side rendering (SSR), the initially rendered content will not contain the Indicator. It will appear after React hydrates the components.
If you want to minimize the time the Indicator is not visible, you can set the renderBeforeHydration
prop to true
.
This will make the component include an inline script that sets the CSS variables as soon as it's rendered by the browser.
It is disabled by default, as the script contributes to the size of the payload sent by the server.
Orientation
To arrange tabs vertically, set orientation="vertical"
on the <Tabs />
component.
Now, the user can navigate with the up and down arrow keys rather than the default left-to-right behavior for horizontal tabs.
Links
Tabs can be rendered as links to routes in your application. A common use case for tabs is implementing client-side navigation that doesn't require an HTTP round-trip to the server.
'use client';
import * as React from 'react';
import { Tabs } from '@base_ui/react/Tabs';
import {
MemoryRouter,
Route,
Routes,
Link,
matchPath,
useLocation,
} from 'react-router-dom';
import { StaticRouter } from 'react-router-dom/server';
import { styled } from '@mui/system';
function Router(props: { children?: React.ReactNode }) {
const { children } = props;
if (typeof window === 'undefined') {
return <StaticRouter location="/drafts">{children}</StaticRouter>;
}
return (
<MemoryRouter initialEntries={['/drafts']} initialIndex={0}>
{children}
</MemoryRouter>
);
}
function useRouteMatch(patterns: readonly string[]) {
const { pathname } = useLocation();
for (let i = 0; i < patterns.length; i += 1) {
const pattern = patterns[i];
const possibleMatch = matchPath(pattern, pathname);
if (possibleMatch !== null) {
return possibleMatch;
}
}
return null;
}
function MyTabs() {
// You need to provide the routes in descendant order.
// This means that if you have nested routes like:
// users, users/new, users/edit.
// Then the order should be ['users/add', 'users/edit', 'users'].
const routeMatch = useRouteMatch(['/inbox/:id', '/drafts', '/trash']);
const currentTab = routeMatch?.pattern?.path;
return (
<Tabs.Root value={currentTab}>
<TabsList>
<Tab
value="/inbox/:id"
render={(props) => <Link {...props} to="/inbox/1" />}
>
Inbox
</Tab>
<Tab value="/drafts" render={(props) => <Link {...props} to="/drafts" />}>
Drafts
</Tab>
<Tab value="/trash" render={(props) => <Link {...props} to="/trash" />}>
Trash
</Tab>
</TabsList>
</Tabs.Root>
);
}
function CurrentRoute() {
const location = useLocation();
return <RouteDisplay>Current route: {location.pathname}</RouteDisplay>;
}
export default function UnstyledTabsRouting() {
return (
<Router>
<div>
<Routes>
<Route path="*" element={<CurrentRoute />} />
</Routes>
<MyTabs />
</div>
</Router>
);
}
const blue = {
50: '#F0F7FF',
100: '#C2E0FF',
200: '#80BFFF',
300: '#66B2FF',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
800: '#004C99',
900: '#003A75',
};
const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
const RouteDisplay = styled('p')`
font-size: 0.75rem;
color: ${grey[500]};
`;
const Tab = styled(Tabs.Tab)`
font-family: 'IBM Plex Sans', sans-serif;
color: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
background-color: transparent;
width: 100%;
padding: 10px 12px;
margin: 6px;
border: none;
border-radius: 7px;
display: flex;
justify-content: center;
&:hover {
background-color: ${blue[400]};
}
&:focus {
color: #fff;
outline: 3px solid ${blue[200]};
}
&[data-selected] {
background-color: #fff;
color: ${blue[600]};
}
`;
const TabsList = styled(Tabs.List)(
({ theme }) => `
min-width: 400px;
background-color: ${blue[500]};
border-radius: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
align-content: space-between;
box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
`,
);
Manual tab activation
By default, when using keyboard navigation, tabs are activated automatically when they receive focus.
Alternatively, you can set activateOnFocus={false}
on <Tabs.List>
so tabs are not activated automatically when they receive focus.
Overriding default components
Use the render
prop to override the rendered element:
If you provide a non-interactive element such as a <span>
, the Tab components automatically add the necessary accessibility attributes.
Accessibility
Base UI Tabs follow the Tabs WAI-ARIA design pattern.
Keyboard navigation
Key | Description |
---|---|
Left Arrow | Moves focus to the previous tab (when orientation="horizontal" ) and activates it if activateOnFocus is set. |
Right Arrow | Moves focus to the next tab (when orientation="horizontal" ) and activates it if activateOnFocus is set. |
Up Arrow | Moves focus to the previous tab (when orientation="vertical" ) and activates it if activateOnFocus is set. |
Down Arrow | Moves focus to the next tab (when orientation="vertical" ) and activates it if activateOnFocus is set. |
Space, Enter | Activates the focused tab. |
Labeling
To make the Tabs component suite accessible to assistive technology, label the <Tabs.List />
element with aria-label
.
API Reference
TabsRoot
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
defaultValue | any | The default value. Use when the component is not controlled. | |
direction | enum | 'ltr' | The direction of the text. |
onValueChange | func | Callback invoked when new value is being set. | |
orientation | enum | 'horizontal' | The component orientation (layout flow direction). |
render | union | A function to customize rendering of the component. | |
value | any | The value of the currently selected Tab . If you don't want any selected Tab , you can set this prop to null . |
TabPanel
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
keepMounted | bool | false | If true , keeps the contents of the hidden TabPanel in the DOM. |
render | union | A function to customize rendering of the component. | |
value | any | The value of the TabPanel. It will be shown when the Tab with the corresponding value is selected. If not provided, it will fall back to the index of the panel. It is recommended to explicitly provide it, as it's required for the tab panel to be rendered on the server. |
Tab
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
render | union | A function to customize rendering of the component. | |
value | any | You can provide your own value. Otherwise, it falls back to the child position index. |
TabsList
Prop | Type | Default | Description |
---|---|---|---|
activateOnFocus | bool | true | If true , the tab will be activated whenever it is focused. Otherwise, it has to be activated by clicking or pressing the Enter or Space key. |
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
loop | bool | true | If true , using keyboard navigation will wrap focus to the other end of the list once the end is reached. |
render | union | A function to customize rendering of the component. |
TabIndicator
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
render | union | A function to customize rendering of the component. | |
renderBeforeHydration | bool | false | If true , the indicator will include code to render itself before React hydrates. This will minimize the time the indicator is not visible after the SSR-generated content is downloaded. |