Tabs
Free · MITHeadless, 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
npx astrocraft-ui add navigation/tabsPlain-CSS theme — no build step
npx astrocraft-ui add navigation/tabs --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add navigation/tabs --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/tabs --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/tabs --theme tailwind --bridge lumosLive 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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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 — 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 — 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>
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.