3 Proven Ways to Create JS Tabs That Actually Work (2025)
Tired of broken JS tabs? Learn 3 proven, modern methods for 2025: Vanilla JS, Web Components, and the crucial WAI-ARIA approach for full accessibility.
Elena Petrova
Senior front-end developer focused on creating accessible and performant user interfaces.
Tabs. They seem so simple, right? A few buttons, a few content panels. Click one, show the other. What could be easier? Yet, if you’ve spent any time on the web, you’ve seen it: tabs that break on mobile, tabs a screen reader can’t understand, or tabs held together with a spaghetti-mess of jQuery from 2012.
It’s 2025. We can do better. Forget the outdated tutorials and brittle code. Today, we’re diving into three proven, robust, and modern ways to build JavaScript tabs that actually work—for everyone.
The Foundation: Vanilla JS with Data Attributes
Before reaching for a framework or library, every developer should know how to build core UI components with pure, unadulterated JavaScript. This method is lightweight, fast, and gives you a rock-solid understanding of the mechanics. The secret sauce? Event delegation and data attributes.
Instead of adding a separate event listener to every single tab button, we add just one to their common parent. This is more performant and scales effortlessly. Data attributes then provide a clean, semantic link between each button and its corresponding panel.
The HTML Structure
First, we need clean, semantic HTML. Notice how we use a `ul` for the tab controls, as they are fundamentally a list of navigation items for the content panels.
<div class="tabs-container"> <ul class="tab-list" role="tablist"> <li><button class="tab-control is-active" data-tab-target="#panel-1">Section 1</button></li> <li><button class="tab-control" data-tab-target="#panel-2">Section 2</button></li> <li><button class="tab-control" data-tab-target="#panel-3">Section 3</button></li> </ul> <div class="tab-panels"> <div class="tab-panel is-active" id="panel-1"> <h3>Content for Section 1</h3> <p>This is the first panel's content. It's currently visible.</p> </div> <div class="tab-panel" id="panel-2"> <h3>Content for Section 2</h3> <p>Some interesting content for the second section lives here.</p> </div> <div class="tab-panel" id="panel-3"> <h3>Content for Section 3</h3> <p>And here is the final piece of content for our tabs.</p> </div> </div> </div>
The JavaScript Logic
The JS is surprisingly concise. We listen for clicks on the `tab-list`, identify the clicked button, and then toggle classes on the appropriate elements.
const tabsContainer = document.querySelector('.tabs-container'); const tabList = tabsContainer.querySelector('.tab-list'); const tabControls = tabList.querySelectorAll('.tab-control'); const tabPanels = tabsContainer.querySelectorAll('.tab-panel'); tabList.addEventListener('click', (e) => { const clickedTab = e.target.closest('.tab-control'); if (!clickedTab) return; // Ignore clicks that aren't on a tab button e.preventDefault(); // Switch Tabs const targetPanelId = clickedTab.dataset.tabTarget; const targetPanel = tabsContainer.querySelector(targetPanelId); // Deactivate all tabs and panels tabControls.forEach(control => control.classList.remove('is-active')); tabPanels.forEach(panel => panel.classList.remove('is-active')); // Activate the clicked tab and its corresponding panel clickedTab.classList.add('is-active'); targetPanel.classList.add('is-active'); });
Why this works so well: It’s minimal, has no external dependencies, and is incredibly fast. It's the perfect approach for smaller projects or when you want full control without any overhead.
The Modern Approach: Encapsulation with Web Components
What if you need to use the same tab component in ten different places across your application? Copying and pasting the HTML and initializing the script each time is messy. This is where Web Components shine. They let you create your own custom, reusable HTML elements with their own logic and styling encapsulated inside.
Let's create a `
Defining the `` Element
This involves a bit more setup, but the reusability is worth it. We define a class that extends `HTMLElement` and handles all the logic internally using the Shadow DOM. The Shadow DOM is key—it keeps the component's styles and scripts from leaking out or clashing with other parts of your page.
class TabGroup extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { const tabs = Array.from(this.querySelectorAll('[slot="tab"]')); const panels = Array.from(this.querySelectorAll('[slot="panel"]')); this.shadowRoot.innerHTML = ` <style> .tab-list { display: flex; list-style: none; padding: 0; margin: 0; border-bottom: 1px solid #ccc; } .tab-control { padding: 1rem; border: none; background: #f0f0f0; cursor: pointer; } .tab-control.active { background: #fff; border-bottom: 2px solid blue; } .panel { display: none; padding: 1rem; } .panel.active { display: block; } </style> <div> <ul class="tab-list"> ${tabs.map(tab => `<li><button class="tab-control">${tab.textContent}</button></li>`).join('')} </ul> <div class="panels"> <slot name="panel"></slot> </div> </div> `; const tabControls = this.shadowRoot.querySelectorAll('.tab-control'); tabControls.forEach((control, index) => { if (index === 0) { control.classList.add('active'); panels[index].classList.add('active'); } control.addEventListener('click', () => { this.shadowRoot.querySelector('.tab-control.active').classList.remove('active'); this.querySelector('.panel.active').classList.remove('active'); control.classList.add('active'); panels[index].classList.add('active'); }); }); } } customElements.define('tab-group', TabGroup);
How to Use It in Your HTML
Once that JavaScript is on your page, using the component is beautifully simple. We use `slot` attributes to tell the component where our content should go.
<tab-group> <!-- These are used for the button text --> <span slot="tab">Profile</span> <span slot="tab">Settings</span> <span slot="tab">Billing</span> <!-- These are the actual content panels --> <div slot="panel" class="panel">User Profile Content</div> <div slot="panel" class="panel">Account Settings Form</div> <div slot="panel" class="panel">Billing History</div> </tab-group>
This approach is perfect for design systems and large-scale applications where consistency and reusability are paramount.
The Professional Standard: Building with WAI-ARIA
Here’s the part many tutorials skip, and it’s arguably the most important. A tab interface that a sighted user can operate but a screen reader user cannot is a broken interface. The WAI-ARIA (Web Accessibility Initiative – Accessible Rich Internet Applications) specification gives us the tools to make our tabs understandable to assistive technologies.
This isn't a separate method, but rather a crucial layer to add on top of your chosen implementation (like our Vanilla JS example).
Key ARIA Attributes for Tabs
Integrating ARIA involves adding specific attributes to our existing HTML. Here's what they do:
role="tablist"
: Tells a screen reader, "This is a container for a set of tabs." (Goes on the `ul` or tab container).role="tab"
: Identifies an element as a tab control. (Goes on each `button`).role="tabpanel"
: Identifies an element as a panel of content associated with a tab.aria-selected="true/false"
: Explicitly tells the screen reader which tab is currently active. This is vital.aria-controls="panel-id"
: Creates a programmatic link between the tab button and the panel it controls.tabindex="0"
/tabindex="-1"
: Manages keyboard focus. The active tab should have `tabindex="0"` to be in the page's focus order, while inactive tabs should have `tabindex="-1"` to be removed from it.
The Accessible JavaScript Update
Let's upgrade our first vanilla JS example. The HTML gets the new roles and attributes, and the JavaScript is updated to manage them on each click.
// ... (inside the 'click' event listener from the first example) // Deactivate all tabs and panels tabControls.forEach(control => { control.classList.remove('is-active'); control.setAttribute('aria-selected', 'false'); control.setAttribute('tabindex', '-1'); }); tabPanels.forEach(panel => { panel.classList.remove('is-active'); }); // Activate the clicked tab and its corresponding panel clickedTab.classList.add('is-active'); clickedTab.setAttribute('aria-selected', 'true'); clickedTab.setAttribute('tabindex', '0'); targetPanel.classList.add('is-active'); // Optional but recommended: move focus to the new tab clickedTab.focus();
This small addition makes a world of difference. Now, a screen reader user can navigate the tabs, understand which one is selected, and know what content it relates to. This is the hallmark of professional front-end development.
So, Which Method Should You Choose?
Choosing the right method depends on your project's context, but here’s a quick guide:
Method | Best For | Key Advantage |
---|---|---|
Vanilla JS + Data Attributes | Small projects, prototypes, learning fundamentals. | Lightweight and zero dependencies. |
Web Components | Large apps, design systems, component libraries. | True encapsulation and reusability. |
WAI-ARIA Integration | All projects. This is non-negotiable. | Makes your UI usable by everyone. |
The ultimate professional approach is to combine these ideas: build an accessible-from-the-start tab component (whether with vanilla JS or as a Web Component) that uses all the correct ARIA roles and attributes.
Building robust UI components like tabs is a foundational skill. By moving beyond the basics and incorporating scalability and accessibility, you’re not just writing code that works—you’re crafting experiences that are inclusive, maintainable, and truly professional. Happy coding!