What Does a Tailwind Tabs Component Consist Of?
Start with the container, which takes role=”tablist” and is usually a flex row with a bottom border. Each tab inside it is a button with role=”tab” and an id, plus aria-controls pointing at its panel. The panel is a div with role=”tabpanel”, and aria-labelledby points back at the tab.
Tailwind itself ships no script for any of this. The Tailwind CSS framework supplies the classes, and the switching comes from JavaScript, a framework, a headless library or a CSS-only trick.
Put the tab role on buttons, not links. A button takes focus and fires on Enter and Space without extra code, which is why tabs in plain HTML start from button elements.
A base tab carries px-4 py-2 text-sm font-medium plus border-b-2 border-transparent. The transparent border reserves the space, so the layout does not jump when the active tab gets its colored border.
Tailwind Labs’ v4 upgrade guide has a couple of changes that touch this markup. The hidden attribute now beats display utilities like flex or block, so a hidden panel stays hidden even when it carries a display class. And buttons use the browser default cursor now, so you add cursor-pointer to each tab yourself.
How to Build Tailwind Tabs With Vanilla JavaScript
The script is short. It flips aria-selected and the hidden attribute on click and moves focus on arrow keys, while the markup and aria-selected variants handle everything else.
- Create the tablist as a div with role=”tablist” and an aria-label, holding one button per tab. Give each button role=”tab”, an id, and aria-controls set to its panel id.
- Set the starting state with aria-selected=”true” and tabindex=”0″ on the first tab, and aria-selected=”false” and tabindex=”-1″ on the rest.
- Create one div per panel with role=”tabpanel” and aria-labelledby set to its tab id. Add the hidden attribute to every panel except the first.
- Style the tabs with aria-selected: variants, such as aria-selected:border-indigo-600 and aria-selected:text-indigo-600, over a border-transparent base.
- Write the click handler. Set aria-selected to false and tabindex to -1 on every tab, mark the clicked tab true and 0, add hidden to every panel, then remove it from the matching one.
- Add a keydown handler on the tablist for Left and Right Arrow, Home and End. It moves focus and calls the same switch function.
Because the classes never change, a few lines of JavaScript that set attributes are enough.
The one thing that goes wrong is toggling color classes from the click handler. The visual state and aria-selected drift apart the first time someone edits one and forgets the other.
Which Method Should Track the Active Tab?
For a single tab set on a static page, vanilla JavaScript is plenty. If you have several tab sets and Alpine.js is already loaded, use Alpine. React or Vue apps do better with Headless UI or another headless primitive.
CSS-only tabs with peer and radio inputs suit a prototype where no script is allowed, and only where losing the full ARIA and keyboard pattern costs nothing. A data-attribute library such as Flowbite or Preline makes sense when you want a prebuilt look and no custom behavior.
Alpine.js
Alpine keeps the active tab in one variable on x-data. Each button sets it on click, a :class binding styles the active state, and each panel gets x-show.
According to the Alpine docs, x-show toggles display: none, and the class binding accepts an object of class names mapped to boolean expressions.
Alpine adds no roles on the tablist, tabs or panels. You also write the :aria-selected binding on each tab yourself, plus a @keydown handler for the arrow keys. State, show and hide, and class binding are covered. The rest stays your job.
React and Vue with Headless UI
Tailwind Labs released Headless UI v2.0 for React on May 7, 2024.
Tabs use TabGroup, TabList, Tab, TabPanels and TabPanel. Props cover manual activation, vertical layout, defaultIndex, and a controlled selectedIndex with onChange, per the Headless UI docs.
Each tab exposes data-selected, data-hover, data-focus and data-active, so data-selected:bg-blue-500 styles the active tab with no render props.
In a project that already uses Tailwind CSS in React, this is the shortest path to a tab set that handles focus and keys correctly.
Vue is a different story. The Vue link on the React docs page points to the v1 documentation, which describes the older data-headlessui-state attribute and ui-selected plugin variants instead of data-selected.
Check which attribute your installed version emits before copying classes from either page.
CSS-only tabs with peer and radio inputs
Radio inputs hold the state, labels act as the tabs, and peer-checked variants reveal the matching panel. No script runs.
Tailwind’s own docs show the mechanism with named peers: a radio marked peer/draft, a label styled with peer-checked/draft, and a hidden panel that becomes block through peer-checked/draft:block.
Plenty of CSS-only tab examples rely on the same trick, and each radio group needs its own unique name (daisyUI’s tab docs repeat that rule).
The catch, also from the Tailwind docs, is that a peer marker only reaches later siblings. Every radio input must sit before the panels it controls.
Whether a CSS-only build exposes tabpanel roles and aria-controls depends entirely on your markup, so check it against the W3C roles list before shipping.
Data-attribute libraries
Flowbite wants data-tabs-toggle on the tablist, data-tabs-target on each button, role=”tab”, aria-selected, and the Flowbite JavaScript loaded. Active and inactive classes pass through data-tabs-active-classes and data-tabs-inactive-classes.
Preline works from data-hs-tab with aria-controls on each button, started by HSTabs.autoInit(). An eventType option switches between click and hover.
Flowbite’s code is MIT licensed, according to its docs. I’d take either library only if you want its prebuilt look too, because the runtime dependency replaces a job a small script already covers.
How to Make Tailwind Tabs Accessible
The W3C Authoring Practices tabs pattern defines what accessible tabs need. Each tab is tied to its panel, exactly one tab is marked selected, and arrow keys move focus inside the tablist.
The ARIA roles and states tell a screen reader that the buttons form one tab set and which panel each one controls.
The container gets role=”tablist”, each tab gets role=”tab” and each panel gets role=”tabpanel”. Label the tablist with aria-label, or with aria-labelledby when a visible label already exists. Wiring runs both ways, with aria-controls on each tab and aria-labelledby on each panel.
Set aria-selected=”true” on the active tab and false on all the others. Vertical lists also need aria-orientation=”vertical”, since horizontal is the default.
| Key | W3C pattern | Headless UI |
|---|
| Left / Right Arrow | Previous or next tab, wrapping at the ends | Selects previous or next non-disabled tab |
| Home / End | Optional: first or last tab | Home or PageUp, End or PageDown |
| Enter / Space | Activates when activation is not automatic | Activates only when manual is set |
| Up / Down Arrow | Replace Left and Right when vertical; ignored when horizontal | Same, with the vertical prop |
The W3C pattern recommends activating a tab on focus when its panel appears without noticeable latency. When panels are slow, automatic activation makes keyboard navigation sluggish, so manual activation fits better.
Headless UI activates on arrow keys by default and offers a manual prop for tabs that trigger expensive work.
Both sources land on the same rule. Activate automatically by default, and go manual when switching costs time.
Panels need one more detail. When a tabpanel holds no focusable element, the W3C note says to give it tabindex=”0″ so the Tab key lands on it from the tablist.
How to Style Active, Hover and Focus States in Tailwind CSS v4
Style the active tab from its own state attribute. That means aria-selected: on hand-built tabs and data-selected: on Headless UI tabs, with focus-visible: handling keyboard focus.
Tailwind’s aria-selected variant targets [aria-selected=”true”], so the script never touches a color class.
Inactive tabs usually get border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700. The active tab adds aria-selected:border-indigo-600 aria-selected:text-indigo-600. For keyboard focus, focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 does the job.
The v4 upgrade guide changed four defaults that tab styles run into.
- Hover applies only when the primary input supports hover, so touch screens never show it. Tailwind advises treating hover as an enhancement.
- The outline-2 utility now draws a solid outline by itself, and outline-none was renamed outline-hidden.
- The bare ring utility dropped from 3px to 1px and from blue-500 to currentColor, so use ring-3 and name the color.
- Border utilities default to currentColor, which means an underline tab needs an explicit color.
Headless UI reaches the same touch behavior from the other side. Its data-hover attribute is ignored on touch devices, and data-focus acts like focus-visible without false positives from imperative focusing (Tailwind Labs, Headless UI v2.0 announcement, May 7, 2024).
One constraint decides whether v4 is an option at all. Tailwind CSS v4.0 targets Safari 16.4, Chrome 111 and Firefox 128, and projects that support older browsers stay on v3.4, per the upgrade guide.
Dark mode
Pair each color class with a dark: twin. By default the dark variant follows prefers-color-scheme, so tabs match the operating system with no toggle.
| Part | Light | Dark |
|---|
| Tablist border | border-gray-200 | dark:border-gray-700 |
| Inactive tab text | text-gray-500 | dark:text-gray-400 |
| Active tab | aria-selected:text-indigo-600 aria-selected:border-indigo-600 | dark:aria-selected:text-indigo-400 dark:aria-selected:border-indigo-400 |
For a manual toggle, override the variant in CSS with @custom-variant dark (&:where(.dark, .dark *)). Dark classes then apply whenever a dark class sits above the element, and a data-theme attribute works the same way (Tailwind CSS docs).
A toggle that offers light, dark and system reads the stored choice from localStorage and falls back to matchMedia.
Underline, Pill, Vertical, Icon and Badge Tabs
All of these styles share the same markup. Only the class pattern changes: a bottom border for underlines, a rounded fill for pills, flex-col for vertical lists, and extra children inside the button for icons and badges.
Nielsen Norman Group’s tabs guidance (written in 2024) calls for more than one selection indicator on the active tab. Pair the border or fill with a color change instead of relying on a single signal.
Underline tabs
The tablist carries the baseline, with flex gap-6 border-b border-gray-200, and each tab carries a thicker border that overlaps it. On the tab that means -mb-px border-b-2 border-transparent pb-3 text-sm font-medium. The active state is aria-selected:border-indigo-600 aria-selected:text-indigo-600.
Use border-b-2 on the tab, not border-b. The same guidance warns against single-pixel strokes and poor-contrast line colors.
Skip bold on the active tab. A heavier weight widens the label and shifts every neighbor on each click.
Pill tabs
Pills swap the line for a filled shape.
Add gap-1 to the tablist and rounded-md px-3 py-2 text-sm font-medium to each tab. The active pill takes aria-selected:bg-indigo-100 and aria-selected:text-indigo-700, while inactive tabs get hover:bg-gray-100.
The usual failure is weak color contrast on the inactive labels. Nielsen Norman Group cites MongoDB, where faded tab labels read like disabled text.
Vertical tabs
Wrap the tablist and the panels in one flex row with flex gap-6, which turns the tablist into a fixed-width column. The tablist itself takes w-48 shrink-0 flex-col. Each tab gets border-l-2 border-transparent px-4 py-2 text-left, and the active one adds aria-selected:border-indigo-600 aria-selected:bg-gray-50. Panels need min-w-0 flex-1.
Reserve this layout for settings screens, where the list is the main control. Nielsen Norman Group warns that vertical and bottom lists get overlooked, and it cites Okta’s tabs, placed to the right of the panel.
Tabs with icons
Heroicons, the SVG icon set from the makers of Tailwind CSS, fits tabs without extra setup.
Make the button inline-flex items-center gap-2 and size the icon with size-4 shrink-0. Because the SVG icons draw with currentColor, aria-selected:text-indigo-600 on the button recolors icon and label together.
Heroicons added aria-hidden=”true” to its icons in v1.0.5 (project changelog), so the text label carries the tab’s name. An icon-only tab needs an aria-label, and Nielsen Norman Group advises pairing icons with labels anyway.
Badge tabs
Visa’s design system describes number badges on its mobile tab bar. A badge in the top-right corner of the tab icon shows dynamic information, such as the number of messages.
In Tailwind that is a span after the label with rounded-full px-2 py-0.5 text-xs font-medium, a neutral fill when inactive and aria-selected:bg-indigo-100 when active. Put inline-flex items-center gap-2 on the tab so everything stays aligned.
The badge text joins the tab’s name, so a bare number reads as “Inbox 12”. Add visually hidden text with sr-only, such as “unread messages”.
How to Make Tailwind Tabs Responsive
How many tabs you have decides the approach. Short labels can scroll in a row. A longer set is better off with a select swapped in below the sm breakpoint, and an accordion makes sense when each panel is long. Tailwind’s sm breakpoint starts at 40rem (640px), per its responsive design docs.
This is a responsive design decision before it is a styling one, because every option trades discoverability for space.
Tailwind’s mobile-first breakpoints trip people up here. An unprefixed class applies at every width, and sm: means from the small breakpoint upward, so the select takes sm:hidden and the tablist takes hidden sm:flex.
Select fallback
Below sm, show a native select with the same options as the tabs and an sr-only label. From sm up, show the tablist. One change handler on the select and one click handler on the tabs should both call the same switch function.
The weak spot is that a select is a different control, so screen readers announce a select, not a tab set. Both elements also need to stay in sync when the viewport crosses the breakpoint.
Horizontal scrolling
Add overflow-x-auto and whitespace-nowrap to the tablist so the row scrolls instead of wrapping.
The scrollbar-none utility, documented in the current Tailwind v4 docs, hides the scrollbar while the row still scrolls. It also hides the only cue that more tabs exist.
Nielsen Norman Group makes the same point about overflowing tab lists. Hidden tabs become less discoverable and cost more interaction. It shows Patagonia’s store, where a button scrolled the tab list on narrow viewports.
Keep the last visible tab partly cut off, or scroll the active tab into view when it changes.
Accordion on phones
Nielsen Norman Group finds that accordions work better than tabs on mobile because screen space is limited. It adds that tabs can suit desktop better, since a closed accordion can leave the page looking empty.
If each panel runs longer than a screenful, skip the select and render the sections as an accordion below sm.
Switching orientation at a breakpoint
A sidebar-style list that stacks above the panel on narrow screens takes flex-col md:flex-row on the wrapper and flex-row md:flex-col on the tablist.
The CSS flips, but aria-orientation and the arrow-key mapping do not. The W3C pattern ties aria-orientation to the visual layout, so update it with a matchMedia listener.
When the tab set sits in a narrow card or sidebar, size it by its parent instead. Mark the parent @container and use variants like @md:flex-row, which respond to container width (Tailwind docs).
Where to Get Ready-Made Tailwind Tabs
One tab set doesn’t justify a library, so build it by hand. Past that, it depends on the stack. React has Headless UI or Radix UI Tabs, Vue has Headless UI, and plain HTML projects can use daisyUI, Flowbite or Preline UI.
Tailwind Plus is no longer sold to new customers.
| Source | Behavior comes from | Cost type | Catch |
|---|
| Headless UI | React and Vue components | Free, MIT (npm) | Unstyled, so every class is yours |
| daisyUI | Plugin classes, optional radio inputs | Free, open source | Class names changed in v5 |
| Flowbite | Data attributes plus its JavaScript | Free core, paid Pro | Pro blocks sold separately |
| Preline UI | @preline/tabs plugin | MIT plus Fair Use terms, paid Pro | Redistribution limits |
| Tailwind Plus | el-tab-group web component | Paid, one-time | Closed to new customers |
Tailwind Labs’ own announcement of September 9, 2026 says the company is joining Shopify. It closes sign-ups for new Tailwind Plus customers and lets existing customers keep access.
Several outlets call the move an acquisition, a word the announcement avoids.
Tailwind Plus Elements, which supplies the el-tab-group component, needs a commercial license, per the license file in its npm package. Without an existing license, that rules it out.
Older tutorials still send readers there. The React tabs page on headlessui.com, checked in October 2026, still points to Tailwind Plus for styled examples.
daisyUI v5 renamed several classes, per its upgrade guide. tabs-bordered became tabs-border, tabs-lifted became tabs-lift and tabs-boxed became tabs-box, so snippets written for v4 lose their variant styling.
Preline’s open-source library is free for unlimited projects, and redistribution is allowed under the MIT and Fair Use licenses as long as attribution and license notices stay in place (Preline pricing page). Pro is a lifetime license for a single developer, and it prohibits redistribution, public sharing and use in derivative themes or generators.
shadcn/ui’s Tabs page now offers Base UI (the default), React Aria and Radix UI variants. The Radix variant sends you to the Radix Tabs docs for its API. Radix marks triggers with data-state=”active”, so data-[state=active]: classes style the active tab.
When Tailwind Tabs Are the Wrong Pattern
Tabs are a poor fit when the choices are really pages, when readers need to compare panels side by side, or when the row passes six or seven tabs. They also go wrong when a panel holds state that has to survive a switch.
If the choices are pages, you don’t want tabs. Nielsen Norman Group separates in-page tabs, which swap related content without leaving the page, from navigation tabs, which load other pages. It calls Behance’s “Creatives for Hire” tab a link masquerading as a tab, because it opened a different site.
A row of links between pages is site navigation. Build it with anchors in a nav element and mark the current page with aria-current=”page”, not role=”tab”.
Readers who need two panels at once get a worse deal. Tabs tax short-term memory when information under different tabs must be compared, according to Nielsen Norman Group, which cites Google’s ad settings page as the cautionary example. Put that content on one page with subheadings.
Overflowing rows are the next problem, and design systems that set a limit land in the same place.
| Source | Limit | Wording |
|---|
| IBM Carbon (v10 docs) | No more than six tabs in most scenarios | Guideline, not a hard maximum |
| Zuora Occam | Fewer than seven | “Usually” rule of thumb |
| Nielsen Norman Group | As few as possible | Overflow turns the bar into a carousel |
Carbon’s current v11 guidance no longer states the six-tab figure. It lets line and contained tabs scroll when they overflow and sets its own limits for grid-aware tabs.
Past that point, an accordion built with Tailwind holds long lists of sections better, since its labels can run longer (Nielsen Norman Group).
When one panel matters most, tabs bury it. The default tab gets the attention, and Nielsen Norman Group advises keeping content in the other tabs supplemental rather than critical.
Live state is the last trap. Headless UI’s TabPanel unmounts hidden panels by default, and Radix UI Tabs mounts only the selected content unless forceMount is set. A half-filled form inside a hidden tab is gone when the user switches back, unless the form state lives above the tab set.
Vue projects on Headless UI have their own wrinkle. As of October 2026, npm lists @headlessui/vue at 1.7.23, while the React package moved on to v2. The two do not share a feature set.
Styling hooks differ between the two packages too. The Vue docs page documents data-headlessui-state with ui-selected plugin variants, so test which attribute your installed version emits.
Tailwind Tabs FAQ
How do you open a specific tab from a link or keep it after a reload?
Read the tab id from location.hash on load and pass it to the same switch function the click handler uses. Write the hash back with history.replaceState on each switch, so the Back button does not fill with entries.
In Headless UI, drive selectedIndex and onChange from a query parameter.
Can the panel change fade in with Tailwind classes?
Yes, but the hidden attribute removes the panel with display: none, which blocks an ordinary transition. Tailwind’s starting: variant sets the opening style for an element that goes from display: none to visible, so transition-opacity duration-300 starting:opacity-0 produces a fade.
Add motion-reduce:transition-none for users who ask for less motion.
How do you disable a tab?
On hand-built tabs, set aria-disabled=”true” and style it with the aria-disabled: variant, for example aria-disabled:opacity-50 and aria-disabled:cursor-not-allowed. The click and arrow-key handlers then skip that tab.
Headless UI takes a disabled prop on Tab and skips disabled tabs during keyboard movement, per its docs.
How do you make tabs fill the full width?
Make the tablist flex and give each tab flex-1 text-center, so the tabs share the row in equal widths.
Use sm:flex-1 instead if you want content-sized tabs on phones.
Is it fine to nest one tab set inside another?
Yes, as long as each set has its own ids and its own keydown handler. Put the inner tablist inside an outer tabpanel, and give CSS-only radio groups unique names.
Stop at two levels, because the inner labels start to compete with the outer ones.
How to Test a Tab Set Before Shipping
Test with the keyboard first, a screen reader second, and a narrow screen last.
The order follows dependency. A tab set that traps focus or skips a tab fails the screen reader pass before it starts, and the narrow-screen pass only checks layout.
For the keyboard pass, tab into the tablist, then arrow through every tab. With the screen reader, confirm each tab announces its name and selected state. Then resize across the sm breakpoint and repeat the arrow keys.
Hand-built markup carries a cost, because the team owns the keydown handler after launch.
Verified October 29, 2026. Reopened Tailwind Plus sign-ups, or a v2 release of Headless UI for Vue, would change which source to start from.