# Code Tabs (Moonarc)

A code block with tabs (for example npm, pnpm, bun) and a copy button per panel. Highlighted at build by Astro's own <Code> (Shiki) with light and dark themes as CSS variables, so it follows the page theme with no script. The tabs are radio inputs and :has(), so arrow keys and selection are native and the indicator slides on the preset. The only script is CopyButton's, shared; long lines scroll inside the panel.

- Import: `import CodeTabs from '@moonarc/core/CodeTabs'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/code-tabs.json`
- Tier A · category ui · trigger click
- Readout: `<CodeTabs id="install" tabs={[…]}>`
- Browser support: widely (Chrome 117 · Firefox 129 · Safari 17.5); elsewhere: panels switch without the fade
- Measured cost: 0 B JS of its own · uses CopyButton + runtime (with dependencies 2.5 kB raw; CSS 14.3 kB raw)
- Page: https://moonarc.dev/components/code-tabs/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `tabs` | `{ label: string; code: string; lang?: string }[]` | none | Up to eight tabs; lang is a Shiki language id, sh by default. |
| `id` | `string` | none | Radio group name and id prefix; unique per page. |
| `label` | `string` | none | Accessible name of the radio group ("Package manager"). |
| `active` | `number` | `0` | Index selected on load. |
| `themes` | `{ light: string; dark: string }` | `github-light / github-dark-default` | Shiki themes; both are emitted as variables per token, each colour moved until it reads at 4.5 : 1 on its panel. |

## Usage

```astro
<CodeTabs
  id="install"
  tabs={[
    { label: 'npm', code: 'npx astro add moonarc' },
    { label: 'pnpm', code: 'pnpm astro add moonarc' },
    { label: 'bun', code: 'bunx astro add moonarc' },
  ]}
/>
```

## Reduced motion

The indicator jumps; panels switch instantly.

## With ClientRouter

The tabs are CSS; CopyButton rebinds through the shared runtime.

## Craft

- Highlighting happens at build: the HTML carries the colours as two custom properties per token, and one rule per theme state picks one. Zero bytes of highlighter in the browser.
- Contrast is fixed at build: a transformer (lib/contrast) moves each token colour in OKLCH lightness, hue kept, until it reads at 4.5 : 1 on a panel a shade heavier than the default. As shipped, GitHub light's red and orange, and every grey comment, fall short of AA. Colours that already pass are untouched.
- The tabs are a radiogroup, not a tablist: the controls are radios, and a screen reader announces them as radios ("pnpm, radio button, 2 of 3"). label names the group.
- Every panel has its own CopyButton, shown only with its panel (:has() again), and each copies its own <pre>, so no script decides which tab is open.
- Tabs are equal-width columns in an auto-sized grid, so the indicator is one translate of whole widths; no measuring.
- The panel scrolls horizontally inside itself (min-width: 0 on the grid item, overflow-x on the pre), so a long install line never widens a 390 px page.

## Replaces

- CodeTabs (Fumadocs)
- CodeBlock tabs (Aceternity)
- Tabs + CodeBlock (shadcn)

## Source

```astro
---
/**
 * CodeTabs — a code block with tabs (npm / pnpm / bun …) and a copy button
 * per panel. Highlighted at build by Astro's own <Code> (Shiki) with a
 * light and a dark theme as CSS variables, so the block follows the page's
 * theme with no script; every token colour is moved at build until it reads at
 * 4.5 : 1 on the panel (lib/contrast), whichever themes are passed. The tabs are the Tabs pattern — radio inputs and
 * :has() — so arrow keys, focus and one-selected are native and the
 * indicator slides on the preset. The only script is CopyButton's, shared.
 * Long lines scroll inside the panel; the page never widens.
 */
import type { HTMLAttributes } from 'astro/types';
import { Code } from 'astro:components';
import CopyButton from './CopyButton.astro';
import { readableCode } from '../lib/contrast';

interface Tab {
  label: string;
  code: string;
  /** Shiki language id. */
  lang?: 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;
  active?: number;
  /** Shiki themes for light and dark. */
  themes?: { light: string; dark: string };
}

const { tabs, id, label, active = 0, themes = { light: 'github-light', dark: 'github-dark-default' }, class: className, style, ...rest } = Astro.props;
const list = tabs.slice(0, 8);
const vars = [`--ma-codetabs-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-codetabs', className]} style={vars} {...rest}>
  <div class="ma-codetabs__bar">
    <div class="ma-codetabs__list" role="radiogroup" aria-label={label}>
      {list.map((t, i) => (
        <>
          <input type="radio" name={id} id={`${id}-${i}`} class="ma-codetabs__input" checked={i === active} />
          <label for={`${id}-${i}`} class="ma-codetabs__tab">{t.label}</label>
        </>
      ))}
      <span class="ma-codetabs__indicator" aria-hidden="true"></span>
    </div>
    <div class="ma-codetabs__copies">
      {list.map((_, i) => <span class="ma-codetabs__copy"><CopyButton target={`#${id}-p${i} pre`} compact /></span>)}
    </div>
  </div>
  <div class="ma-codetabs__panels">
    {list.map((t, i) => (
      <div class="ma-codetabs__panel" id={`${id}-p${i}`}>
        <Code code={t.code} lang={(t.lang ?? 'sh') as never} themes={themes as never} defaultColor={false} transformers={[readableCode()]} />
      </div>
    ))}
  </div>
</div>

<style is:global>
  @layer components {
    :where(.ma-codetabs) {
      display: grid;
      /* the block's own width, not its widest line's: the tab list scrolls sideways and the code scrolls in its <pre> */
      grid-template-columns: minmax(0, 1fr);
      overflow: clip;
      border: 1px solid var(--ma-edge);
      border-radius: 0.75rem;
      background: var(--ma-panel);
      color: var(--ma-ink);
      font-size: 0.875rem;
    }
    :where(.ma-codetabs__bar) {
      display: flex;
      align-items: stretch;
      justify-content: space-between;
      gap: 0.5rem;
      border-bottom: 1px solid var(--ma-edge);
      padding-inline-end: 0.4rem;
    }
    :where(.ma-codetabs__list) {
      position: relative;
      display: grid;
      grid-auto-flow: column;
      grid-auto-columns: 1fr; /* equal in an auto-sized grid: every fr track takes the widest label */
      min-width: 0;
      overflow-x: auto;
      scrollbar-width: none;
    }
    :where(.ma-codetabs__input) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    :where(.ma-codetabs__tab) {
      padding: 0.7rem 0.9rem;
      font-family: ui-monospace, monospace;
      font-size: 0.8125em;
      opacity: var(--ma-dim, 0.7);
      cursor: pointer;
      white-space: nowrap;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-codetabs__tab:hover),
    :where(.ma-codetabs__input:checked + .ma-codetabs__tab) {
      opacity: 1;
    }
    :where(.ma-codetabs__input:focus-visible + .ma-codetabs__tab) {
      outline: 2px solid currentColor;
      outline-offset: -3px;
      border-radius: 0.375rem;
    }
    /* the indicator is as wide as one tab and translates by whole tabs — tabs share one width so no measuring is needed */
    :where(.ma-codetabs__indicator) {
      position: absolute;
      inset-inline-start: 0;
      bottom: 0;
      width: calc(100% / var(--ma-codetabs-n, 1));
      height: 2px;
      background: currentColor;
      translate: calc(var(--ma-codetab, 0) * 100% * var(--ma-codetabs-dir, 1)) 0;
      transition: translate var(--ma-duration) var(--ma-ease);
    }
    /* right to left the tabs run from the right edge, so the indicator steps left. [dir] and not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome 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-codetabs, .ma-codetabs[dir='rtl']) { --ma-codetabs-dir: -1; }
    :where([dir='rtl'] [dir='ltr'] .ma-codetabs, [dir='rtl'] .ma-codetabs[dir='ltr']) { --ma-codetabs-dir: 1; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked)) { --ma-codetab: 1; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked)) { --ma-codetab: 2; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked)) { --ma-codetab: 3; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked)) { --ma-codetab: 4; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked)) { --ma-codetab: 5; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked)) { --ma-codetab: 6; }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked)) { --ma-codetab: 7; }
    :where(.ma-codetabs__copies) {
      display: grid;
      align-self: center;
      flex: none;
    }
    /* nth-of-type, not nth-child: Astro renders the component's <script> tag next to the first CopyButton. The wrapper is
       this component's own element (7F): a display rule on the button itself competed with CopyButton's own
       `display` at equal specificity, and which one won depended on the order the bundler gave the two stylesheets. */
    :where(.ma-codetabs__copy) {
      grid-area: 1 / 1;
      display: none;
    }
    :where(.ma-codetabs__copy .ma-copy) {
      border-color: transparent;
      background: transparent;
    }
    :where(.ma-codetabs__panel) {
      display: none;
      opacity: 1;
      translate: 0 0;
      transition:
        opacity var(--ma-duration) var(--ma-ease),
        translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(1):checked) .ma-codetabs__panel:nth-child(1)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked) .ma-codetabs__panel:nth-child(2)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked) .ma-codetabs__panel:nth-child(3)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked) .ma-codetabs__panel:nth-child(4)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked) .ma-codetabs__panel:nth-child(5)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked) .ma-codetabs__panel:nth-child(6)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked) .ma-codetabs__panel:nth-child(7)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked) .ma-codetabs__panel:nth-child(8)) {
      display: block;
    }
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(1):checked) .ma-codetabs__copy:nth-of-type(1)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked) .ma-codetabs__copy:nth-of-type(2)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked) .ma-codetabs__copy:nth-of-type(3)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked) .ma-codetabs__copy:nth-of-type(4)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked) .ma-codetabs__copy:nth-of-type(5)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked) .ma-codetabs__copy:nth-of-type(6)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked) .ma-codetabs__copy:nth-of-type(7)),
    :where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked) .ma-codetabs__copy:nth-of-type(8)) {
      display: inline-flex;
    }
    @starting-style {
      :where(.ma-codetabs__panel) {
        opacity: 0;
        translate: 0 var(--ma-travel-hover, 4px);
      }
    }
    :where(.ma-codetabs__panel > pre) {
      margin: 0;
      padding: 1rem;
      overflow-x: auto;
      background: transparent !important;
      font-size: 0.8125rem;
      line-height: 1.6;
      tab-size: 2;
    }
    /* Shiki with defaultColor: false leaves the colours in two variables per token; --ma-dark (0 or 1 from the base tokens'
       three-state rule) picks one, so the block follows system, data-theme and .dark exactly as every other token does */
    :where(.ma-codetabs__panel pre span) {
      color: color-mix(in srgb, var(--shiki-dark) calc(var(--ma-dark, 0) * 100%), var(--shiki-light));
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-codetabs__indicator),
      :where(.ma-codetabs__panel) {
        transition: none;
      }
    }
  }
</style>

```
