Let's make responsive tab navigation with React. On a desktop, you can go between views using the keyboard, and on mobile, it will have an invisible horizontal scroll with automatic centering of the selected item.
The component receives an array of views, a function to get view name, active view, select handler, and group name for keyboard navigation. As a container, we use a horizontal stack with overflow x auto and styles to hide the scrollbar.
import { HStack } from "lib/ui/Stack"
import { hideScrollbarsCSS } from "lib/ui/utils/hideScrollbarsCSS"
import styled from "styled-components"
import { TabNavigationItem } from "./TabNavigationItem"
interface TabNavigationProps<T extends string | number | symbol> {
views: readonly T[]
getViewName: (view: T) => string
activeView: T
onSelect: (option: T) => void
groupName: string
}
const Container = styled(HStack)`
gap: 4px;
position: relative;
overflow-x: auto;
${hideScrollbarsCSS};
`
export function TabNavigation<T extends string | number | symbol>({
views,
getViewName,
activeView,
onSelect,
groupName,
}: TabNavigationProps<T>) {
return (
<Container>
{views.map((view) => {
const name = getViewName(view)
return (
<TabNavigationItem
groupName={groupName}
isSelected={view === activeView}
value={name}
onSelect={() => onSelect(view)}
key={name}
>
{name}
</TabNavigationItem>
)
})}
</Container>
)
}
Then we go over every view and display TabNavigationItem
. It has the same props as InvisibleHTMLRadio
, with children and an optional class name. It renders a container without background when the view isn't active. Since we have an invisible radio inside, we can also change value with keys. To center the selected view, we have the useEffect
hook that calls scrollIntoView
on the ref
.
import { ReactNode, useEffect, useRef } from "react"
import styled, { css } from "styled-components"
import { centerContentCSS } from "lib/ui/utils/centerContentCSS"
import { roundedCSS } from "lib/ui/utils/roundedCSS"
import {
InvisibleHTMLRadio,
InvisibleHTMLRadioProps,
} from "../inputs/InvisibleHTMLRadio"
import { defaultTransitionCSS } from "../animations/transitions"
const Container = styled.label<{ isSelected: boolean }>`
cursor: pointer;
${roundedCSS}
padding: 0 16px;
text-decoration: none;
${centerContentCSS};
font-weight: 500;
height: 48px;
user-select: none;
color: ${({ theme, isSelected }) =>
(isSelected
? theme.colors.text
: theme.colors.textSupporting
).toCssValue()};
${defaultTransitionCSS}
&:hover {
background: ${({ theme }) => theme.colors.backgroundGlass.toCssValue()};
}
${({ isSelected, theme }) =>
isSelected &&
css`
background: ${theme.colors.backgroundGlass2.toCssValue()};
`};
`
interface Props extends InvisibleHTMLRadioProps {
children: ReactNode
className?: string
}
export const TabNavigationItem = ({
isSelected,
children,
className,
...rest
}: Props) => {
const ref = useRef<HTMLLabelElement>(null)
useEffect(() => {
if (isSelected) {
ref.current?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
})
}
}, [isSelected])
return (
<Container
ref={ref}
className={className}
tabIndex={-1}
isSelected={isSelected}
>
{children}
<InvisibleHTMLRadio isSelected={isSelected} {...rest} />
</Container>
)
}