Text-first MUI Tabs
I love the Material-UI (MUI) library for React. Hand on heart, I'm not very good at making UIs that are attractive. So I always grab for something to paper over the cracks. MUI is awesome for that.
One of the components that I use frequently is the tabs component. However, I've found that it can be a little tricky to use in a "text-first" way, that also remains strongly typed. This post documents how to do just that!
Official example
What does the tabs component look like? Well, here's a screenshot of it in action:
It's very useful if you'd like your users to be able to switch between different views easily. The official MUI documentation provides an example of how to use the tabs component:
import * as React from 'react';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function CustomTabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
<Typography>{children}</Typography>
</Box>
)}
</div>
);
}
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
};
}
export default function BasicTabs() {
const [value, setValue] = React.useState(0);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={value}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab label="Item One" {...a11yProps(0)} />
<Tab label="Item Two" {...a11yProps(1)} />
<Tab label="Item Three" {...a11yProps(2)} />
</Tabs>
</Box>
<CustomTabPanel value={value} index={0}>
Item One
</CustomTabPanel>
<CustomTabPanel value={value} index={1}>
Item Two
</CustomTabPanel>
<CustomTabPanel value={value} index={2}>
Item Three
</CustomTabPanel>
</Box>
);
}
This example is great, but (personally) I find it a little hard to read. There's a direct relationship between the tabs and the tab panels, but it's not immediately obvious. When you see the 0
passed to a11yProps
and the 0
passed to CustomTabPanel
, it's not clear that they're related. And if the a11yProps
function call was not present, it would be even less clear.
I'd like to see the tabs and tab panels presented together in a more text-first way, that makes the relationship between tab and tab panel more apparent.
Text-first tabs
The code I'd like to see would look something like this:
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={selectedTab}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab {...customTabProps('Item One')} />
<Tab {...customTabProps('Item Two')} />
<Tab {...customTabProps('Item Three')} />
</Tabs>
</Box>
<CustomTabPanel selectedTab={selectedTab} tab="Item One">
Item One
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Two">
Item Two
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Three">
Item Three
</CustomTabPanel>
</Box>
In this code snippet, the tabs and tab panels have more of a linkage, in a text-first way. It's hopefully clear that the "Item One" Tab
and the "Item One" CustomTabPanel
are related.
In the code above, the customTabProps
function is used to generate the props for the tabs (it's an evolution of the a11yProps
function that handles accessibility props as well as all others). Meanwhile, the CustomTabPanel
component is used to render the tab panels. The selectedTab
state is used to keep track of the selected tab.
How does this work? And is it strongly typed? Let's find out.
Strongly typed tabs
Yes, it's strongly typed! We achieve this by defining a mapping of tab text to tab index:
const tabs = {
'Item One': 0,
'Item Two': 1,
'Item Three': 2,
} as const;
type TabText = keyof typeof tabs;
type TabIndex = (typeof tabs)[TabText];
So "Item One" is 0
, "Item Two" is 1
, and "Item Three" is 2
.
We then do some TypeScript magic to strongly type this. We use as const
to tell TypeScript this is an immutable object. With that done we can then extract the keys and values from the object and use them to create the derived types TabText
and TabIndex
.
TabText
is the keys of the tabs
object and TabIndex
is the values. So TabText
is "Item One" | "Item Two" | "Item Three"
and TabIndex
is 0 | 1 | 2
. If we should subsequently amend the tabs
object in our code, TypeScript will ensure that the TabText
and TabIndex
types are updated accordingly.
We can then use these types in our components:
function customTabProps(tab: TabText) {
const index = tabs[tab];
return {
id: `simple-tab-tab-${index}`,
'aria-controls': `simple-tab-tabpanel-${index}`,
label: tab,
};
}
interface CustomTabPanelProps {
children?: React.ReactNode;
tab: TabText;
selectedTab: TabIndex;
}
function CustomTabPanel(props: CustomTabPanelProps) {
const { children, selectedTab, tab, ...other } = props;
const index = tabs[tab];
return (
<div
role="tabpanel"
hidden={selectedTab !== index}
id={`simple-tab-tabpanel-${index}`}
aria-labelledby={`simple-tab-tab-${index}`}
{...other}
>
{selectedTab === index && (
<Box sx={{ p: 3 }}>
<Typography>{children}</Typography>
</Box>
)}
</div>
);
}
Then our final example code looks like this:
export default function BasicTabs() {
const [selectedTab, setSelectedTab] = React.useState<TabIndex>(
tabs['Item One'],
);
const handleChange = (event: React.SyntheticEvent, newValue: TabIndex) => {
setSelectedTab(newValue);
};
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={selectedTab}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab {...customTabProps('Item One')} />
<Tab {...customTabProps('Item Two')} />
<Tab {...customTabProps('Item Three')} />
</Tabs>
</Box>
<CustomTabPanel selectedTab={selectedTab} tab="Item One">
Item One
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Two">
Item Two
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Three">
Item Three
</CustomTabPanel>
</Box>
);
}
Note how we use our TabIndex
types to strongly type the selectedTab
state and the handleChange
function. And also how the TabText
type is used to strongly type the tab
prop in the CustomTabPanel
component and the tab
argument in the customTabProps
function. With this in place, we cannot provide invalid tab text to the customTabProps
function or the CustomTabPanel
component. TypeScript would fight us every step of the way if we tried.
Conclusion
So now we have a strongly typed, text-first way to use the MUI tabs component. We've used TypeScript to ensure that our tabs and tab panels are related in a way that is clear and easy to understand. This approach makes our code more maintainable and easier to work with. The full code is below:
import * as React from 'react';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
const tabs = {
'Item One': 0,
'Item Two': 1,
'Item Three': 2,
} as const;
type TabText = keyof typeof tabs;
type TabIndex = (typeof tabs)[TabText];
function customTabProps(tab: TabText) {
const index = tabs[tab];
return {
id: `simple-tab-tab-${index}`,
'aria-controls': `simple-tab-tabpanel-${index}`,
label: tab,
};
}
interface CustomTabPanelProps {
children?: React.ReactNode;
tab: TabText;
selectedTab: TabIndex;
}
function CustomTabPanel(props: CustomTabPanelProps) {
const { children, selectedTab, tab, ...other } = props;
const index = tabs[tab];
return (
<div
role="tabpanel"
hidden={selectedTab !== index}
id={`simple-tab-tabpanel-${index}`}
aria-labelledby={`simple-tab-tab-${index}`}
{...other}
>
{selectedTab === index && (
<Box sx={{ p: 3 }}>
<Typography>{children}</Typography>
</Box>
)}
</div>
);
}
export default function BasicTabs() {
const [selectedTab, setSelectedTab] = React.useState<TabIndex>(
tabs['Item One'],
);
const handleChange = (event: React.SyntheticEvent, newValue: TabIndex) => {
setSelectedTab(newValue);
};
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={selectedTab}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab {...customTabProps('Item One')} />
<Tab {...customTabProps('Item Two')} />
<Tab {...customTabProps('Item Three')} />
</Tabs>
</Box>
<CustomTabPanel selectedTab={selectedTab} tab="Item One">
Item One
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Two">
Item Two
</CustomTabPanel>
<CustomTabPanel selectedTab={selectedTab} tab="Item Three">
Item Three
</CustomTabPanel>
</Box>
);
}
It's certainly more complicated than the official example (and this may well be why the official example is the way it is), but it matches my preferences.
As an aside, I'd like the code even more if I had the following instead of using Tab
with customTabProps
:
<CustomTab tab="Item One" />
I avoided that in this post because it would have made the example more complicated. But I think it would be a nice improvement.