Skip to main content
astrocraft-ui/ components · 101

Tabs

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add navigation/tabs

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add navigation/tabs --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add navigation/tabs --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/tabs --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/tabs --theme tailwind --bridge lumos

Live demo

Tabs

Account panel — arrow keys move between tabs (roving focus).

Password panel — only the active panel is shown once hydrated.

Team panel — degrades to all-panels-visible without JS.

Dashboard panel — icons are decorative; the label names the tab.

Explore panel — same roving focus and keyboard behavior.

Settings panel — tokens flip with the theme.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`Tabs.astro``tabs`———
`TabsContent.astro``tabs-content`——`[hidden]`
`TabsList.astro``tabs-list`———
`TabsTrigger.astro``tabs-trigger`——`[aria-selected]`

Source

What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/navigation/tabs/Tabs.astro
---
// src/components/ui/navigation/tabs/Tabs.astro — headless primitive (see ../../README.md).
// Accessible tabs (role=tablist/tab/tabpanel + arrow-key roving focus) wired by the small bundled
// script below. HTML has no native tab element, so this is the one Tier-2 primitive that needs JS;
// it degrades to "all panels visible" without it. Compose TabsList / TabsTrigger / TabsContent and
// match each TabsTrigger `value` to its TabsContent `value`.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div">;

const { class: className, ...rest } = Astro.props;
---

<div class={className} data-slot="tabs" {...rest}>
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";

  // One bundled module for every <Tabs> on the page (Astro dedupes it to a single include).
  let uid = 0;

  function initTabs(root: HTMLElement) {
    const triggers = [...root.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')];
    const panels = [...root.querySelectorAll<HTMLElement>('[data-slot="tabs-content"]')];
    if (triggers.length === 0) return;

    // Link each trigger to its panel by shared `value`, minting ids where the author left them off.
    for (const trigger of triggers) {
      const panel = panels.find((p) => p.dataset.value === trigger.dataset.value);
      if (!panel) continue;
      trigger.id ||= `tab-${(uid += 1)}`;
      panel.id ||= `tabpanel-${(uid += 1)}`;
      trigger.setAttribute("aria-controls", panel.id);
      panel.setAttribute("aria-labelledby", trigger.id);
    }

    const activate = (value: string | undefined, focus = false) => {
      for (const trigger of triggers) {
        const selected = trigger.dataset.value === value;
        trigger.setAttribute("aria-selected", String(selected));
        trigger.tabIndex = selected ? 0 : -1;
        if (selected && focus) trigger.focus();
      }
      for (const panel of panels) panel.hidden = panel.dataset.value !== value;
    };

    triggers.forEach((trigger, i) => {
      trigger.addEventListener("click", () => activate(trigger.dataset.value));
      trigger.addEventListener("keydown", (event) => {
        const moves: Record<string, number | "home" | "end"> = {
          ArrowRight: 1,
          ArrowLeft: -1,
          Home: "home",
          End: "end",
        };
        const move = moves[event.key];
        if (move === undefined) return;
        event.preventDefault();
        const next =
          move === "home"
            ? 0
            : move === "end"
              ? triggers.length - 1
              : (i + move + triggers.length) % triggers.length;
        activate(triggers[next].dataset.value, true);
      });
    });

    const initial = triggers.find((t) => t.getAttribute("aria-selected") === "true") ?? triggers[0];
    activate(initial.dataset.value);
  }

  // load + re-init after each view-transition swap, via the shared contract (see _client.ts).
  onReadyOnce('[data-slot="tabs"]', initTabs);
</script>
src/components/ui/navigation/tabs/TabsContent.astro
---
// src/components/ui/navigation/tabs/TabsContent.astro — Tabs compound part (see ../../README.md).
// `value` must match its TabsTrigger. Panels render visible without JS (progressive enhancement);
// the Tabs script hides the inactive ones once hydrated.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & { value: string };

const { value, class: className, ...rest } = Astro.props;
---

<div
  role="tabpanel"
  tabindex={0}
  data-slot="tabs-content"
  data-value={value}
  class={className}
  {...rest}
>
  <slot />
</div>
src/components/ui/navigation/tabs/TabsList.astro
---
// src/components/ui/navigation/tabs/TabsList.astro — Tabs compound part (see ../../README.md).
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div">;

const { class: className, ...rest } = Astro.props;
---

<div class={className} role="tablist" data-slot="tabs-list" {...rest}>
  <slot />
</div>
src/components/ui/navigation/tabs/TabsTrigger.astro
---
// src/components/ui/navigation/tabs/TabsTrigger.astro — Tabs compound part (see ../../README.md).
// `value` links this tab to the TabsContent with the same value; mark the default tab `selected`.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button"> & { value: string; selected?: boolean };

const { value, selected = false, class: className, ...rest } = Astro.props;
---

<button
  type="button"
  role="tab"
  data-slot="tabs-trigger"
  data-value={value}
  aria-selected={selected ? "true" : "false"}
  tabindex={selected ? 0 : -1}
  class={className}
  {...rest}
>
  <slot />
</button>
src/components/ui/navigation/tabs/index.ts
import Tabs from "./Tabs.astro";
import TabsContent from "./TabsContent.astro";
import TabsList from "./TabsList.astro";
import TabsTrigger from "./TabsTrigger.astro";

export { Tabs, TabsContent, TabsList, TabsTrigger };
export default Tabs;

What you get

The component source, copied into your project by npx astrocraft-ui add navigation/tabs — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.