Skip to content

Components / UI

Tabs

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.

Live demo

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

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

Measured

JavaScript of its own

0 B

CSS 8.0 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. Elsewhere: panels switch without the fade (or the slide).

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 Tabs from '@moonarc/core/Tabs';
---
<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} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/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; it imports nothing.

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

Props

PropTypeDefaultDescription
tabs{ label: string; body: string }[]noneUp to eight tabs; body is an HTML string.
idstringnoneRadio group name and id prefix; unique per page.
labelstringnoneAccessible name of the radio group ("Package manager"); read before the first tab.
activenumber0Index selected on load.
directionbooleanfalsePanels 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.

Reduced motion

The indicator jumps; panels switch instantly.

With ClientRouter

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

Why it is built this way

Replaces: Tabs (Radix / shadcn) · Tabs (Aceternity) · AnimatedTabs. See the migration table.

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces.

newly · Chrome 131 · Firefox 143 · Safari 18.4click

A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync.

Chrome 135 · not Firefox · not Safariclickpointer

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click

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