komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Components & Islands••By Komal Nakrani

Accessible Astro Tabs Component

Copy-paste Astro tabs following the ARIA tabs pattern: tablist, tab and tabpanel roles, arrow keys, and server-rendered panels that work before JS loads.

#Astro#Components#Tabs#Accessibility

These Astro tabs follow the WAI-ARIA tabs pattern: a tablist of tab buttons, each controlling a tabpanel, with arrow keys moving between tabs. Panels are rendered on the server, so content is in the HTML for search engines and readers, and a short script handles switching. Usage is two components: <Tabs> with labels, and one <TabPanel> per tab.

Key Takeaways

  • Only the active tab is in the tab order (tabindex="0"); arrow keys move between tabs.
  • Every panel is server-rendered; inactive ones get hidden.
  • The script is under 1 KB and runs once per page for every tab set.
  • Use tabs for parallel content (package managers, frameworks), not for sequential steps.

The components

---
// src/components/Tabs.astro
interface Props { labels: string[]; id: string }
const { labels, id } = Astro.props;
---
<div class="tabs" data-tabs>
  <div class="tab-list" role="tablist">
    {labels.map((label, i) => (
      <button
        type="button"
        role="tab"
        id={`${id}-tab-${i}`}
        aria-controls={`${id}-panel-${i}`}
        aria-selected={i === 0 ? 'true' : 'false'}
        tabindex={i === 0 ? 0 : -1}
        class="tab"
      >
        {label}
      </button>
    ))}
  </div>
  <slot />
</div>

<style>
  .tab-list { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--color-border, #e5e4df); }
  .tab {
    padding: 0.6rem 1rem; font: inherit; background: none; border: 0;
    border-bottom: 2px solid transparent; cursor: pointer; color: inherit;
  }
  .tab[aria-selected='true'] { border-bottom-color: currentColor; font-weight: 600; }
  .tab:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
</style>

<script>
  document.querySelectorAll<HTMLElement>('[data-tabs]').forEach((root) => {
    const tabs = [...root.querySelectorAll<HTMLButtonElement>('[role="tab"]')];

    const select = (index: number) => {
      tabs.forEach((tab, i) => {
        const selected = i === index;
        tab.setAttribute('aria-selected', String(selected));
        tab.tabIndex = selected ? 0 : -1;
        document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = !selected;
      });
      tabs[index].focus();
    };

    tabs.forEach((tab, i) => {
      tab.addEventListener('click', () => select(i));
      tab.addEventListener('keydown', (e) => {
        const last = tabs.length - 1;
        if (e.key === 'ArrowRight') select(i === last ? 0 : i + 1);
        else if (e.key === 'ArrowLeft') select(i === 0 ? last : i - 1);
        else if (e.key === 'Home') select(0);
        else if (e.key === 'End') select(last);
        else return;
        e.preventDefault();
      });
    });
  });
</script>
---
// src/components/TabPanel.astro
interface Props { id: string; index: number }
const { id, index } = Astro.props;
---
<div
  role="tabpanel"
  id={`${id}-panel-${index}`}
  aria-labelledby={`${id}-tab-${index}`}
  tabindex="0"
  hidden={index !== 0}
  class="tab-panel"
>
  <slot />
</div>

<style>
  .tab-panel { padding: 1rem 0; }
</style>

Using it

---
import Tabs from '../components/Tabs.astro';
import TabPanel from '../components/TabPanel.astro';
---
<Tabs id="install" labels={['npm', 'pnpm', 'Bun']}>
  <TabPanel id="install" index={0}><pre>npm create astro@latest</pre></TabPanel>
  <TabPanel id="install" index={1}><pre>pnpm create astro@latest</pre></TabPanel>
  <TabPanel id="install" index={2}><pre>bun create astro@latest</pre></TabPanel>
</Tabs>

Use a unique id for each tab set on a page.

Design notes

  • Panels in the HTML. Hidden panels are still in the source, so their text is indexable and works with find-in-page once revealed.
  • Focusable panels. tabindex="0" on panels lets keyboard users reach panel content that has no links.
  • Automatic activation. Arrow keys switch panels immediately. For panels that load something heavy, switch to manual activation: arrows move focus and Enter or Space selects.

Remembering the choice

For install instructions, readers often want the same package manager everywhere. Store the label in localStorage and select it on load:

const saved = localStorage.getItem('tabs:install');
const index = saved ? tabs.findIndex((t) => t.textContent?.trim() === saved) : -1;
if (index > 0) select(index);

and save it inside select(). Wrap both in try/catch if storage may be blocked.

More component patterns are in the Astro components guide, and the directives cheatsheet covers script processing, and the zero-JS alternative for collapsible content is the accordion component. See the WAI-ARIA tabs pattern for the full keyboard model.

FAQ

Are tabs bad for SEO?

No, as long as panel content is in the HTML, as it is here. Avoid tabs that fetch content only on click.

Can I use these tabs in MDX?

Yes. Import Tabs and TabPanel at the top of the MDX file and use them like any component.