What are Bootstrap tabs and how do they work?
The markup comes in two parts. A nav list holds the tab triggers, and a tab-content container holds one tab-pane per trigger.
The Tab plugin, shipped with the Bootstrap framework, links each trigger to its pane and moves the active and show classes around when a trigger is used.
That switching logic lives in JavaScript, not in the stylesheet. Adding nav-tabs to a list only changes how it looks, and Bootstrap’s plugins have no special fallback when scripting is off (Bootstrap 5.3 documentation).
A navbar styled with nav-tabs is a different animal. It links to other pages and must not carry the tablist, tab and tabpanel roles, while a real tab interface swaps panes on the same page and does carry them.
The docs draw that line themselves. Version 5.0.0 also renamed the docs page from “Navs” to “Navs & tabs” (Bootstrap migration guide).
Everything below follows Bootstrap 5.3.8, the release the official docs list as current in October 2026.
How to create Bootstrap tabs step by step
Start with the plugin, since nothing else works without it.
- Load one build of the plugin, such as bootstrap.bundle.min.js from jsDelivr, or install the bootstrap package from npm. The docs warn against including both bootstrap.js and bootstrap.min.js.
- Build a ul with the classes nav and nav-tabs and role=”tablist”, with one li.nav-item per tab.
- Put a button.nav-link inside each li, with data-bs-toggle=”tab” and a data-bs-target that points at the pane id.
- Add a div.tab-content after the nav, holding one div.tab-pane per trigger. Each pane gets the id its trigger targets.
- Put active on the first button and active on the first pane.
- Add role, aria-controls and aria-selected to the triggers, and role and aria-labelledby to the panes.
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#home-pane" type="button" role="tab" aria-controls="home-pane" aria-selected="true">Home</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#profile-pane" type="button" role="tab" aria-controls="profile-pane" aria-selected="false">Profile</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="home-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">...</div>
<div class="tab-pane" id="profile-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">...</div>
</div>
The role and aria attributes in step 6 are what the ARIA tabs pattern asks for. That’s why the markup looks so much heavier than a plain nav. It is wordy, no way around it.
Anchors with an href also work as triggers, as older tutorials show. The docs still recommend buttons, because a tab triggers an in-page change and does not navigate anywhere.
This markup targets Bootstrap 5, and Bootstrap 4 has reached end of life according to the banner on its own docs.
Among sites running Bootstrap, 21.4% used version 4 and 10.7% used version 5 (W3Techs, 28 October 2026). Old data-toggle and data-target snippets are still easy to find for that reason.
Adding the fade effect
Add fade to every tab-pane. The first pane also needs show, or its content stays invisible after load.
<div class="tab-pane fade show active" id="home-pane" ...>...</div>
<div class="tab-pane fade" id="profile-pane" ...>...</div>
Skip fade and the panes switch instantly.
Which style fits: tabs, pills, underline, vertical, fill or justified?
Classic tab headers come from nav-tabs. If the active tab should look like a button, use nav-pills instead, and nav-underline gives a lighter look. Stack the triggers with flex-column, or stretch the row across the full width with nav-fill or nav-justified.
| Style | Class | Result |
|---|
| Tabs | nav-tabs | Classic headers, bordered active tab |
| Pills | nav-pills | Rounded background on the active link |
| Underline | nav-underline | Simple bottom border under the active link |
| Fill | nav-fill | Proportional widths, all space used |
| Justified | nav-justified | Equal widths, all space used |
Pills use data-bs-toggle=”pill” on the triggers. The rest of the markup stays the same.
nav-underline was added in Bootstrap 5.3.0, so earlier 5.x releases do not have it (Bootstrap 5.3 documentation).
Vertical tabs
Put flex-column on the nav, and place the nav beside the tab-content. The wrapper gets d-flex align-items-start. The trigger list gets nav flex-column nav-pills me-3, plus aria-orientation=”vertical”, and tab-content sits as the second child of the wrapper.
That is the layout from the 5.3 docs. Col-3 and col-9 columns from the grid system work as well, and they give the pane a fixed share of the width.
Fill versus justified
With nav-fill, items share the available space in proportion to their content, so widths differ. nav-justified gives every item the same width, which works when the labels are close in length.
If one label is far longer than the rest, go with nav-fill, because equal widths would squeeze it.
How to control Bootstrap tabs with JavaScript
Call getOrCreateInstance on the trigger element, then call show(). The call starts the transition and returns at once, so listen for shown.bs.tab when code has to run after the pane is visible.
Like every other Bootstrap plugin, the tab plugin exposes the same programmatic API, with a constructor, getInstance, getOrCreateInstance and dispose.
Methods
const trigger = document.querySelector('#profile-tab');
bootstrap.Tab.getOrCreateInstance(trigger).show();
Wire that to a click on any button and it jumps to the Profile tab from anywhere on the page.
The show method selects the tab and its pane and hides the previous pair. getInstance returns the existing instance, or null when none exists. getOrCreateInstance returns the instance or creates one, and dispose destroys it.
Because getInstance returns null when nothing was initialized, getOrCreateInstance is the safer call from an external button.
Every method is asynchronous, and a call made during a running transition is ignored (Bootstrap 5.3 documentation).
Events and their order
Each switch fires its events in the same sequence. hide.bs.tab goes first, on the current active tab, then show.bs.tab on the tab about to be shown. After the switch, hidden.bs.tab fires on the previous tab and shown.bs.tab on the new one.
If no tab was active, hide.bs.tab and hidden.bs.tab do not fire.
In shown.bs.tab, event.target is the newly active tab and event.relatedTarget is the previous one.
The show and hide events can be cancelled with preventDefault(), which stops the switch before it starts, per the Bootstrap 5.3 JavaScript docs.
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
el.addEventListener('shown.bs.tab', event => {
console.log(event.target, event.relatedTarget);
});
});
How to open a specific tab and keep it after reload
Bootstrap keeps no tab state, so a reload returns to the tab marked active in the markup. Store the active tab in the URL hash for shareable links, or in localStorage for a per-browser memory, and call show() on load to restore it.
The gap is old. An early twbs/bootstrap issue titled “Linking to specific tab” (number 2415) asked for it, and the current docs list no state option for the plugin.
Joomla hit the same wall. After its move to Bootstrap 5, a reload opened the first tab, and a November 2022 pull request (number 39283) fixed the tab-state script, borrowing a solution from twbs/bootstrap issue 34767 (Joomla issue tracker).
const hash = window.location.hash;
const trigger = hash && document.querySelector('[data-bs-target="' + hash + '"]');
if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show();
}
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
el.addEventListener('shown.bs.tab', event => {
history.replaceState(null, '', event.target.dataset.bsTarget);
});
});
The localStorage version follows the same pattern. Save event.target.dataset.bsTarget under a key in the shown.bs.tab handler, and read it back before calling show().
The hash route carries the state in the link, so it can be shared or bookmarked, and other pages can deep-link to a tab, for example page.html#profile-pane. The downsides are small. Setting location.hash adds a history entry per click, and replaceState avoids that at the cost of back-button tab stepping. An unknown hash also needs a fallback to the default tab.
localStorage suits returning visitors, who land on their last tab without any change to the URL. The memory is per browser and per device though, so a link cannot carry it, and storage access can fail in private or restricted modes, so wrap reads and writes in try/catch.
Use the hash when people link to or share a tab. Use localStorage for pages users revisit, such as dashboards, and let the hash win when both are present.
What do Bootstrap tabs need for accessibility and keyboard use?
Keyboard use runs on a roving tabindex. Only the active tab sits in the Tab sequence, and the arrow keys, Home and End move between tabs (Bootstrap 5.3 documentation).
Tabs are easy to get wrong for web accessibility, because the semantics live in attributes that browsers do not infer from the markup.
Tab moves focus into the list, onto the active tab only. Left and right arrows activate the previous or next tab, and Home and End jump to the first and last. Every inactive tab gets tabindex=”-1″ when the plugin initializes.
Koha, the open-source library system, logged bug 38015 after moving to Bootstrap 5 because only the active tab took Tab focus. One commenter on the thread argued that this is the better behavior, since the arrow keys switch tabs and Tab skips the whole list (Koha bug tracker).
Bootstrap and the W3C pattern part ways in a couple of places. Bootstrap sends up and left to the previous tab and down and right to the next, in horizontal and vertical lists alike.
The W3C ARIA Authoring Practices Guide is stricter. A horizontal list ignores Up and Down so those keys keep their normal page scrolling, and Up and Down apply only when aria-orientation is vertical.
So in a horizontal Bootstrap tab list, pressing Down on a focused tab switches tabs instead of scrolling the page. Home and End are optional in the W3C pattern, and Bootstrap implements both.
Bootstrap activates a tab as the arrow keys move focus. The W3C guide recommends that behavior only when panels appear without noticeable latency, which typically means content loaded in advance, so lazy-loaded panes make arrow-key use feel slow.
Label the tab list. The W3C pattern wants aria-label or aria-labelledby on the tablist, and the Bootstrap docs example carries neither.
<ul class="nav nav-tabs" id="myTab" role="tablist" aria-label="Account sections">
Panes keep tabindex=”0″ unless the first element inside is already focusable, such as a link or an input. In that case the attribute adds a redundant stop and can go.
How to style Bootstrap tabs with CSS variables and Sass
Override the local CSS variables on the nav for per-component changes, and the Sass variables before compiling for site-wide changes. The nav CSS variables arrived in Bootstrap 5.2.0, and the nav-underline ones in 5.3.0 (Bootstrap 5.3 documentation).
CSS variables are set at runtime on the element, need no recompile and stay scoped to one tab set. Sass variables get compiled into the stylesheet, so they have to be set before the variables import to replace the defaults.
.nav-tabs.settings-tabs {
--bs-nav-link-color: #495057;
--bs-nav-tabs-link-active-color: #0b5ed7;
--bs-nav-tabs-link-active-bg: #f8f9fa;
--bs-nav-tabs-border-color: #dee2e6;
}
Use two classes on the same element. The variables are defined on .nav-tabs itself, so a value set on a parent wrapper never reaches them.
Each component carries its own set. .nav covers link padding, font size, font weight, color, hover color and disabled color. .nav-tabs adds border width, border color, border radius and hover border color, plus active color, background and border color. .nav-pills has border radius, active color and active background, and .nav-underline has gap, border width and active color.
The Sass side starts with two spacing variables, $nav-link-padding-y (.5rem by default) and $nav-link-padding-x (1rem).
@import "functions";
$nav-link-padding-y: .75rem;
$nav-link-padding-x: 1.25rem;
@import "variables";
// then maps, mixins and the rest of the Bootstrap imports
Default tab colors reference body variables such as –bs-body-bg and –bs-emphasis-color, so they follow data-bs-theme in Bootstrap 5.3 without extra rules.
Hard-coded replacements lose that, so check the color contrast of active and inactive tabs in both light and dark mode.
What changed in Bootstrap tabs between version 4 and version 5?
Bootstrap 5 adds the bs namespace to data attributes (data-toggle becomes data-bs-toggle, data-target becomes data-bs-target), drops the jQuery requirement, and replaces the jQuery call with the bootstrap.Tab class. The four event names stay the same.
| Item | Bootstrap 4.6 | Bootstrap 5.3 |
|---|
| Toggle attribute | data-toggle=”tab” | data-bs-toggle=”tab” |
| Target attribute | data-target (or href on links) | data-bs-target (or href on links) |
| Activation call | $(‘#myTab button’).tab(‘show’) | bootstrap.Tab.getOrCreateInstance(el).show() |
| Event listener | jQuery .on(‘shown.bs.tab’) | addEventListener(‘shown.bs.tab’) |
Plenty carries over untouched. hide.bs.tab, show.bs.tab, hidden.bs.tab and shown.bs.tab fire in the same order, triggers are buttons, and panes use fade show active (Bootstrap 4.6 and 5.3 documentation).
Bootstrap 5 does not need jQuery, but when it detects jQuery on the page it registers its plugins there. Old $(‘#myTab button’).tab(‘show’) calls therefore keep working on pages that still load jQuery (Bootstrap 5.3 documentation).
Version 5 has been the default since May 5, 2021. Bootstrap 3 reached end of life on July 24, 2019 and Bootstrap 4 on January 1, 2023, and neither gets features or security fixes (Bootstrap release schedule in the twbs/release repository).
For tabs, the minimal migration is to rename data-toggle to data-bs-toggle and data-target to data-bs-target. After that, either swap .tab(‘show’) for bootstrap.Tab.getOrCreateInstance(el).show(), or keep jQuery loaded. The panes can stay as they are, since 4.6 already used fade show active.
When do Bootstrap tabs fail or fit poorly?
Tabs break in a few well-documented ways, and they are a poor fit for core content that every reader needs.
Dropdowns are the first one. The plugin does not support dropdown menus inside a tab list, because the active tab’s trigger can end up hidden in a closed menu, and no standard ARIA pattern describes that construct (Bootstrap docs).
Keep any Bootstrap dropdown beside the tab list instead, or flatten its items into plain tabs.
An element takes one set of data attributes, so a tab button cannot also trigger a tooltip through data-bs-toggle. Put the tooltip on a wrapper.
Bootstrap’s JavaScript is not fully compatible with React, Vue and Angular, which assume full knowledge of the DOM. The docs point to React Bootstrap and ng-bootstrap instead.
Tabs inside accordions had their own problem. Bootstrap 5.1.0 shipped a regression where the active tab’s content vanished after the surrounding accordion was reopened (twbs/bootstrap issue 34698). Pull request 34835 fixed it, and the fix shipped in Bootstrap 5.1.1 in September 2021.
When tabs refuse to work, check that only one Bootstrap JavaScript build is loaded and that each data-bs-target matches a pane id and is a valid selector, with special characters escaped. Look for leftover data-toggle attributes from Bootstrap 4 snippets too. And if jQuery UI or Prototype is on the page, that can cause trouble, since Bootstrap does not officially support either.
The CDC’s web content guidance advises against tabs and accordions for most content. Readers overlook important content in them and cannot scan it easily. The modules also work poorly in small viewports and do not print well.
The California Courts design system adds further limits: use a table when readers must compare content across sections, and use separate pages when the content inside a tab is complex and needs further navigation.
When several sections should stay open at once, a Bootstrap accordion fits better, because omitting data-bs-parent keeps each item open independently (Bootstrap 5.3 documentation).
Sources disagree on one practice. CDC guidance advises against linking to content inside a tab or accordion section, while the hash technique does exactly that.
Keep hash links for application screens and dashboards, where people expect to land on a specific tab.
Use tabs for parallel views of one subject that readers open one at a time.
Common Questions About Bootstrap Tabs
How do you disable a tab in Bootstrap?
Add the disabled attribute to the button trigger, as the official 5.3 example does. For an anchor trigger, use the disabled class plus aria-disabled=”true”.
A disabled button also drops out of the keyboard sequence.
Can Bootstrap tabs be nested?
Yes. Put a second nav and tab-content inside a tab-pane, and give every trigger and pane its own unique id so the data-bs-target selectors never collide.
An early bug removed the active class from inner tabs whenever the outer tabs switched (twbs/bootstrap issue 310), so test the inner state after switching outer tabs.
How do you lazy load content inside a tab?
Fetch the content in a shown.bs.tab handler the first time a tab opens, and skip the request on later visits.
The tab activates before the request finishes, so put a loading state in the pane.
How can tabs sit inside a card?
Place the nav inside the card header with the classes nav, nav-tabs and card-header-tabs, then put the tab-content in the card body.
Everything else follows the standard Bootstrap card markup.
How do you make tabs stack on small screens?
Add flex-column flex-sm-row to the nav. The triggers stack on the smallest breakpoint and sit in a row from sm upward (Bootstrap 5.3 documentation).
Add flex-sm-fill to each link to stretch them across the row. It gives you a responsive design without custom media queries.
What to Check Before Bootstrap Tabs Go Live
Tabs are ready to ship once the checks pass, and the order matters.
Check the attribute syntax against the installed major version first, because a wrong data attribute makes every other test meaningless. Keyboard comes second, from a labeled tab list, since it exposes labeling and focus gaps. State goes last, because it depends on working switching and events, so a reload should land on the intended tab.
Passing the keyboard check with the plugin’s automatic activation means keeping every pane in the initial HTML, which costs page weight.
This position was verified against Bootstrap 5.3.8 on 28 October 2026. The team planned that release as the last patch before 5.4.0, so rerun all 3 checks when it ships (Bootstrap blog, August 2025).