Tabs

Tabs organize groups of related content and let users navigate between them.

Give FeedbackWAI-ARIABundle Size
My account page
'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>
  <Tabs.List>
    <Tabs.Tab>One</Tabs.Tab>
    <Tabs.Tab>Two</Tabs.Tab>
    <Tabs.Indicator />
  </Tabs.List>
  <Tabs.Panel>First page</Tabs.Panel>
  <Tabs.Panel>Second page</Tabs.Panel>
</Tabs.Root>

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.

<Tabs.Root defaultValue={1}>
  <Tabs.List>
    <Tabs.Tab value={1}>One</Tabs.Tab>
    <Tabs.Tab value={2}>Two</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value={1}>First page</Tabs.Panel>
  <Tabs.Panel value={2}>Second page</Tabs.Panel>
</Tabs.Root>

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.

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:

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.

<Tabs.Indicator renderBeforeHydration />

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.

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.

Current route: /drafts

InboxDraftsTrash
'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.

<Tabs.List activateOnFocus={false} />

Overriding default components

Use the render prop to override the rendered element:

<Tabs.Tab render={<MyCustomTab />} />
// or
<Tabs.Tab render={(props) => <MyCustomTab {...props} />} />

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

KeyDescription
Left ArrowMoves focus to the previous tab (when orientation="horizontal") and activates it if activateOnFocus is set.
Right ArrowMoves focus to the next tab (when orientation="horizontal") and activates it if activateOnFocus is set.
Up ArrowMoves focus to the previous tab (when orientation="vertical") and activates it if activateOnFocus is set.
Down ArrowMoves focus to the next tab (when orientation="vertical") and activates it if activateOnFocus is set.
Space, EnterActivates the focused tab.

Labeling

To make the Tabs component suite accessible to assistive technology, label the <Tabs.List /> element with aria-label.

<Tabs>
  <Tabs.List aria-label="Seasons">
    <Tabs.Tab>Spring</Tabs.Tab>
    <Tabs.Tab>Summer</Tabs.Tab>
    <Tabs.Tab>Fall</Tabs.Tab>
    <Tabs.Tab>Winter</Tabs.Tab>
  </Tabs.List>
</Tabs>

API Reference

TabsRoot

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
defaultValueanyThe default value. Use when the component is not controlled.
directionenum'ltr'The direction of the text.
onValueChangefuncCallback invoked when new value is being set.
orientationenum'horizontal'The component orientation (layout flow direction).
renderunionA function to customize rendering of the component.
valueanyThe value of the currently selected Tab. If you don't want any selected Tab, you can set this prop to null.

TabPanel

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
keepMountedboolfalseIf true, keeps the contents of the hidden TabPanel in the DOM.
renderunionA function to customize rendering of the component.
valueanyThe 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

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.
valueanyYou can provide your own value. Otherwise, it falls back to the child position index.

TabsList

PropTypeDefaultDescription
activateOnFocusbooltrueIf 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.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
loopbooltrueIf true, using keyboard navigation will wrap focus to the other end of the list once the end is reached.
renderunionA function to customize rendering of the component.

TabIndicator

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.
renderBeforeHydrationboolfalseIf 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.

Contents