No Time Dad

A blog about web development written by a busy dad

Basic Tabs with React

Intro

When I’ve needed tabs I’ve always reached for bootstrap, material, or whatever web framework I was using at the time. I didn’t think twice about it. I assumed tabs were complicated and I should use the implementation the web framework provided me with because their implementation is “the right way”.

All of this is probably true, but I thought it would be fun to try out my own implementation anyways. I know it takes more time, but it feels good to write the code and come up with my own version of the common components like tabs that most web frameworks provide. If nothing else, I get insight into the internals of how their implementations work.

Despite my best efforts, I don’t think you can build a tabs component without JavaScript. I went down the road of using the :active pseudo-class and toggling display: none; but that really only worked on the initial click. The straightforward solution I found was to track the active tab in state and show the component it mapped to. All of this required JavaScript, which is why this example uses React.

Scaffolding

I started by deciding on the basic structure of my tabs component. I think the best structure is a wrapper div with two child divs. One child div for the content that’ll be switched when a new tab is clicked, and another child div for the tabs themselves. Most of the tab implementations I’ve seen have the tabs on top, so I thought it might be fun to put them on the bottom instead.

I’ll use flexbox in the tabs container for alignment purposes. To get the tabs at the very bottom of the container I’ll use the margin-top: auto; trick. Keeping in mind that flexbox defaults to flex-direction: row;, and that I want the child elements stacked, I need to change the flex-direction to column.

I initially thought the tab elements themselves should just be divs, but the problem I quickly found with that is you cannot press the tab button on your keyboard to reach divs. Anyone not using a mouse wouldn’t be able to select a tab, and that is a problem. Using a button element for each tab is likely the easiest and most accessible solution.

const TabsElement = () => {
  return (
    <div className={styles.Container}>
      <div className={styles.Content}>
        {/* Later, dynamically switch the content based active tab */}
        <Content1 />
      </div>
      <div className={styles.Tabs}>
        <button>Tab 1</button>
        <button>Tab 2</button>
        <button>Tab 3</button>
      </div>
    </div>
  )
}

// Content components to map to each tab
const Content1 = () => <div>Content 1</div>
const Content2 = () => <div>Content 2</div>
const Content3 = () => <div>Content 3</div>
.Container {
  /* Init flexbox */
  display: flex;
  /* Ensure that elements are not in a row */
  flex-direction: column;
  height: 200px;
  width: 300px;
  padding: 1rem;
  background-color: lightskyblue;
}

.Tabs {
  /* Push the tabs div to the bottom */
  margin-top: auto;
  /* Init flexbox for alignment purposes */
  display: flex;
  align-items: center;
  justify-content: center;
}
Content 1

Content switching

At the moment, clicking on any of the tabs doesn’t do anything. When the tab button is clicked, its corresponding content should be shown. I imagine there are many different ways to achieve this. My way might not the perfect way, but it works and I think it could scale easily.

I’ll need a way to track which tab is the active tab. Knowing that, I can display the active tab’s content, and apply custom styles to it. I’ll be using React hooks to track and set the state. The first tab will default to being the active tab when the page is first loaded.

I went through a few different iterations of selecting the active content based on the active tab. The first, and possibly the worst iteration, was trying to build a complex if else chain in the content section. I quickly realized this won’t scale and was tedious to maintain. My next iteration was to build a switch statement with a case for each value of the active state. This is an ok solution, but you’d need a helper method because you cannot put a switch in the component body.

Since the two solutions I came up with on my own were not great, I turned to Google. Surely someone has had this exact problem before and has come up with a decent solution. Indeed, they have. The basic idea is to create a new component that takes all three content sections (since we have three tabs) as children and filters them for the active one to display. It is essentially a switch without explicitly writing a switch.

I decided to give my components and tabs numbers to identify and map them. Maybe a string name would be better, but using numbers was easier at the time. The main thing to note about the SwitchComponent is that it accesses the child props tab value even though I didn’t explicitly declare a prop called tab on any of the content components.

const TabsElement = () => {
  const [activeTab, setActiveTab] = useState(1)
  return (
    <div className={styles.Container}>
      <div className={styles.Content}>
        {/* Pass the active tab state value to the SwitchComponent */}
        <SwitchComponent activeTab={activeTab}>
          {/* Content1 maps to tab 1 */}
          <Content1 tab={1} />
          {/* Content2 maps to tab 2 */}
          <Content2 tab={2} />
          {/* Content3 maps to tab 3 */}
          <Content3 tab={3} />
        </SwitchComponent>
      </div>
      <div className={styles.Tabs}>
        {/* Set the active tab value when the button is clicked */}
        <button onClick={() => setActiveTab(1)}>Tab 1</button>
        <button onClick={() => setActiveTab(2)}>Tab 2</button>
        <button onClick={() => setActiveTab(3)}>Tab 3</button>
      </div>
    </div>
  )
}

// Wrapper component that acts as a switch statement
const SwitchComponent = ({ activeTab, children }) =>
  // Filter for the component tab that matches the activeTab from state
  children.filter(child => child.props.tab === activeTab)

const Content1 = () => <div>Content 1</div>
const Content2 = () => <div>Content 2</div>
const Content3 = () => <div>Content 3</div>

Try clicking on the tab buttons below to see how the content changes.

Content 1

Styling the tabs

At this point, I have a working tabs compontent. Clicking on a tab will re-render its correspondingcontent. But I think there is a still a problem. The problem is that the buttons look like buttons and not tabs. It isn’t clear to the user which tab is the active tab. To fix this problem I created two new css selectors. One that changes the style of all of the tabs to look less like buttons, and one to change the style of the active tab.

The active tab style is applied by checking if a given tab is the active tab. If it is, then it is added to the className string. If it’s not, then just pass an empty string. The empty string is ignored and the default tab selector is the only one applied.

There is likely a more interesting way to apply the active tab style, but I ended up using a ternary operator. The main issue that I have with my implementation is that I had to hard-code the tab index (1, 2, or 3) twice in the button element. For some reason this feels wrong to me, but maybe I am just over thinking it.

The code below focuses on the tabs container in the TabsElement component.

<div className={styles.Tabs}>
  <button
    className={`${styles.Tab} ${activeTab === 1 ? styles.Tab__Active : ""}`}
    onClick={() => setActiveTab(1)}
  >
    Tab 1
  </button>
  <button
    className={`${styles.Tab} ${activeTab === 2 ? styles.Tab__Active : ""}`}
    onClick={() => setActiveTab(2)}
  >
    Tab 2
  </button>
  <button
    className={`${styles.Tab} ${activeTab === 3 ? styles.Tab__Active : ""}`}
    onClick={() => setActiveTab(3)}
  >
    Tab 3
  </button>
</div>
.Tab {
  padding: 0.5rem 1.5rem;
  background-color: whitesmoke;
  border: none;
}

.Tab__Active {
  border: 1px solid lightcoral;
  border-bottom: 5px solid lightcoral;
}
Content 1

Conclusion & final code

So, tabs turned out to be less difficult than I thought. I am sure there are ways I can make them better and have less hard-coded index values, but I think that as a starting point this version works well.

Final code
import React, { useState } from "react"
import * as styles from "./tabs.module.css"

const TabsElement = () => {
  const [activeTab, setActiveTab] = useState(1)
  return (
    <div className={styles.Container}>
      <div className={styles.Content}>
        <SwitchComponent activeTab={activeTab}>
          <Content1 tab={1} />
          <Content2 tab={2} />
          <Content3 tab={3} />
        </SwitchComponent>
      </div>
      <div className={styles.Tabs}>
        <button
          className={`${styles.Tab} ${
            activeTab === 1 ? styles.Tab__Active : ""
          }`}
          onClick={() => setActiveTab(1)}
        >
          Tab 1
        </button>
        <button
          className={`${styles.Tab} ${
            activeTab === 2 ? styles.Tab__Active : ""
          }`}
          onClick={() => setActiveTab(2)}
        >
          Tab 2
        </button>
        <button
          className={`${styles.Tab} ${
            activeTab === 3 ? styles.Tab__Active : ""
          }`}
          onClick={() => setActiveTab(3)}
        >
          Tab 3
        </button>
      </div>
    </div>
  )
}

const SwitchComponent = ({ activeTab, children }) =>
  children.filter(child => child.props.tab === activeTab)

const Content1 = () => <div>Content 1</div>
const Content2 = () => <div>Content 2</div>
const Content3 = () => <div>Content 3</div>
.Container {
  display: flex;
  flex-direction: column;
  height: 200px;
  width: 300px;
  padding: 1rem;
  background-color: lightskyblue;
}

.Tabs {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: center;
}

.Tab {
  padding: 0.5rem 1.5rem;
  background-color: whitesmoke;
  border: none;
}

.Tab__Active {
  border: 1px solid lightcoral;
  border-bottom: 5px solid lightcoral;
}