A good[ish] website
Web development blog, loads of UI and JavaScript topics
In my previous post I wrote about tabs. That post circles around the general logic and accessibility features of tabs, but the tab system presented there is very much hard-coded with one design, which might serve most purposes, but it isn’t very flexible to be used in larger environments with more complex business requirements.
Jump to the full component 👇 if you’re just looking for that.
In this post I’ll be using compound components paradigm to build a tab system that has no limitations in styling and is flexible at handling data in various forms.
The idea of compound components is to export each piece of the UI separately, then reconstruct the UI from these pieces at the target. This way the dev has a full control of all the elements, and can reshuffle and style them as they wish.
Here’s my tab system as is, without any styles it looks like nothing, but it works:
And here’s the code for it:
import { Tabs, TabList, TabPanels, Tab, TabPanel } from './CompoundTabs'
return (
<Tabs id="my-tabs">
<TabList>
<Tab>Foo</Tab>
<Tab>Bar</Tab>
<Tab>Baz</Tab>
</TabList>
<TabPanels>
<TabPanel>Foo content</TabPanel>
<TabPanel>Bar content</TabPanel>
<TabPanel>Baz content</TabPanel>
</TabPanels>
</Tabs>
)
It’s just components. If you want the tabs on the bottom, just put them in the bottom, no need to add props and change the base component.
Here’s an example of a styled version:
I’m extending the components with styled-components
, but the CSS styling layer can be anything, every component receives the className
prop.
Here’s what the component for that above example looks like:
// StyledTabs.js
import styled from 'styled-components'
import { resetButton } from '@src/utils/style-utils'
import { Tabs, TabList, Tab, TabPanel } from './CompoundTabs'
const StyledTabs = styled(Tabs)({ marginBottom: '20px',
maxWidth: '700px'
})
const StyledTabList = styled(TabList)({
display: 'flex',
gap: '1px',
listStyleType: 'none',
margin: 0
})
const StyledTab = styled(Tab)({
...resetButton, backgroundColor: 'rgb(242, 242, 235)',
borderBottom: '1px solid rgb(255, 255, 255)',
borderRadius: '18px 18px 0 0',
display: 'flex',
flex: '1 0 auto',
padding: '10px 15px',
textTransform: 'uppercase',
':focus': {
position: 'relative',
zIndex: 100
},
'&[data-selected]': { backgroundColor: 'rgb(230, 230, 230)',
borderBottom: '1px solid rgb(230, 230, 230)'
}
})
const StyledTabPanels = TabPanels
const StyledTabPanel = styled(TabPanel)({
backgroundColor: 'var(--gray-light)',
borderRadius: '0 0 18px 18px',
marginBottom: '1px',
minHeight: '300px',
padding: '15px'
})
const App = () => (
<StyledTabs id="my-tabs">
<StyledTabList>
<StyledTab>Foo</StyledTab>
<StyledTab>Bar</StyledTab>
<StyledTab>Baz</StyledTab>
</StyledTabList>
<StyledTabPanels>
<StyledTabPanel>Foo content</StyledTabPanel>
<StyledTabPanel>Bar content</StyledTabPanel>
<StyledTabPanel>Baz content</StyledTabPanel>
</StyledTabPanels>
</StyledTabs>
)
styled(Tabs)
styled-components
, could be any lib tho....resetButton
[data-selected]
This is the good stuff. It’s not black magic, but there are few interesting bits:
useDescendants
is used to solve the current index issue.What is the current index issue?
With compound components we don‘t have access to the current index, like we do with the old school comps that take their data as an array.
Forget the component approach and go with arrays, it‘s easy to pull the current index and the length if needed:
<Tabs
data={[
{ name: 'Foo', content: 'Foo content' },
{ name: 'Bar', content: 'Bar content' },
{ name: 'Baz', content: 'Baz content' }
]}
/>
But this is super inflexible, and soon enough you’ll find yourself drilling in tens of different props if you want to reorder the elements or whatever.
The other option is to use the Top-Level React.Children.map()
to loop over the children, then leverage the React.cloneElement()
to add an index into the child component without actually passing in a prop:
// Inside a parent component, TabList.js or Tabs.js
const children = React.Children.map(props.children, (child, index) => {
return React.cloneElement(child, { index })
})
Downside of this approach is that it breaks if we wrap the children in any way, for example if we’d want to make the TabList
a unordered list:
<Tabs id="tabs">
<TabList as="ul">
{/* ❌ breaks ❌ */}
<li>
<Tab>Foo</Tab>
</li>
<li>
<Tab>Bar</Tab>
</li>
</TabList>
<TabPanels>
<TabPanel>Foo content</TabPanel>
<TabPanel>Bar content</TabPanel>
</TabPanels>
</Tabs>
You could give the components the indices as props:
<Tabs id="tabs">
<TabList as="ul">
<li>
<Tab index={1}>Foo</Tab>
</li>
<li>
<Tab index={2}>Bar</Tab>
</li>
</TabList>
<TabPanels>
<TabPanel>Foo content</TabPanel>
<TabPanel>Bar content</TabPanel>
</TabPanels>
</Tabs>
That would work, but you have to remember to add the props, and props are not the ethos compound components. There is a solution tho.
There’s a package called useDescendants, and it works like below.
I forked the package, because it’s in beta and has a bug. See my fork here. This post uses the fork, I’ll make an update here when the real package is fixed.
import { Descendants, useDescendant, useDescendants } from './useDescendants'
export const TabList = ({ as: Comp, className, children }) => {
// Get the context by calling `useDescendants` const context = useDescendants()
return (
// Wrap the children in `Descendants` and pass it the context that you get // from `useDescendants` <Descendants value={context}>
<Comp className={className} role="tablist">
{children}
</Comp>
</Descendants>
)
}
export const Tab = ({ as: Comp, children }) => {
const ref = React.useRef()
// Get the index and the context (returning the context here is part of my // fork), you can also pass it any props you want and they’ll appear in // `context.map.current` const { index, context } = useDescendant({ ref })
const isActive = index === activeTabIndex
const { handleKeyboard } = React.useContext(TabContext)
return (
<Comp
aria-selected={isActive}
key={index}
// `context` and `index` are needed in the keyboard handler onKeyDown={handleKeyboard(index, context)}
ref={ref}
role="tab"
tabIndex={isActive ? 0 : -1}
{/* Bunch or other props... see the full comp below */}
>
{children}
</Comp>
)
}
Check the full code behind the fold, interesting lines highlighted.
import React from 'react'
import PropTypes from 'prop-types'
import { Descendants, useDescendant, useDescendants } from './useDescendants'
const TabContext = React.createContext()
const sharedPropTypes = {
as: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.elementType, PropTypes.array])
.isRequired,
className: PropTypes.string
}
const sharedDefaultProps = {
as: 'div'
}
export const Tab = props => {
const { as: Comp, children, className } = props
const ref = React.useRef()
const { index, context } = useDescendant({ ref }) const { handleClick, handleKeyboard, getId, activeTabIndex } =
React.useContext(TabContext)
const isActive = index === activeTabIndex
return (
<Comp
aria-controls={getId(index, 'tabpanel')}
aria-selected={isActive}
className={className}
data-selected={isActive ? '' : undefined}
href={Comp === 'a' ? `#${getId(index, 'tabpanel')}` : undefined}
id={getId(index)}
key={index}
onClick={handleClick(index)}
onKeyDown={handleKeyboard(index, context)} ref={ref}
role="tab"
tabIndex={isActive ? 0 : -1}
type={Comp === 'button' ? 'button' : undefined}
>
{children}
</Comp>
)
}
Tab.defaultProps = { as: 'button' }
Tab.propTypes = sharedPropTypes
export const TabList = ({ as: Comp, className, children }) => {
const context = useDescendants()
return (
<Descendants value={context}> <Comp className={className} role="tablist">
{children}
</Comp>
</Descendants>
)
}
TabList.defaultProps = sharedDefaultProps
TabList.propTypes = sharedPropTypes
export const TabPanels = ({ as: Comp, className, children }) => {
const context = useDescendants()
return (
<Descendants value={context}> <Comp className={className} tole="tablist">
{children}
</Comp>
</Descendants>
)
}
TabPanels.defaultProps = sharedDefaultProps
TabPanels.propTypes = sharedPropTypes
export const TabPanel = props => {
const { as: Comp, className, children } = props
const { index } = useDescendant() const { activeTabIndex, getId } = React.useContext(TabContext)
const isActive = activeTabIndex === index
return (
<Comp
aria-labelledby={getId(index)}
className={className}
id={getId(index, 'tabpanel')}
hidden={!isActive}
key={index}
tabIndex={0}
role="tabpanel"
>
{children}
</Comp>
)
}
TabPanel.defaultProps = sharedDefaultProps
TabPanel.propTypes = sharedPropTypes
export const Tabs = ({ children, id, initialIndex, className }) => {
const [activeTabIndex, setActiveTabIndex] = React.useState(initialIndex) const getId = (...rest) => [...rest, id].join('-')
const handleClick = index => event => {
event.preventDefault()
setActiveTabIndex(index)
}
const focusTab = currentTab => { setActiveTabIndex(currentTab.index)
currentTab.props.ref.current.focus() }
const handleKeyboard = (index, context) => event => { const tabCount = Object.keys(context.map.current).length - 1
const getTab = index => { const tabs = context.map.current const id = Object.keys(tabs).find(id => tabs[id].index === index) return tabs[id] }
if (event.key === 'ArrowRight') {
if (tabCount > index) return focusTab(getTab(index + 1))
return focusTab(getTab(0))
}
if (event.key === 'ArrowLeft') {
if (index > 0) return focusTab(getTab(index - 1))
return focusTab(getTab(tabCount))
}
if (event.key === 'Home') {
event.preventDefault()
return focusTab(getTab(0))
}
if (event.key === 'End') {
event.preventDefault()
return focusTab(getTab(tabCount))
}
}
return (
<TabContext.Provider value={{ activeTabIndex, handleClick, handleKeyboard, getId }} > <div className={className}>{children}</div> </TabContext.Provider> )
}
Tabs.defaultProps = { initialIndex: 0 }
Tabs.propTypes = {
...sharedPropTypes,
initialIndex: PropTypes.number
}
Was an interesting trip developing this. Compound components feels like a technological singularity when it comes to React components.
In the Reach GitHub there is a great piece on the journey of discovering the concept of descendant components. Also be sure to check Ryan Florence’s talk on compound components.
Hope this was helpful, thanks for reading.
Comments would go here, but the commenting system isn’t ready yet, sorry.