Open demo

CSS code

HTML
<div class="utabs">
  <div role="tablist" aria-label="Product sections">
    <button type="button" role="tab" id="ut-tab-0" aria-selected="true" aria-controls="ut-panel-0">Overview</button>
    <button type="button" role="tab" id="ut-tab-1" aria-selected="false" aria-controls="ut-panel-1" tabindex="-1">Features</button>
    <button type="button" role="tab" id="ut-tab-2" aria-selected="false" aria-controls="ut-panel-2" tabindex="-1">Pricing</button>
    <button type="button" role="tab" id="ut-tab-3" aria-selected="false" aria-controls="ut-panel-3" tabindex="-1">FAQ</button>
    <span class="ink" aria-hidden="true"></span>
  </div>
  <div role="tabpanel" id="ut-panel-0" aria-labelledby="ut-tab-0" tabindex="0"><p>The classic underline tab, upgraded: the indicator is a single element that slides and resizes to match the active tab, instead of jumping between borders.</p></div>
  <div role="tabpanel" id="ut-panel-1" aria-labelledby="ut-tab-1" tabindex="0" hidden><p>Keyboard accessible, ARIA-labelled, and the sliding ink bar measures the real tab width — long labels and short labels both work.</p></div>
  <div role="tabpanel" id="ut-panel-2" aria-labelledby="ut-tab-2" tabindex="0" hidden><p>No dependencies. A short script handles selection, arrow keys, panels, and the indicator geometry.</p></div>
  <div role="tabpanel" id="ut-panel-3" aria-labelledby="ut-tab-3" tabindex="0" hidden><p>Copy the tablist, panels, and script into any page. Colors live in four custom properties on .utabs.</p></div>
</div>
CSS
/* Underline tabs: one .ink element slides and resizes under the selected tab. */
.utabs{--ut-accent:#2563eb;--ut-muted:#677389;--ut-text:#1a222f;--ut-line:#edf0f5;
  box-sizing:border-box;width:100%;max-width:560px;background:#fff;border:1px solid #e7eaf0;border-radius:16px;padding:28px 30px;
  box-shadow:0 14px 34px -20px rgba(20,30,50,.18)}
.utabs [role=tablist]{position:relative;display:flex;gap:4px;margin-bottom:20px;overflow-x:auto;scrollbar-width:none;
  box-shadow:inset 0 -2px 0 var(--ut-line)}
.utabs [role=tab]{flex:0 0 auto;border:0;margin:0;background:none;padding:11px 16px;font:inherit;font-size:.9rem;font-weight:600;
  color:var(--ut-muted);cursor:pointer;border-radius:6px 6px 0 0;transition:color .15s}
.utabs [role=tab]:hover{color:var(--ut-text)}
.utabs [role=tab][aria-selected=true]{color:var(--ut-accent)}
.utabs .ink{position:absolute;left:0;bottom:0;width:0;height:2.5px;background:var(--ut-accent);border-radius:99px;
  transition:left .25s cubic-bezier(.4,.1,.2,1),width .25s cubic-bezier(.4,.1,.2,1)}
.utabs [role=tabpanel]{border-radius:6px}
.utabs [role=tabpanel] p{margin:0;font-size:.9rem;line-height:1.7;color:#525f78}
.utabs [role=tab]:focus-visible{outline:2px solid var(--ut-accent);outline-offset:-2px}
.utabs [role=tabpanel]:focus-visible{outline:2px solid var(--ut-accent);outline-offset:4px}
.utabs [hidden]{display:none}
@media (max-width:480px){.utabs{padding:22px 18px}.utabs [role=tab]{padding:11px 10px}}
@media (prefers-reduced-motion:reduce){.utabs [role=tab],.utabs .ink{transition:none}}
JavaScript
/* WAI-ARIA tabs: click or use the arrow keys to select, Home/End jump to the first/last tab.
   Only the selected tab is in the Tab order (roving tabindex). The ink follows the selected tab. */
document.querySelectorAll('.utabs').forEach(root => {
  const list = root.querySelector('[role=tablist]'), ink = root.querySelector('.ink');
  const tabs = [...list.querySelectorAll('[role=tab]')];
  const current = () => tabs.find(t => t.getAttribute('aria-selected') === 'true');
  const place = t => { ink.style.left = t.offsetLeft + 'px'; ink.style.width = t.offsetWidth + 'px'; };
  const select = (tab, focus) => {
    tabs.forEach(t => {
      const on = t === tab;
      t.setAttribute('aria-selected', on);
      t.tabIndex = on ? 0 : -1;
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    });
    if (focus) tab.focus();
    place(tab);
  };
  list.addEventListener('click', e => { const t = e.target.closest('[role=tab]'); if (t) select(t); });
  list.addEventListener('keydown', e => {
    const i = tabs.indexOf(e.target), n = tabs.length;
    const j = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
    if (i < 0 || j === undefined) return;
    e.preventDefault();
    select(tabs[j % n], true);
  });
  ink.style.transition = 'none'; place(current()); ink.offsetWidth; ink.style.transition = '';
  new ResizeObserver(() => place(current())).observe(list);
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<div class="utabs">
  <ul class="nav" role="tablist" aria-label="Product sections">
    <li class="nav-item" role="presentation"><button class="nav-link active" type="button" id="ut-tab-0" data-bs-toggle="tab" data-bs-target="#ut-panel-0" role="tab" aria-controls="ut-panel-0" aria-selected="true">Overview</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="ut-tab-1" data-bs-toggle="tab" data-bs-target="#ut-panel-1" role="tab" aria-controls="ut-panel-1" aria-selected="false" tabindex="-1">Features</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="ut-tab-2" data-bs-toggle="tab" data-bs-target="#ut-panel-2" role="tab" aria-controls="ut-panel-2" aria-selected="false" tabindex="-1">Pricing</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="ut-tab-3" data-bs-toggle="tab" data-bs-target="#ut-panel-3" role="tab" aria-controls="ut-panel-3" aria-selected="false" tabindex="-1">FAQ</button></li>
    <li class="ink" role="presentation" aria-hidden="true"></li>
  </ul>
  <div class="tab-content">
    <div class="tab-pane active" id="ut-panel-0" role="tabpanel" aria-labelledby="ut-tab-0" tabindex="0"><p>The classic underline tab, upgraded: the indicator is a single element that slides and resizes to match the active tab, instead of jumping between borders.</p></div>
    <div class="tab-pane" id="ut-panel-1" role="tabpanel" aria-labelledby="ut-tab-1" tabindex="0"><p>Keyboard accessible, ARIA-labelled, and the sliding ink bar measures the real tab width — long labels and short labels both work.</p></div>
    <div class="tab-pane" id="ut-panel-2" role="tabpanel" aria-labelledby="ut-tab-2" tabindex="0"><p>No dependencies. A short script handles selection, arrow keys, panels, and the indicator geometry.</p></div>
    <div class="tab-pane" id="ut-panel-3" role="tabpanel" aria-labelledby="ut-tab-3" tabindex="0"><p>Copy the tablist, panels, and script into any page. Colors live in four custom properties on .utabs.</p></div>
  </div>
</div>
CSS
/* Bootstrap .nav with the Tab plugin, restyled through the --bs-nav-link-* variables. One .ink element slides under the active tab. */
.utabs{--ut-accent:#2563eb;--ut-muted:#677389;--ut-text:#1a222f;--ut-line:#edf0f5;
  width:100%;max-width:560px;background:#fff;border:1px solid #e7eaf0;border-radius:16px;padding:28px 30px;
  box-shadow:0 14px 34px -20px rgba(20,30,50,.18)}
.utabs .nav{--bs-nav-link-padding-x:16px;--bs-nav-link-padding-y:11px;--bs-nav-link-font-size:.9rem;--bs-nav-link-font-weight:600;
  --bs-nav-link-color:var(--ut-muted);--bs-nav-link-hover-color:var(--ut-text);
  position:relative;flex-wrap:nowrap;gap:4px;margin-bottom:20px;overflow-x:auto;scrollbar-width:none;box-shadow:inset 0 -2px 0 var(--ut-line)}
.utabs .nav-link{border-radius:6px 6px 0 0;line-height:normal;white-space:nowrap}
.utabs .nav-link.active{color:var(--ut-accent)}
.utabs .ink{position:absolute;left:0;bottom:0;width:0;height:2.5px;background:var(--ut-accent);border-radius:99px;
  transition:left .25s cubic-bezier(.4,.1,.2,1),width .25s cubic-bezier(.4,.1,.2,1)}
.utabs .tab-pane{border-radius:6px}
.utabs .tab-pane p{margin:0;font-size:.9rem;line-height:1.7;color:#525f78}
.utabs .nav-link:focus-visible{box-shadow:none;outline:2px solid var(--ut-accent);outline-offset:-2px}
.utabs .tab-pane:focus-visible{outline:2px solid var(--ut-accent);outline-offset:4px}
@media (max-width:480px){.utabs{padding:22px 18px}.utabs .nav{--bs-nav-link-padding-x:10px}}
@media (prefers-reduced-motion:reduce){.utabs .nav-link,.utabs .ink{transition:none}}
JavaScript
/* The Tab plugin handles selection, arrow keys, Home/End and roving tabindex. This only moves the ink. */
document.querySelectorAll('.utabs').forEach(root => {
  const nav = root.querySelector('.nav'), ink = root.querySelector('.ink');
  const place = t => { ink.style.left = t.offsetLeft + 'px'; ink.style.width = t.offsetWidth + 'px'; };
  nav.addEventListener('show.bs.tab', e => place(e.target));
  ink.style.transition = 'none'; place(nav.querySelector('.nav-link.active')); ink.offsetWidth; ink.style.transition = '';
  new ResizeObserver(() => place(nav.querySelector('.nav-link.active'))).observe(nav);
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="utabs w-full max-w-[560px] rounded-2xl border border-[#e7eaf0] bg-white px-[30px] py-7 shadow-[0_14px_34px_-20px_rgba(20,30,50,.18)] max-[480px]:px-[18px] max-[480px]:py-[22px]">
  <div role="tablist" aria-label="Product sections" class="relative mb-5 flex gap-1 overflow-x-auto shadow-[inset_0_-2px_0_var(--color-ut-line)] [scrollbar-width:none]">
    <button type="button" role="tab" id="ut-tab-0" aria-selected="true" aria-controls="ut-panel-0" class="shrink-0 cursor-pointer rounded-t-md px-4 py-[11px] text-[.9rem] font-semibold text-ut-muted transition-colors duration-150 hover:text-ut-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ut-accent aria-selected:text-ut-accent motion-reduce:transition-none max-[480px]:px-2.5">Overview</button>
    <button type="button" role="tab" id="ut-tab-1" aria-selected="false" aria-controls="ut-panel-1" tabindex="-1" class="shrink-0 cursor-pointer rounded-t-md px-4 py-[11px] text-[.9rem] font-semibold text-ut-muted transition-colors duration-150 hover:text-ut-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ut-accent aria-selected:text-ut-accent motion-reduce:transition-none max-[480px]:px-2.5">Features</button>
    <button type="button" role="tab" id="ut-tab-2" aria-selected="false" aria-controls="ut-panel-2" tabindex="-1" class="shrink-0 cursor-pointer rounded-t-md px-4 py-[11px] text-[.9rem] font-semibold text-ut-muted transition-colors duration-150 hover:text-ut-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ut-accent aria-selected:text-ut-accent motion-reduce:transition-none max-[480px]:px-2.5">Pricing</button>
    <button type="button" role="tab" id="ut-tab-3" aria-selected="false" aria-controls="ut-panel-3" tabindex="-1" class="shrink-0 cursor-pointer rounded-t-md px-4 py-[11px] text-[.9rem] font-semibold text-ut-muted transition-colors duration-150 hover:text-ut-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ut-accent aria-selected:text-ut-accent motion-reduce:transition-none max-[480px]:px-2.5">FAQ</button>
    <span class="ink absolute bottom-0 left-0 h-[2.5px] w-0 rounded-full bg-ut-accent transition-[left,width] duration-[250ms] ease-[cubic-bezier(.4,.1,.2,1)] motion-reduce:transition-none" aria-hidden="true"></span>
  </div>
  <div role="tabpanel" id="ut-panel-0" aria-labelledby="ut-tab-0" tabindex="0" class="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ut-accent"><p class="text-[.9rem] leading-[1.7] text-[#525f78]">The classic underline tab, upgraded: the indicator is a single element that slides and resizes to match the active tab, instead of jumping between borders.</p></div>
  <div role="tabpanel" id="ut-panel-1" aria-labelledby="ut-tab-1" tabindex="0" hidden class="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ut-accent"><p class="text-[.9rem] leading-[1.7] text-[#525f78]">Keyboard accessible, ARIA-labelled, and the sliding ink bar measures the real tab width — long labels and short labels both work.</p></div>
  <div role="tabpanel" id="ut-panel-2" aria-labelledby="ut-tab-2" tabindex="0" hidden class="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ut-accent"><p class="text-[.9rem] leading-[1.7] text-[#525f78]">No dependencies. A short script handles selection, arrow keys, panels, and the indicator geometry.</p></div>
  <div role="tabpanel" id="ut-panel-3" aria-labelledby="ut-tab-3" tabindex="0" hidden class="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ut-accent"><p class="text-[.9rem] leading-[1.7] text-[#525f78]">Copy the tablist, panels, and script into any page. Colors live in four custom properties on .utabs.</p></div>
</div>
CSS
@theme {
  --color-ut-accent: #2563eb;
  --color-ut-muted: #677389;
  --color-ut-text: #1a222f;
  --color-ut-line: #edf0f5;
}
JavaScript
/* WAI-ARIA tabs: click or use the arrow keys to select, Home/End jump to the first/last tab.
   Only the selected tab is in the Tab order (roving tabindex). The ink follows the selected tab. */
document.querySelectorAll('.utabs').forEach(root => {
  const list = root.querySelector('[role=tablist]'), ink = root.querySelector('.ink');
  const tabs = [...list.querySelectorAll('[role=tab]')];
  const current = () => tabs.find(t => t.getAttribute('aria-selected') === 'true');
  const place = t => { ink.style.left = t.offsetLeft + 'px'; ink.style.width = t.offsetWidth + 'px'; };
  const select = (tab, focus) => {
    tabs.forEach(t => {
      const on = t === tab;
      t.setAttribute('aria-selected', on);
      t.tabIndex = on ? 0 : -1;
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    });
    if (focus) tab.focus();
    place(tab);
  };
  list.addEventListener('click', e => { const t = e.target.closest('[role=tab]'); if (t) select(t); });
  list.addEventListener('keydown', e => {
    const i = tabs.indexOf(e.target), n = tabs.length;
    const j = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
    if (i < 0 || j === undefined) return;
    e.preventDefault();
    select(tabs[j % n], true);
  });
  ink.style.transition = 'none'; place(current()); ink.offsetWidth; ink.style.transition = '';
  new ResizeObserver(() => place(current())).observe(list);
});

About this set

A white card with four text tabs, Overview, Features, Pricing and FAQ, over a thin grey baseline. The selected tab turns blue and a single blue ink bar slides and resizes to sit exactly under it, instead of the underline jumping from one border to the next. Use it for product pages, documentation and settings where the tabs are short words and the page around them is quiet. The markup is a tablist of buttons, one tabpanel per tab and an empty span for the ink. A short script sets aria-selected, the roving tabindex and the hidden attribute on the panels, then measures the selected tab’s offsetLeft and offsetWidth to place the ink; a ResizeObserver repositions it when fonts load or the layout changes, so long and short labels both line up. The tab row scrolls sideways rather than overflowing if the card gets narrow. The Bootstrap version is a .nav run by the Tab plugin with the same ink moved on show.bs.tab, and the Tailwind version styles the states with aria-selected utilities.

What’s included

  • One ink bar that slides and resizes to the selected tab
  • Full WAI-ARIA tabs pattern with arrow keys, Home and End
  • Ink position recalculated with a ResizeObserver
  • Tab row scrolls instead of overflowing on narrow screens
  • Muted tab text darkened to meet 4.5:1

Accessibility

Buttons with role="tab" sit in a labelled tablist, each controls a tabpanel that is labelled by its tab, and aria-selected tracks the choice. Only the selected tab is in the Tab order; Left and Right (or Up and Down) move between tabs and select them, and Home and End jump to the first and last tab. Tabs and panels show a 2px blue outline on keyboard focus, and the ink and colour transitions are switched off under prefers-reduced-motion.

Customise it

Change --ut-accent, --ut-muted, --ut-text and --ut-line on .utabs in the plain CSS and Bootstrap versions (Bootstrap also reads the --bs-nav-link-* variables on .nav). In Tailwind edit the --color-ut-* theme colours and the px- and py- utilities on the tabs.