# Tabs (Moonarc)

Tabs with an indicator that slides to the selected one and panels that fade in. Zero JavaScript: the tabs are radio inputs, so keyboard and selection are native, and :has() turns the checked index into the indicator's position.

- Import: `import Tabs from '@moonarc/core/Tabs'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/tabs.json`
- Tier A · category ui · trigger click
- Readout: `<Tabs direction>`
- Browser support: widely (Chrome 117 · Firefox 129 · Safari 17.5); elsewhere: panels switch without the fade (or the slide)
- Measured cost: 0 B JS (CSS 8.0 kB raw)
- Page: https://moonarc.dev/components/tabs/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `tabs` | `{ label: string; body: string }[]` | none | Up to eight tabs; body is an HTML string. |
| `id` | `string` | none | Radio group name and id prefix; unique per page. |
| `label` | `string` | none | Accessible name of the radio group ("Package manager"); read before the first tab. |
| `active` | `number` | `0` | Index selected on load. |
| `direction` | `boolean` | `false` | Panels slide sideways in the direction of the change (3 → 1 comes in from the left, and from the right under dir="rtl") instead of fading in place. The panels sit in one track as tall as the tallest of them. |

## Usage

```astro
<Tabs
  id="install"
  tabs={[
    { label: 'npm', body: '<pre>npx astro add moonarc</pre>' },
    { label: 'pnpm', body: '<pre>pnpm astro add moonarc</pre>' },
    { label: 'copy', body: '<pre>npx shadcn@latest add …</pre>' },
  ]}
/>

<!-- panels slide the way you moved -->
<Tabs id="features" direction tabs={features} />
```

## Reduced motion

The indicator jumps; panels switch instantly.

## With ClientRouter

CSS-only; the selection lives in the radio inputs and resets with the page.

## Craft

- Radios, not buttons with aria-selected: arrow keys, focus and exactly-one-selected are free. The group says so with role="radiogroup": a tablist promises aria-selected and roving focus that only a script can keep, and a screen reader would announce a tab it then cannot find.
- Equal-width columns make the indicator a translate of whole widths: one custom property, no measuring. Under dir="rtl" the first tab is on the right and the translate changes sign; the switch reads the dir attribute, because Chrome has :dir() only from 120 and a build that targets older engines rewrites it into a guess from lang.
- The indicator moves on the preset spring; the incoming panel travels 4 px (--ma-travel-hover), enough to read as arriving, too little to read as a slide.
- Inputs are visually clipped and stay focusable; display: none would remove them from the tab order.
- direction needs no memory of the previous tab: the panels are one horizontal track translated by the checked index, so the slide is always toward the selection. The clip sits on the component because a track that clipped itself would carry the clip along as it moved. Panels off screen turn visibility: hidden when the slide ends (visibility interpolates as visible for the whole transition), so Tab never lands in a hidden panel.

## Replaces

- Tabs (Radix / shadcn)
- Tabs (Aceternity)
- AnimatedTabs

## Source

```astro
---
/**
 * Tabs — a sliding indicator between tabs, zero JS.
 *
 * Tabs are radio inputs (arrow keys, focus, one selected: native). :has()
 * turns the checked index into a custom property; the indicator translates
 * by it on the active preset's curve and the matching panel is shown with a
 * @starting-style entrance. Up to eight tabs. `direction` lays the panels
 * out as one horizontal track that translates by the same index, so the
 * panel slides the way you moved — 3 → 1 comes in from the left — with no
 * knowledge of the previous tab; the track is as tall as its tallest panel
 * and the panels off-screen are visibility: hidden once the slide ends.
 * Right to left, tab 1 sits on the right and both movements run the other
 * way.
 */
import type { HTMLAttributes } from 'astro/types';

interface Tab {
  label: string;
  /** HTML string. */
  body: string;
}

interface Props extends HTMLAttributes<'div'> {
  tabs: Tab[];
  /** Radio group name and id prefix; unique per page. */
  id: string;
  /** Accessible name of the group, e.g. "Package manager". */
  label?: string;
  /** Index selected on load. */
  active?: number;
  /** Panels slide sideways in the direction of the change instead of fading in place. */
  direction?: boolean;
}

const { tabs, id, label, active = 0, direction = false, class: className, style, ...rest } = Astro.props;
const list = tabs.slice(0, 8);
// the caller's style joins the tab count instead of replacing it (the columns and the indicator's width read it)
const vars = [`--ma-tabs-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-tabs', className]} data-direction={direction ? '' : undefined} style={vars} {...rest}>
  <div class="ma-tabs__list" role="radiogroup" aria-label={label}>
    {
      list.map((t, i) => (
        <>
          <input type="radio" name={id} id={`${id}-${i}`} class="ma-tabs__input" checked={i === active} />
          <label for={`${id}-${i}`} class="ma-tabs__tab">{t.label}</label>
        </>
      ))
    }
    <span class="ma-tabs__indicator" aria-hidden="true"></span>
  </div>
  <div class="ma-tabs__panels">
    {list.map((t) => <div class="ma-tabs__panel" set:html={t.body} />)}
  </div>
</div>

<style is:global>
  @layer components {
    :where(.ma-tabs__list) {
      position: relative;
      display: grid;
      grid-template-columns: repeat(var(--ma-tabs-n, 2), 1fr);
      border-bottom: 1px solid var(--ma-edge);
    }
    :where(.ma-tabs__input) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    :where(.ma-tabs__tab) {
      padding: 0.625rem 0.75rem;
      text-align: center;
      cursor: pointer;
      opacity: var(--ma-dim, 0.7);
      font-weight: 500;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-tabs__tab:hover),
    :where(.ma-tabs__input:checked + .ma-tabs__tab) {
      opacity: 1;
    }
    :where(.ma-tabs__input:focus-visible + .ma-tabs__tab) {
      outline: 2px solid currentColor;
      outline-offset: -2px;
      border-radius: 0.375rem;
    }
    :where(.ma-tabs__indicator) {
      position: absolute;
      inset-inline-start: 0;
      bottom: -1px;
      width: calc(100% / var(--ma-tabs-n, 2));
      height: 2px;
      background: currentColor;
      translate: calc(var(--ma-tab, 0) * 100% * var(--ma-tabs-dir, 1)) 0;
      transition: translate var(--ma-duration) var(--ma-ease);
    }
    /* Right to left the first tab is on the right, so the indicator (and the direction track) translate the other
       way. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets older engines rewrites it
       into a guess from lang, which a page that sets only dir never matches. An ltr island one level inside an rtl
       page is set back. */
    :where([dir='rtl'] .ma-tabs, .ma-tabs[dir='rtl']) { --ma-tabs-dir: -1; }
    :where([dir='rtl'] [dir='ltr'] .ma-tabs, [dir='rtl'] .ma-tabs[dir='ltr']) { --ma-tabs-dir: 1; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked)) { --ma-tab: 1; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked)) { --ma-tab: 2; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked)) { --ma-tab: 3; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked)) { --ma-tab: 4; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked)) { --ma-tab: 5; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked)) { --ma-tab: 6; }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked)) { --ma-tab: 7; }
    :where(.ma-tabs__panel) {
      display: none;
      padding-top: 1rem;
      opacity: 1;
      translate: 0 0;
      transition: opacity var(--ma-duration) var(--ma-ease), translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
    :where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
      display: block;
    }
    @starting-style {
      :where(.ma-tabs__panel) {
        opacity: 0;
        translate: 0 var(--ma-travel-hover, 4px);
      }
    }
    /* direction: every panel is laid out in one row of equal columns; the row translates by the checked index,
       so the slide goes the way the selection moved. Off-screen panels hide once the slide has ended (visibility
       interpolates as visible for the whole transition) so focus never lands in them. The clip sits on the root:
       a row that clips itself and then translates carries its clip along, and every panel after the first was cut
       away. */
    :where(.ma-tabs[data-direction]) {
      overflow-x: clip;
    }
    :where(.ma-tabs[data-direction] .ma-tabs__panels) {
      display: grid;
      grid-auto-flow: column;
      grid-auto-columns: 100%;
      translate: calc(var(--ma-tab, 0) * -100% * var(--ma-tabs-dir, 1)) 0;
      transition: translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-tabs[data-direction] .ma-tabs__panel) {
      display: block;
      visibility: hidden;
      opacity: 0.4;
      translate: 0 0;
      transition:
        visibility var(--ma-duration),
        opacity var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
    :where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
      visibility: visible;
      opacity: 1;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-tabs__indicator),
      :where(.ma-tabs__panel),
      :where(.ma-tabs[data-direction] .ma-tabs__panels) {
        transition: none;
      }
    }
  }
</style>

```
