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.
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"anddata-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'sdata-tabvalue exactly matches one panel'sdata-panelvalue. that matching pair is how the javascript in step 3 knows which panel belongs to which buttonclass="active"on the first button and its matching panel: one tab needs to start visible by default, otherwise the box opens completely empty. always putactiveon 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
.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: flexon.tabbox-nav: lines all the buttons up in a single horizontal row instead of stacking themflex: 1on 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 isborder: none+ a customfont-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 thetabbox-nav buttonpart and the extraactiveclass 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 theactiveclass. 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 totabbox(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 boxtabButtons.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
forEachloops strip theactiveclass 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-addsactive, but only to the button that was actually clicked, this is the onethis-specific-buttonreference, not the whole listbutton.dataset.tab: reads the clicked button'sdata-tabattribute as plain text. adata-tab="favorites"attribute becomes the string"favorites"heretabbox.querySelector('.tabbox-panel[data-panel="' + targetName + '"]'): this is the matching step. it searches for a panel whosedata-panelattribute equals that same text, which is exactly why the values had to match in step 1. found panel gets theactiveclass added, making it visible
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 wordtabboxhere 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 classtabbox, regardless of how many there are.forEach(initTabbox): runs theinitTabboxfunction once per box found, automatically passing each one in as thetabboxparameter. 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 sideflex-shrink: 0+ a fixedwidth: 130pxon 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 panelborder-bottom: none+border-right: 1px solidon 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 insteadflex: noneon each button, replacing the earlierflex: 1: in the horizontal version,flex: 1made 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 insteadtext-align: lefton 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 indexborder-right: none+border-bottom: 1px solidon 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
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.