Underline Tabs
A keyboard-accessible tab strip with the active tab underlined.
6 frameworksIntermediate
A single underline bar that slides between tabs as selection moves.
<!--
One underline bar slides between tabs. The script measures the active tab's
offsetLeft/offsetWidth and writes them to the indicator; the CSS transition
animates the slide and is dropped under prefers-reduced-motion.
-->
<div class="max-w-2xl" data-tabs-animated>
<div class="relative">
<div class="flex gap-6 overflow-x-auto border-b border-gray-200 dark:border-gray-800" role="tablist" aria-label="Project phase">
<button class="whitespace-nowrap px-0.5 py-3 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 motion-reduce:transition-none aria-selected:text-blue-600 dark:text-gray-400 dark:hover:text-gray-100 dark:focus-visible:ring-blue-400 dark:aria-selected:text-blue-400" type="button" role="tab" id="an-design" aria-controls="an-panel-design" aria-selected="true" tabindex="0">Design</button>
<button class="whitespace-nowrap px-0.5 py-3 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 motion-reduce:transition-none aria-selected:text-blue-600 dark:text-gray-400 dark:hover:text-gray-100 dark:focus-visible:ring-blue-400 dark:aria-selected:text-blue-400" type="button" role="tab" id="an-build" aria-controls="an-panel-build" aria-selected="false" tabindex="-1">Build</button>
<button class="whitespace-nowrap px-0.5 py-3 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 motion-reduce:transition-none aria-selected:text-blue-600 dark:text-gray-400 dark:hover:text-gray-100 dark:focus-visible:ring-blue-400 dark:aria-selected:text-blue-400" type="button" role="tab" id="an-launch" aria-controls="an-panel-launch" aria-selected="false" tabindex="-1">Launch</button>
</div>
<span data-indicator aria-hidden="true" class="absolute bottom-0 left-0 h-0.5 w-0 bg-blue-600 transition-[left,width] duration-300 ease-out motion-reduce:transition-none dark:bg-blue-400"></span>
</div>
<div class="pt-4 text-sm leading-relaxed text-gray-600 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:text-gray-300 dark:focus-visible:ring-blue-400" role="tabpanel" id="an-panel-design" aria-labelledby="an-design" tabindex="0"><p>Wireframes approved, hi-fi mocks in review.</p></div>
<div class="pt-4 text-sm leading-relaxed text-gray-600 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:text-gray-300 dark:focus-visible:ring-blue-400" role="tabpanel" id="an-panel-build" aria-labelledby="an-build" tabindex="0" hidden><p>Two of five milestones shipped this sprint.</p></div>
<div class="pt-4 text-sm leading-relaxed text-gray-600 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:text-gray-300 dark:focus-visible:ring-blue-400" role="tabpanel" id="an-panel-launch" aria-labelledby="an-launch" tabindex="0" hidden><p>Targeting the last week of the quarter.</p></div>
</div>
<script>
(function () {
document.querySelectorAll('[data-tabs-animated]').forEach(function (root) {
var tabs = [].slice.call(root.querySelectorAll('[role="tab"]'));
var indicator = root.querySelector('[data-indicator]');
function panelFor(t) { return document.getElementById(t.getAttribute('aria-controls')); }
function move(tab) {
if (indicator) { indicator.style.left = tab.offsetLeft + 'px'; indicator.style.width = tab.offsetWidth + 'px'; }
}
function select(i, focus) {
tabs.forEach(function (t, n) {
var on = n === i;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
var p = panelFor(t);
if (p) p.hidden = !on;
if (on) move(t);
});
if (focus) tabs[i].focus();
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { select(i, false); });
t.addEventListener('keydown', function (e) {
var n = -1;
if (e.key === 'ArrowRight') n = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft') n = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') n = 0;
else if (e.key === 'End') n = tabs.length - 1;
else return;
e.preventDefault();
select(n, true);
});
});
var initial = tabs.filter(function (t) { return t.getAttribute('aria-selected') === 'true'; })[0] || tabs[0];
if (initial) move(initial);
window.addEventListener('resize', function () { if (initial) move(root.querySelector('[aria-selected="true"]') || initial); });
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | TabItem[] | - | The array of items to render. |
defaultTabId | string | - | Id of the item expanded on mount. |
onSelect | (id: string) => void | - | Fired with the menu item the user chose. |
className | string | - | Additional classes merged onto the root element. |
One shared indicator is measured from the active tab's `offsetLeft`/`offsetWidth` after layout, so it tracks the tab through wrapping and font swaps rather than hard-coding positions. The slide honours `motion-reduce`, snapping instantly for users who ask for less motion - keep that guard if you retune the duration.