# Tabs

> Pill tab list with a sliding thumb and full keyboard support, one named slot per panel.

- **Slug:** `tabs`
- **Kind:** component · **Category:** Navigation
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/design/components/tabs
- **All files in one bundle:** https://viewership.ai/design/components/tabs.bundle.txt
- **Structured JSON:** https://viewership.ai/design/components/tabs.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/tabs/`:

   - `Tabs.astro` (raw: https://viewership.ai/design/raw/tabs/Tabs.astro.txt)

npm packages: none.

## Usage

```astro
---
import Tabs from "../components/viewership/tabs/Tabs.astro";
---

<Tabs />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import Tabs from "./Tabs.astro";
---

<div class="stage">
  <Tabs
    label="Citation report"
    tabs={[
      { id: "chatgpt", label: "ChatGPT" },
      { id: "perplexity", label: "Perplexity" },
      { id: "overviews", label: "AI Overviews" },
    ]}
  >
    <div slot="chatgpt" class="panel">
      <p class="num">38<span>citations this week</span></p>
      <p class="copy">Most came from the rewax guide and two Reddit threads about winter packs.</p>
    </div>
    <div slot="perplexity" class="panel">
      <p class="num">21<span>citations this week</span></p>
      <p class="copy">Perplexity leans on the repair pages and the size chart.</p>
    </div>
    <div slot="overviews" class="panel">
      <p class="num">12<span>citations this week</span></p>
      <p class="copy">AI Overviews cite the trip journal for route questions.</p>
    </div>
  </Tabs>
</div>

<style>
  .stage {
    max-width: 520px;
    margin-inline: auto;
    padding: 32px 20px;
  }
  .panel {
    padding: 24px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .num {
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin: 0;
    font-size: 2.5rem;
    font-weight: 500;
    letter-spacing: -0.03em;
  }
  .num span {
    font-size: 0.875rem;
    font-weight: 400;
    letter-spacing: 0;
    color: var(--vs-ink-3);
  }
  .copy {
    margin: 8px 0 0;
    color: var(--vs-ink-2);
    line-height: 1.5;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` | `{ id: string; label: string }[]` |  | The tabs, in order. Each id names a slot. |
| `label` | `string` |  | Accessible name for the tab list. |
| `selected` | `string` |  | Id of the tab selected on load (default: first). |
| `full` | `boolean` | `false` | Stretch the tab list to the full width. |
| `slot: <id>` | `slot` |  | Panel content for the tab with that id. |
| `vs-change` | `CustomEvent<{ id: string; index: number }>` |  | Fires when the selected tab changes. |

## Source

### `src/components/viewership/tabs/Tabs.astro`

```astro
---
/**
 * Tabs: a pill tab list with a sliding thumb and one panel per tab.
 * Follows the WAI-ARIA tabs pattern: arrow keys move between tabs, Home and
 * End jump to the ends, and the panel switches as focus moves.
 *
 * Each panel is a named slot matching the tab's `id`.
 *
 * @example
 * <Tabs label="Report views" tabs={[{ id: "week", label: "This week" }, { id: "month", label: "This month" }]}>
 *   <div slot="week">…</div>
 *   <div slot="month">…</div>
 * </Tabs>
 */

interface Tab {
  id: string;
  label: string;
}
interface Props {
  tabs: Tab[];
  /** Accessible name for the tab list. */
  label: string;
  /** Id of the tab selected on load (default: first). */
  selected?: string;
  /** Stretch the tab list to the full width. */
  full?: boolean;
  class?: string;
}

const { tabs, label, selected = tabs[0]?.id, full = false, class: className } = Astro.props;
const base = `vs-tabs-${Math.random().toString(36).slice(2, 8)}`;
const panels = await Promise.all(tabs.map((t) => (Astro.slots.has(t.id) ? Astro.slots.render(t.id) : "")));
---

<div class:list={["vs-tabs", { "is-full": full }, className]}>
  <div class="vs-tabs__list" role="tablist" aria-label={label}>
    <span class="vs-tabs__thumb" aria-hidden="true"></span>
    {
      tabs.map((t) => (
        <button
          type="button"
          role="tab"
          class="vs-tabs__tab"
          id={`${base}-tab-${t.id}`}
          aria-controls={`${base}-panel-${t.id}`}
          aria-selected={t.id === selected ? "true" : "false"}
          tabindex={t.id === selected ? 0 : -1}
        >
          {t.label}
        </button>
      ))
    }
  </div>
  {
    tabs.map((t, i) => (
      <div
        class="vs-tabs__panel"
        role="tabpanel"
        id={`${base}-panel-${t.id}`}
        aria-labelledby={`${base}-tab-${t.id}`}
        tabindex="0"
        hidden={t.id !== selected}
        set:html={panels[i]}
      />
    ))
  }
</div>

<style>
  .vs-tabs {
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    font-stretch: var(--vs-font-stretch);
  }
  .vs-tabs__list {
    position: relative;
    display: inline-flex;
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: none;
    padding: 4px;
    gap: 2px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-field);
    box-shadow: var(--vs-shadow-inset-field);
  }
  .vs-tabs.is-full .vs-tabs__list {
    display: flex;
  }
  .vs-tabs.is-full .vs-tabs__tab {
    flex: 1;
  }
  .vs-tabs__thumb {
    position: absolute;
    top: 4px;
    bottom: 4px;
    left: 0;
    width: 0;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
    opacity: 0;
    transition:
      transform var(--vs-duration) var(--vs-ease-out),
      width var(--vs-duration) var(--vs-ease-out);
  }
  .vs-tabs[data-vs-ready] .vs-tabs__thumb {
    opacity: 1;
  }
  .vs-tabs__tab {
    position: relative;
    z-index: 1;
    flex: none;
    min-height: 36px;
    padding: 0 18px;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-2);
    font: inherit;
    font-size: 0.9375rem;
    font-weight: 500;
    white-space: nowrap;
    cursor: pointer;
    transition:
      color var(--vs-duration-fast) ease-out,
      background-color var(--vs-duration-fast) ease-out;
  }
  .vs-tabs__tab:hover {
    color: var(--vs-ink);
  }
  .vs-tabs__tab[aria-selected="true"] {
    color: var(--vs-ink);
  }
  /* Before the script runs, the selected tab draws its own fill. */
  .vs-tabs:not([data-vs-ready]) .vs-tabs__tab[aria-selected="true"] {
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
  }
  .vs-tabs__tab:focus-visible,
  .vs-tabs__panel:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .vs-tabs__panel {
    margin-top: 20px;
    border-radius: var(--vs-radius-card);
    animation: vs-fade-up var(--vs-duration) var(--vs-ease-out);
  }
</style>

<script>
  function init(root: HTMLElement) {
    const list = root.querySelector<HTMLElement>(".vs-tabs__list")!;
    const thumb = root.querySelector<HTMLElement>(".vs-tabs__thumb")!;
    const tabs = [...root.querySelectorAll<HTMLButtonElement>(".vs-tabs__tab")];
    const panels = tabs.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.getAttribute("aria-controls")!)}`));

    const place = (tab: HTMLElement) => {
      thumb.style.width = `${tab.offsetWidth}px`;
      thumb.style.transform = `translateX(${tab.offsetLeft}px)`;
    };

    const select = (i: number, focus = true) => {
      tabs.forEach((t, j) => {
        const on = i === j;
        t.setAttribute("aria-selected", String(on));
        t.tabIndex = on ? 0 : -1;
        if (panels[j]) panels[j]!.hidden = !on;
      });
      place(tabs[i]);
      if (focus) tabs[i].focus();
      tabs[i].scrollIntoView({ block: "nearest", inline: "nearest" });
      root.dispatchEvent(new CustomEvent("vs-change", { detail: { id: tabs[i].id, index: i }, bubbles: true }));
    };

    tabs.forEach((t, i) => t.addEventListener("click", () => select(i, false)));
    list.addEventListener("keydown", (e) => {
      const i = tabs.indexOf(document.activeElement as HTMLButtonElement);
      if (i < 0) return;
      const n = tabs.length;
      const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
      if (next === undefined) return;
      e.preventDefault();
      select(next);
    });

    const current = () => tabs.find((t) => t.getAttribute("aria-selected") === "true") ?? tabs[0];
    // Place without animating, then let later moves slide.
    thumb.style.transition = "none";
    place(current());
    void thumb.offsetWidth; // commit the position before re-enabling the slide
    thumb.style.transition = "";
    new ResizeObserver(() => place(current())).observe(list);
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-tabs:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
