Tabs (Vanilla JS)
Prompt
Create a Tabs component using vanilla JavaScript, HTML, and CSS that allows users to switch between different content panels. The component should be accessible, user-friendly, and follow best practices for tab interfaces.
Requirements
- Implement a tab interface where clicking on a tab makes it the active tab
- Display a clear visual indication for the active tab (color, underline, etc.)
- Show only one content panel at a time, corresponding to the active tab
- Make the component accessible with proper ARIA attributes
- Add smooth transitions when switching between tabs
Example

Important: When adding functions to index.js, you must attach them to the window object (e.g., window.functionName = function() {...}). This is because the playground uses JavaScript modules, which don't automatically expose functions globally. This is only applicable to Vanilla JavaScript questions.
Playground
In vanilla JavaScript there is no useState to track which tab is active. Instead, track active state directly in the DOM: toggle an active class on the clicked button and the corresponding panel, and remove it from the others.
For accessibility, use the same ARIA attributes as the React version. Apply role="tablist" to the container, role="tab" to each button, and role="tabpanel" to each content panel. Use aria-selected on buttons and aria-controls/aria-labelledby to link tabs to their panels.
Solution
Explanation
I will walk you through this interview challenge. We will go through the solution step by step, showing what an ideal solution looks like, where candidates go wrong, and how interviewers judge your solution.
Before we start coding, it is very important to thoroughly read the prompt and understand the requirements. The prompt gives us the tab data directly in the starter code, so there is no API call to worry about. Our core challenge is building the DOM structure, wiring up click handling, styling it correctly, and making it accessible.
Now, let's start solving.
Setting Up the HTML
We will start with index.html. The only thing we need here is a root element for our JavaScript to mount the tab component into. We keep the HTML minimal because we will build everything else programmatically.
index.html
<!doctype html>
<div class="container">
<div id="tabs-root"></div>
</div>The .container class will give us the max-width and centering. The #tabs-root div is the mount point — our JavaScript will find this element and inject the entire tab component into it.
Setting Up index.js
The starter code already gives us the TABS array. The first thing we do is create a window.initTabs function. We attach it to window because the playground uses ES modules, which scope variables and functions locally and do not expose them globally. All our functions need to be on window so they can reference each other and so the playground can call them.
index.js
import './styles.css';
const TABS = [
{
label: 'HTML',
content: `The HyperText Markup Language or HTML is the standard markup language for documents designed to be displayed in a web browser.`,
},
{
label: 'CSS',
content: `Cascading Style Sheets is a style sheet language used for describing the presentation of a document written in a markup language such as HTML or XML.`,
},
{
label: 'JavaScript',
content: `JavaScript, often abbreviated as JS, is a programming language that is one of the core technologies of the World Wide Web, alongside HTML and CSS.`,
},
];
window.initTabs = function () {
const root = document.getElementById('tabs-root');
const tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
const tabsList = document.createElement('div');
tabsList.className = 'tabs-list';
tabsList.setAttribute('role', 'tablist');
const tabsContent = document.createElement('div');
tabsContent.className = 'tabs-content';
root.appendChild(tabsContainer);
};
window.initTabs();We create three elements: tabsContainer wraps the whole component, tabsList will hold the tab buttons, and tabsContent will hold the content panels. We give tabsList role="tablist" right away — this tells assistive technologies that the buttons inside are a group of tabs, not just standalone buttons.
Building the Tab Buttons
Now we loop over TABS and create the button for each tab. The first tab starts active, so its button gets the active class and aria-selected="true". All others get no active class and aria-selected="false".
index.js
TABS.forEach(function (tab, index) {
const button = document.createElement('button');
button.className =
index === 0 ? 'tab-trigger active' : 'tab-trigger';
button.id = 'tab-' + index;
button.setAttribute('role', 'tab');
button.setAttribute(
'aria-selected',
index === 0 ? 'true' : 'false'
);
button.setAttribute('aria-controls', 'panel-' + index);
button.dataset.index = index;
button.textContent = tab.label;
tabsList.appendChild(button);
});button.dataset.index = index stores the tab's position on the button element itself. When the user clicks, we can read this value directly off the event target without searching the DOM for it. This is the vanilla JS equivalent of knowing the index from a loop — you need to persist it somewhere the click handler can reach.
Common Pitfalls
A common mistake is adding an individual click listener to each button inside this loop. For three tabs it works fine, but for a dynamic list that grows over time every new tab needs its own listener. The correct approach is to add a single click listener to the tabsList container and let clicks bubble up to it. This is event delegation — one listener handles all buttons. We will add it after the loop.
Building the Content Panels
We continue the loop and create the panel for each tab. Like the buttons, the first panel starts active.
index.js
TABS.forEach(function (tab, index) {
const button = document.createElement('button');
button.className =
index === 0 ? 'tab-trigger active' : 'tab-trigger';
button.id = 'tab-' + index;
button.setAttribute('role', 'tab');
button.setAttribute(
'aria-selected',
index === 0 ? 'true' : 'false'
);
button.setAttribute('aria-controls', 'panel-' + index);
button.dataset.index = index;
button.textContent = tab.label;
tabsList.appendChild(button);
const panel = document.createElement('div');
panel.className =
index === 0 ? 'tab-panel active' : 'tab-panel';
panel.id = 'panel-' + index;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', 'tab-' + index);
panel.tabIndex = index === 0 ? 0 : -1;
panel.textContent = tab.content;
tabsContent.appendChild(panel);
});Common Pitfalls
A very common mistake is setting tabIndex = 0 on every panel. This puts all panels, including the hidden ones, into the keyboard tab order. A screen reader user pressing Tab would land on invisible content and get completely disoriented. The correct approach is panel.tabIndex = index === 0 ? 0 : -1: only the active panel is reachable by Tab, all others are removed from the tab order with -1 but can still be focused programmatically.
Composing initTabs
Now we append everything together, add the click listener on tabsList, and call initTabs at the end of the file to kick everything off.
index.js
window.initTabs = function () {
const root = document.getElementById('tabs-root');
const tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
const tabsList = document.createElement('div');
tabsList.className = 'tabs-list';
tabsList.setAttribute('role', 'tablist');
const tabsContent = document.createElement('div');
tabsContent.className = 'tabs-content';
TABS.forEach(function (tab, index) {
const button = document.createElement('button');
button.className =
index === 0 ? 'tab-trigger active' : 'tab-trigger';
button.id = 'tab-' + index;
button.setAttribute('role', 'tab');
button.setAttribute(
'aria-selected',
index === 0 ? 'true' : 'false'
);
button.setAttribute('aria-controls', 'panel-' + index);
button.dataset.index = index;
button.textContent = tab.label;
tabsList.appendChild(button);
const panel = document.createElement('div');
panel.className =
index === 0 ? 'tab-panel active' : 'tab-panel';
panel.id = 'panel-' + index;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', 'tab-' + index);
panel.tabIndex = index === 0 ? 0 : -1;
panel.textContent = tab.content;
tabsContent.appendChild(panel);
});
tabsContainer.appendChild(tabsList);
tabsContainer.appendChild(tabsContent);
root.appendChild(tabsContainer);
tabsList.addEventListener('click', window.handleTabClick);
};
window.initTabs();Handling Clicks
Now we write handleTabClick. This is the single listener attached to tabsList that handles all tab button clicks.
index.js
window.handleTabClick = function (event) {
const button = event.target.closest('[role="tab"]');
if (!button) return;
const index = parseInt(button.dataset.index, 10);
window.setActiveTab(index);
};event.target.closest('[role="tab"]') walks up the DOM from the actual click target until it finds an element with role="tab". In this component the buttons have no child elements so event.target will always be the button itself, but using closest is the correct habit. In a richer UI where a button contains an icon or a span, clicking the icon would set event.target to that inner element — closest handles this correctly while a direct event.target check would not.
The guard if (!button) return handles clicks on the gap between buttons or on the tabsList background where no tab was clicked.
Interview Tip
After wiring up the click handler, add console.log(index) inside handleTabClick and click each tab to verify the correct index is printed before you implement setActiveTab. A lot of candidates skip this and discover much later that their event delegation was not wired up correctly. Checking step by step is much faster than debugging everything at once.
Updating Active State
setActiveTab is where the DOM state update happens. We query all buttons and all panels, then use classList.toggle to add or remove active from each one.
index.js
window.setActiveTab = function (index) {
const buttons = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll(
'[role="tabpanel"]'
);
buttons.forEach(function (btn, i) {
const isActive = i === index;
btn.classList.toggle('active', isActive);
btn.setAttribute(
'aria-selected',
isActive ? 'true' : 'false'
);
});
panels.forEach(function (panel, i) {
const isActive = i === index;
panel.classList.toggle('active', isActive);
panel.tabIndex = isActive ? 0 : -1;
});
};classList.toggle('active', isActive) takes a boolean second argument: when true it adds the class, when false it removes it. This is cleaner than writing separate classList.add and classList.remove calls inside an if/else. We also update aria-selected on each button and tabIndex on each panel to keep the DOM in sync with the visual state.
Styling the Tabs
Now let's write the CSS. The styling is identical to the React version because the HTML structure and class names are the same.
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.container {
max-width: 440px;
margin: 0 auto;
padding: 20px;
}
/* Tabs Container */
.tabs-container {
background-color: white;
color: rgba(0, 0, 0, 0.9);
border: 1px solid #cecece;
border-radius: 8px;
overflow: hidden;
}
/* Tabs List */
.tabs-list {
display: flex;
flex-direction: row;
background-color: #f0f0f0;
border-bottom: 1px solid #cecece;
}
.tab-trigger {
padding: 12px 20px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #555;
transition: all 0.3s ease;
position: relative;
outline: none;
}
.tab-trigger:hover {
background-color: #e0e0e0;
}
.tab-trigger.active {
color: #3a5bc7;
background-color: white;
}
.tab-trigger.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: #3a5bc7;
}
.tab-trigger:focus-visible {
box-shadow: 0 0 0 2px #8da4ef;
z-index: 1;
}
/* Tab Content */
.tabs-content {
background-color: white;
}
.tab-panel {
display: none;
padding: 20px;
}
.tab-panel.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}The first thing I always add is box-sizing: border-box. It makes width and height calculations predictable by including padding and border in the element's total size, preventing unexpected overflow.
To make the tab buttons sit side by side, I use display: flex on .tabs-list. Without Flexbox the buttons would stack vertically because they are block-level elements by default. I add border-bottom to .tabs-list as the visual separator between tabs and content.
Browsers apply their own default styles to <button> elements, including a grey background, a border, and system font styling. Resetting background: none and border: none removes these defaults so we have full control over the appearance.
For the active tab indicator, I use an ::after pseudo-element positioned at the bottom of the button rather than using border-bottom on the button itself. The reason is that border-bottom would push the button taller when added and then remove that extra height when removed as you switch tabs, causing a layout shift. The pseudo-element does not affect the button's size at all.
Common Pitfalls
Do not write outline: none on .tab-trigger without replacing it with something else. Removing the focus outline entirely makes the component unusable for keyboard users — they have no way of knowing which tab is focused. The correct approach is to keep outline absent for mouse clicks but provide a clear visible style for keyboard focus using :focus-visible. This is the small detail that separates candidates who just think about visual styling from candidates who think about everyone who will use the component.
The fadeIn animation adds a subtle entrance effect when switching panels. Small polish details like this show the interviewer you think about the complete user experience, not just the functionality.
Accessibility
The ARIA attributes in this vanilla JS solution are identical in meaning to the React version — the difference is only in how we set them: setAttribute instead of JSX props.
The role="tablist" on the container div tells assistive technologies that this is a group of tabs. Without it, a screen reader would just see a row of buttons with no context about what they are for.
Each button has role="tab" and aria-selected set to "true" or "false". The aria-selected attribute is what a screen reader announces when the user moves to a tab: "HTML, tab, selected" or "CSS, tab, not selected". It is very important to include this because without it the user has no way to know which tab is currently active.
Each button also has aria-controls pointing to the ID of its panel, and each panel has aria-labelledby pointing back to its tab button. These two attributes create a programmatic link between the tab and the panel so that assistive technologies can communicate the relationship.
No one remembers all the ARIA attributes
Remembering every ARIA attribute and role is not possible and not expected. Developers look them up on MDN or use AI tools. What matters is that you understand the purpose — ARIA attributes communicate the semantics and state of your UI to assistive technologies like screen readers.
In an interview, you can say you understand ARIA and would check MDN for the exact attributes. This is the right answer. It shows that you care about accessibility and know how to use resources well. Interviewers appreciate this far more than a candidate who guesses and gets the attributes wrong.
Communicate While You Code
Throughout the interview, it is very important to express your thoughts and the key decisions you are making while writing code. Lots of candidates silently code the solution and don't talk much. Coding is one part of your job as a software developer, but explaining and reasoning about code is another very important part. Interviewers love candidates who can talk through their solutions. So don't be silent, but also don't over explain — focus on the important decisions, for example:
- I am using event delegation with a single listener on the tab list rather than one listener per button
- I am storing the tab index in
dataset.indexso I can read it directly in the click handler without querying the DOM - I am using
tabIndex = -1on inactive panels so hidden content is not reachable by keyboard
These are the kinds of decisions that give the interviewer confidence that you have solid fundamentals and can reason clearly about your code.
Interviewer Criteria
HTML/CSS
Did I create a visually appealing tabs interface with clear styling for active and inactive states?
Have I implemented smooth transitions or animations when switching between tabs?
Did I use proper CSS organization with meaningful class names?
JavaScript
Did I use
dataset.indexto store the tab index on each button so I don't need to query the DOM to find it on click?Did I use event delegation with a single click listener on the tab list instead of one listener per button?
Did I use
event.target.closest('[role="tab"]')to safely find the clicked tab button?Are my variable and function names descriptive and consistent?
Accessibility
Did I implement proper ARIA roles (tablist, tab, tabpanel) for screen reader support?
Have I managed focus correctly, ensuring the active tab is focusable and inactive tabs are not in the tab order?
Did I provide visible focus indicators for keyboard users?
DOM Manipulation
Did I encapsulate all DOM building inside
initTabsrather than running imperative code at the top level?Did I attach functions to the
windowobject so they are accessible across modules?Did I use
classList.toggle(name, boolean)for clean active state toggling instead of separate add/remove calls?
Time Checkpoints
- 10:00 AM
Interview starts 👥
- 10:03 AM
Prompt given by the interviewer
- 10:05 AM
Candidate reads the prompt, asks clarifying questions, and starts coding
- 10:10 AM
HTML structure set up, TABS data defined, initTabs scaffold created
- 10:15 AM
Tab buttons built and appended to DOM with ARIA attributes
- 10:20 AM
Tab panels built and appended to DOM
- 10:25 AM
Event delegation wired up and setActiveTab implemented
- 10:30 AM
CSS styling for tabs and panels added
- 10:40 AM
Active tab styling with visual indicators implemented
- 10:45 AM
ARIA attributes and tabIndex handling for hidden panels verified
- 10:55 AM
Final testing and refinements
- 11:00 AM
Interview ends ✅