building a tabbed box

one box, several tabs, click one and only that tab's content shows. includes a sidebar variant for a journal-style layout.

beginner css javascript
a tabbed box is a single container with a row of buttons along the top (the tabs), and only one section of content showing at a time, whichever tab is currently selected. you'll see this everywhere: about pages with "bio / interests / favorites" tabs, faq boxes, settings panels. this tutorial builds one with five tabs from scratch, using plain css for the look and a small bit of vanilla javascript to handle the switching.

what we're building

a box with five clickable tabs across the top. clicking a tab highlights it and swaps the content below to match, while every other tab's content stays hidden:

live example: click through the tabs below.

hii! this is the about tab. any html can go in here, text, images, whatever you like.
this is the favorites tab, completely separate content from the about tab.
a friends list could go here, little icons linking to other sites.
an embedded guestbook, or a link to one, fits nicely in its own tab too.
credits for whoever made your layout, fonts, and graphics.

step 1 — the html structure

a tabbed box is really just two lists sitting next to each other: a row of buttons, and a stack of content panels. the trick that ties them together is a matching data-tab / data-panel value on each pair:

<div class="tabbox" id="my-tabbox">

  <div class="tabbox-nav">
    <button class="active" data-tab="about">about</button>
    <button data-tab="favorites">favorites</button>
    <button data-tab="friends">friends</button>
    <button data-tab="guestbook">guestbook</button>
    <button data-tab="credits">credits</button>
  </div>

  <div class="tabbox-panel active" data-panel="about">
    about content goes here
  </div>
  <div class="tabbox-panel" data-panel="favorites">
    favorites content goes here
  </div>
  <div class="tabbox-panel" data-panel="friends">
    friends content goes here
  </div>
  <div class="tabbox-panel" data-panel="guestbook">
    guestbook content goes here
  </div>
  <div class="tabbox-panel" data-panel="credits">
    credits content goes here
  </div>

</div>
  • data-tab="about" and data-panel="about": these are custom data attributes, plain text labels you make up yourself, they don't do anything on their own. what matters is that each button's data-tab value exactly matches one panel's data-panel value. that matching pair is how the javascript in step 3 knows which panel belongs to which button
  • class="active" on the first button and its matching panel: one tab needs to start visible by default, otherwise the box opens completely empty. always put active on exactly one button and its matching panel when the page loads
  • this tutorial uses five buttons and five panels, but this same structure works with any number! three, six, ten. nothing about the html or the code in the next steps is hardcoded to a specific count
tip: the panels don't have to be plain text like the demo above. each .tabbox-panel is a regular <div>, so it can contain images, lists, forms, embedded widgets, anything you'd normally put in a div.

step 2 — styling the tabs and panels

css handles two jobs here: making the tab row look like tabs, and hiding every panel except the active one.

.tabbox-nav {
  display: flex;
  border-bottom: 1px solid #e7d0dd;
}

.tabbox-nav button {
  flex: 1;
  padding: 10px 8px;
  background: transparent;
  border: none;
  border-right: 1px solid #e7d0dd;
  font-family: inherit;
  font-size: 12px;
  color: #a88b98;
  cursor: pointer;
  transition: background 200ms ease, color 200ms ease;
}

.tabbox-nav button.active {
  background: white;
  color: #616161;
  font-weight: bold;
}

.tabbox-panel {
  display: none;
  padding: 16px;
}

.tabbox-panel.active {
  display: block;
}
  • display: flex on .tabbox-nav: lines all the buttons up in a single horizontal row instead of stacking them
  • flex: 1 on each button: makes every button stretch to take up an equal share of the row's width, so five tabs each take up exactly one fifth of the space, regardless of how long each tab's label is
  • border: none + a custom font-family/color: buttons come with a lot of default browser styling (grey background, 3d-looking border), this strips all of that away so it can look however you want instead of like a native os button
  • .tabbox-nav button.active: this is a css selector matching only a button that has both the tabbox-nav button part and the extra active class at the same time. writing two class names back to back with no space between them (button.active) means "an element that is both", not "either one or the other"
  • .tabbox-panel { display: none; }: hides every single panel by default, this is the resting state before javascript touches anything
  • .tabbox-panel.active { display: block; }: overrides that hidden state, but only for whichever one panel currently has the active class. since css always applies the more specific matching rule, a panel with both classes gets shown, every other panel stays hidden

step 3 — the javascript that switches tabs

this is the only part that isn't css. all it needs to do, every time a tab button is clicked, is: remove active from whichever button/panel currently has it, then add active to the one that was just clicked and its matching panel.

const tabbox = document.getElementById("my-tabbox");
const tabButtons = tabbox.querySelectorAll(".tabbox-nav button");
const tabPanels = tabbox.querySelectorAll(".tabbox-panel");

tabButtons.forEach(function (button) {
  button.addEventListener("click", function () {

    tabButtons.forEach(function (btn) {
      btn.classList.remove("active");
    });
    tabPanels.forEach(function (panel) {
      panel.classList.remove("active");
    });

    button.classList.add("active");
    const targetName = button.dataset.tab;
    const targetPanel = tabbox.querySelector('.tabbox-panel[data-panel="' + targetName + '"]');
    targetPanel.classList.add("active");

  });
});

walking through it top to bottom:

  • tabbox.querySelectorAll(".tabbox-nav button"): grabs every tab button inside this specific tabbox, and returns them as a list you can loop over. scoping the search to tabbox (instead of searching the whole page) matters if you ever have more than one tabbed box on the same page, each box's buttons only ever look inside their own box
  • tabButtons.forEach(function (button) { ... }): runs the code inside once for every button in that list, attaching a click listener to each one individually
  • inside the click handler, the first two forEach loops strip the active class off every button and every panel. this "clear everything first" step is what guarantees only one tab is ever active at a time, it's much simpler than trying to figure out which specific one needs to be turned off
  • button.classList.add("active"): re-adds active, but only to the button that was actually clicked, this is the one this-specific-button reference, not the whole list
  • button.dataset.tab: reads the clicked button's data-tab attribute as plain text. a data-tab="favorites" attribute becomes the string "favorites" here
  • tabbox.querySelector('.tabbox-panel[data-panel="' + targetName + '"]'): this is the matching step. it searches for a panel whose data-panel attribute equals that same text, which is exactly why the values had to match in step 1. found panel gets the active class added, making it visible
heads up: this script needs to run after the tabbox's html already exists on the page, otherwise document.getElementById("my-tabbox") will return null and every line after it will throw an error. either place the <script> tag right before </body> (after all your html), or wrap it in a DOMContentLoaded listener if it needs to live in the <head>.

step 4 — making it reusable for more than one tabbox

the script in step 3 only sets up one specific box, the one with id="my-tabbox". if your page has several tabbed boxes, you don't want to copy-paste that whole block once per box. instead, wrap it in a function and run that function once for every tabbox on the page:

function initTabbox(tabbox) {
  const tabButtons = tabbox.querySelectorAll(".tabbox-nav button");
  const tabPanels = tabbox.querySelectorAll(".tabbox-panel");

  tabButtons.forEach(function (button) {
    button.addEventListener("click", function () {
      tabButtons.forEach(function (btn) { btn.classList.remove("active"); });
      tabPanels.forEach(function (panel) { panel.classList.remove("active"); });

      button.classList.add("active");
      const targetName = button.dataset.tab;
      tabbox.querySelector('.tabbox-panel[data-panel="' + targetName + '"]').classList.add("active");
    });
  });
}

document.querySelectorAll(".tabbox").forEach(initTabbox);
  • function initTabbox(tabbox) { ... }: the exact same logic as step 3, just wrapped so it can be repeated. the word tabbox here is a parameter, a placeholder that stands in for "whichever box this function is currently being run on"
  • document.querySelectorAll(".tabbox"): finds every element on the page with the class tabbox, regardless of how many there are
  • .forEach(initTabbox): runs the initTabbox function once per box found, automatically passing each one in as the tabbox parameter. this one line replaces having to manually call the function separately for every single box on the page

with this version, adding a second tabbed box anywhere on the page is purely an html change, copy the whole .tabbox block from step 1, give it a different set of data-tab/data-panel values, and it'll work automatically without touching the script at all.

step 5 (bonus) — moving the tabs to the side

the exact same html and javascript from steps 1 through 4 can produce a sidebar-style layout instead, tabs running down the left edge, content filling the rest, like a little journal with a tab index down the spine. nothing about the structure changes, only the css.

live example: same tabbox logic, laid out sideways.

here's every css change needed, laid out as a diff from the horizontal version in step 2:

.tabbox {
  display: flex;
  align-items: stretch;
}

.tabbox-nav {
  flex-direction: column;
  flex-shrink: 0;
  width: 130px;
  border-bottom: none;
  border-right: 1px solid #e7d0dd;
}

.tabbox-nav button {
  flex: none;
  text-align: left;
  border-right: none;
  border-bottom: 1px solid #e7d0dd;
}

.tabbox-panel {
  flex: 1;
}
  • .tabbox { display: flex; }: this is the change that actually rotates the whole layout. the outer box itself becomes a flex container, laying its two direct children (the nav, and the stack of panels) side by side horizontally instead of stacked on top of each other. align-items: stretch (the default, but written here for clarity) makes the shorter of the two, usually the nav, stretch to match the taller one's full height, so the tab list's border runs the whole height of the box instead of stopping short
  • .tabbox-nav { flex-direction: column; }: this is the one line doing the real work of turning the tab row into a tab column. it flips the nav's own internal layout from horizontal back to vertical, so the buttons inside it stack downward instead of sitting side by side
  • flex-shrink: 0 + a fixed width: 130px on the nav: without this, a flex container's children can shrink to fit available space, which could squeeze your tab labels uncomfortably narrow. giving the nav a fixed width and telling it not to shrink keeps it a consistent, readable size no matter how much content is in the active panel
  • border-bottom: none + border-right: 1px solid on the nav: swaps which side gets the dividing line, previously the nav had a line underneath separating it from the content below, now it needs a line on its right separating it from the content beside it instead
  • flex: none on each button, replacing the earlier flex: 1: in the horizontal version, flex: 1 made every button share the row's width equally. stacked vertically, you don't want every button forced to an equal height the same way, so this switches each button back to its natural, content-sized height instead
  • text-align: left on the buttons: horizontal tabs usually look fine centered, but a vertical list of labels reads much more naturally left-aligned, like a real menu or index
  • border-right: none + border-bottom: 1px solid on each button: the individual buttons also need their divider line rotated, from a vertical line between side-by-side buttons, to a horizontal line between stacked ones
  • .tabbox-panel { flex: 1; }: makes the active content panel expand to fill all the remaining width next to the now-narrower sidebar, instead of staying its own natural content width
the important part: notice that steps 1, 3, and 4 (the html and every line of javascript) are completely untouched here. the tab-switching logic doesn't know or care whether the tabs are arranged in a row or a column, it's only ever adding and removing an active class. that's the real benefit of keeping structure (html), behavior (javascript), and appearance (css) separated: you can redesign how something looks without ever having to think about how it works.

result

a fully working tabbed box: five buttons, five panels, one active at a time, built from three simple pieces working together, matching data- attributes to link buttons to panels, css to hide everything but the active panel, and a small script to move the active class around on click. the sidebar variant proves the same three pieces can produce a very different look, a journal-style index down the side, with zero changes to the html or javascript. this exact pattern (click a button, toggle a class, let css handle the visibility) is the same one behind dropdown menus, accordions, and modal popups too, so it's well worth understanding thoroughly.