Skip to content

Components / UI

Code Tabs

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.

Live demo

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses CopyButton and the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.

CSS 14.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 117 · Firefox 129 · Safari 17.5; needs has, starting-style, clipboard. Elsewhere: panels switch without the fade.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import CodeTabs from '@moonarc/core/CodeTabs';
---
<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' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs readableCode(), Copy Button, the shared runtime, Animated Icons and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/code-tabs.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy contrast.ts, CopyButton.astro, runtime.ts and AnimatedIcons.astro.

CodeTabs.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>

Props

PropTypeDefaultDescription
tabs{ label: string; code: string; lang?: string }[]noneUp to eight tabs; lang is a Shiki language id, sh by default.
idstringnoneRadio group name and id prefix; unique per page.
labelstringnoneAccessible name of the radio group ("Package manager").
activenumber0Index selected on load.
themes{ light: string; dark: string }github-light / github-dark-defaultShiki themes; both are emitted as variables per token, each colour moved until it reads at 4.5 : 1 on its panel.

Reduced motion

The indicator jumps; panels switch instantly.

With ClientRouter

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

Why it is built this way

Replaces: CodeTabs (Fumadocs) · CodeBlock tabs (Aceternity) · Tabs + CodeBlock (shadcn). See the migration table.

A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.

every browseralways
npx astro add moonarc
click · check · back

A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration.

every browserclick
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown