How To Make Responsive Tab Navigation with React

December 17, 2022

3 min read

How To Make Responsive Tab Navigation with React
Watch on YouTube

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>
  )
}