A reusable tabs component: click a button, show the matching panel, hide the rest. Structure first, then a horizontal version and a vertical one — same JS both times, only the CSS changes.
Pseudocode
Structure
A wrapper (.tabs) holds two lists: .tabs-buttons (one <li><button> per tab) and .tabs-panels (one <li class="tab-panel"> per tab).
Every button and its matching panel share a data-tab value, so they’re paired by id rather than by position — reordering either list doesn’t break anything.
Only one panel is visible at a time; the buttons stay put, only the panels swap.
Style
Inactive panels are just display: none; the active one is display: block. No animation to start with.
The active button gets a visual marker (an underline, a colored border, a background) so the current tab is always obvious.
Horizontal tabs put .tabs-buttons in a row above the panels; vertical tabs put .tabs itself in a row, with .tabs-buttons as a column beside the panels. That’s the only real difference between the two.
Features
activate(id) toggles the active class on whichever button and panel match that id, and removes it from every other one.
Clicking a button just calls activate with its own data-tab value.
Bonus (not built here): keyboard support — arrow keys moving focus between tab buttons, following the ARIA tablist/tab/tabpanel pattern.
Basic static tabs
Buttons and panels are matched by data-tab, not by their position in the list. Switching tabs is instant — just toggling display between none and block, no transition involved.
<div class="tabs"> <ul class="tabs-buttons"> <li><button class="tab-btn active" data-tab="description">Description</button></li> <li><button class="tab-btn" data-tab="specs">Specs</button></li> <li><button class="tab-btn" data-tab="reviews">Reviews</button></li> </ul> <ul class="tabs-panels"> <li class="tab-panel active" data-tab="description"> <p>A lightweight, breathable jacket built for shoulder-season hikes — packs down to the size of a water bottle.</p> </li> <li class="tab-panel" data-tab="specs"> <ul> <li>Weight: 240g</li> <li>Material: recycled ripstop nylon</li> <li>Sizes: XS–XXL</li> </ul> </li> <li class="tab-panel" data-tab="reviews"> <p>"Kept me dry through a surprise storm on the ridge." — 4.7★ average, 128 reviews</p> </li> </ul></div>
Same JS as above, completely unchanged — only the CSS moves things around: .tabs becomes a flex row, .tabs-buttons becomes a column, and the active indicator moves from a bottom border to a left border. Layout direction is a styling decision, not a structural one.
<div class="tabs"> <ul class="tabs-buttons"> <li><button class="tab-btn active" data-tab="description">Description</button></li> <li><button class="tab-btn" data-tab="specs">Specs</button></li> <li><button class="tab-btn" data-tab="reviews">Reviews</button></li> </ul> <ul class="tabs-panels"> <li class="tab-panel active" data-tab="description"> <p>A lightweight, breathable jacket built for shoulder-season hikes — packs down to the size of a water bottle.</p> </li> <li class="tab-panel" data-tab="specs"> <ul> <li>Weight: 240g</li> <li>Material: recycled ripstop nylon</li> <li>Sizes: XS–XXL</li> </ul> </li> <li class="tab-panel" data-tab="reviews"> <p>"Kept me dry through a surprise storm on the ridge." — 4.7★ average, 128 reviews</p> </li> </ul></div>
CSS can’t transition height: auto — there’s no fixed start value to animate from. The trick is to measure the incoming panel’s real height in JS (scrollHeight) and animate the container between explicit pixel values instead.
The panel itself still swaps instantly (display: none has no transition), but the panel’s own padding has to live on .tab-panel, not on the outer .tabs-panels — scrollHeight only measures the element it’s read from, so if the padding were on the container instead, the measured height would come up short and the content would get clipped.
<div class="tabs"> <ul class="tabs-buttons"> <li><button class="tab-btn active" data-tab="description">Description</button></li> <li><button class="tab-btn" data-tab="specs">Specs</button></li> <li><button class="tab-btn" data-tab="reviews">Reviews</button></li> </ul> <ul class="tabs-panels"> <li class="tab-panel active" data-tab="description"> <p>A lightweight, breathable jacket built for shoulder-season hikes — packs down to the size of a water bottle.</p> </li> <li class="tab-panel" data-tab="specs"> <ul> <li>Weight: 240g</li> <li>Material: recycled ripstop nylon</li> <li>Sizes: XS–XXL</li> <li>Colors: Slate, Moss, Rust</li> <li>Warranty: 2 years</li> <li>Care: machine wash cold, hang dry</li> </ul> </li> <li class="tab-panel" data-tab="reviews"> <p>"Kept me dry through a surprise storm on the ridge." — 4.7★</p> <p>"Packs so small it lives in my bag permanently now." — 5★</p> <p>"Runs a little large, sized down and it's perfect." — 4★</p> </li> </ul></div>
const tabsEl = document.querySelector(".tabs");if (tabsEl) { const buttons = tabsEl.querySelectorAll(".tab-btn"); const panels = tabsEl.querySelectorAll(".tab-panel"); const panelsEl = tabsEl.querySelector(".tabs-panels"); const activate = (id) => { buttons.forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === id)); panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.tab === id)); // the new panel is already `display: block` at this point — measure its real height... const activePanel = tabsEl.querySelector(".tab-panel.active"); // ...and animate the container to it. Old height -> new height, transitioned by CSS. panelsEl.style.height = `${activePanel.scrollHeight}px`; }; // lock in a starting height in px first — transitioning FROM "auto" doesn't animate panelsEl.style.height = `${tabsEl.querySelector(".tab-panel.active").scrollHeight}px`; buttons.forEach((btn) => { btn.onclick = () => activate(btn.dataset.tab); });}
Not built here: the pinned pixel height goes stale if the window resizes and the active panel’s content reflows to a different height — a real component would re-measure on resize (or with a ResizeObserver) rather than trusting the value from the last activate() call.