A complete, accessible tab component system for organizing content into separate views. Built with keyboard navigation support, badge indicators, and horizontal scrolling for many tabs.
The default Tab component combines all subcomponents into a convenient wrapper. Use snippets (Svelte) or named slots (Astro) to define content for each tab.
Welcome to your dashboard! Here's an overview of your account.
---
// GENERATED from packages/demos/tab/basic.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabItems = [
{
id: "dashboard",
label: "Dashboard",
},
{
id: "profile",
label: "Profile",
},
{
id: "notifications",
label: "Notifications",
},
{
id: "settings",
label: "Settings",
},
] as const;
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="dashboard">
<div slot="dashboard">
<h3>Dashboard</h3>
<p>Welcome to your dashboard! Here's an overview of your account.</p>
</div>
<div slot="profile">
<h3>Profile</h3>
<p>Update your profile information and preferences here.</p>
</div>
<div slot="notifications">
<h3>Notifications</h3>
<p>Manage your notification settings and view recent alerts.</p>
</div>
<div slot="settings">
<h3>Settings</h3>
<p>Customize your application settings and preferences.</p>
</div>
</Tab>
</div>The strip renders the minimal underline tabs by default: muted resting labels, a soft fill under the hovered tab (rounded on its top corners), a 2px accent bar under the selected tab (3px at lg), and a 1px baseline track. Tabs hug their labels and sit at the start of the strip; fullWidth stretches them. The group itself is frameless, so the track starts at the content’s edge; border="default" (or a background) frames it and insets the strip and panels. It’s the same visual language as Navigation’s appearance="tabs" and Radio’s appearance="tab". appearance="buttons" renders the strip as a segmented Choice instead, with Choice’s track and thumb.
The selection slides between tabs: the underline bar, or Choice’s thumb in buttons mode. CSS anchor positioning places it on first paint, and a small runtime then measures the selected tab so the slide animates in every engine, including Firefox. It stays under its tab while the strip scrolls, doesn’t animate in when a hidden strip is revealed, and reduced motion turns it off. Pass thumb={false} to keep a static per-tab selection.
The segmented strip — appearance="buttons", rendered by Choice.
Underline tabs scroll horizontally when the strip overflows.
thumb={false} — the selection swaps instead of sliding.
A framed group — border="default". Tab groups are frameless by default.
---
// GENERATED from packages/demos/tab/appearance.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabItems = [
{ id: "overview", label: "Overview" },
{ id: "activity", label: "Activity" },
{ id: "settings", label: "Settings" },
] as const;
const manyItems = [
{ id: "analytics", label: "Analytics" },
{ id: "reports", label: "Reports" },
{ id: "customers", label: "Customers" },
{ id: "products", label: "Products" },
{ id: "orders", label: "Orders" },
{ id: "inventory", label: "Inventory" },
{ id: "shipping", label: "Shipping" },
{ id: "returns", label: "Returns" },
{ id: "preferences", label: "Preferences" },
] as const;
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Tab items={tabItems} defaultTab="overview" appearance="buttons">
<p slot="overview">
The segmented strip — <code>appearance="buttons"</code>, rendered by
Choice.
</p>
<p slot="activity">Recent activity across your account.</p>
<p slot="settings">Customize your application settings.</p>
</Tab>
<Tab items={manyItems} defaultTab="analytics">
<p slot="analytics">
Underline tabs scroll horizontally when the strip overflows.
</p>
<p slot="reports">Generate and export monthly reports.</p>
<p slot="customers">Customer accounts and segments.</p>
<p slot="products">Manage the product catalog.</p>
<p slot="orders">Track and fulfill orders.</p>
<p slot="inventory">Stock levels and warehouses.</p>
<p slot="shipping">Shipping options and carriers.</p>
<p slot="returns">Process customer returns.</p>
<p slot="preferences">Store preferences.</p>
</Tab>
<Tab items={tabItems} defaultTab="activity" thumb={false}>
<p slot="overview">Overview of your account.</p>
<p slot="activity">
<code>thumb={false}</code> — the selection swaps instead of
sliding.
</p>
<p slot="settings">Customize your application settings.</p>
</Tab>
<Tab items={tabItems} defaultTab="overview" border="default">
<p slot="overview">
A framed group — <code>border="default"</code>. Tab groups are frameless
by default.
</p>
<p slot="activity">Recent activity across your account.</p>
<p slot="settings">Customize your application settings.</p>
</Tab>
</div>For more control, use the individual components: TabGroup, TabNav, and TabPanel. This allows you to customize the layout and access the selected tab state.
In Astro, TabGroup takes defaultTab (the id of the initially selected tab). It renders once and is uncontrolled, so it has no selectedTab binding. Svelte (bind:selectedTab), React, Vue and Solid also take a controlled selectedTab; defaultTab seeds the uncontrolled state there too.
This is the Home tab content. You can build custom layouts here.
Currently selected: home
---
// GENERATED from packages/demos/tab/composable.demo.tsx — edit the source, not this file.
import { TabGroup, TabNav, TabPanel } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabs = [
{
id: "home",
label: "Home",
},
{
id: "about",
label: "About",
},
{
id: "contact",
label: "Contact",
},
];
const selectedTab = "home";
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
data-ir-root="tab/composable"
>
<TabGroup defaultTab="home" data-ir="1">
<TabNav tabs={tabs} selectedTab="home" slot="tab-nav" />
<div slot="panel">
<TabPanel id="home" selected>
<p>This is the Home tab content. You can build custom layouts here.</p>
</TabPanel>
<TabPanel id="about">
<p>This is the About tab content with composable components.</p>
</TabPanel>
<TabPanel id="contact">
<p>Contact us at: contact@example.com</p>
</TabPanel>
</div>
</TabGroup>
<p>
Currently selected:{" "}
<strong>
<!--ir:2-->
{selectedTab}
<!--/ir-->
</strong>
</p>
</div>
<script>
import { hydrate } from "./_client/composable.ts";
document
.querySelectorAll('[data-ir-root="tab/composable"]')
.forEach((root) => hydrate([root]));
</script>Add badge indicators to tabs using the badge property on tab items. Useful for showing counts like unread messages or pending items.
You have 12 unread messages in your inbox.
---
// GENERATED from packages/demos/tab/with-badges.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabItems = [
{
id: "inbox",
label: "Inbox",
badge: "12",
},
{
id: "drafts",
label: "Drafts",
badge: "3",
},
{
id: "sent",
label: "Sent",
},
{
id: "archive",
label: "Archive",
},
] as const;
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="inbox">
<div slot="inbox">
<h3>Inbox</h3>
<p>You have 12 unread messages in your inbox.</p>
</div>
<div slot="drafts">
<h3>Drafts</h3>
<p>You have 3 draft messages waiting to be sent.</p>
</div>
<div slot="sent">
<h3>Sent</h3>
<p>View all messages you've sent.</p>
</div>
<div slot="archive">
<h3>Archive</h3>
<p>Access your archived messages.</p>
</div>
</Tab>
</div>Individual tabs can be disabled by setting disabled: true on the tab item. Disabled tabs are not focusable and cannot be selected.
These items are currently available and ready for action.
---
// GENERATED from packages/demos/tab/disabled-tabs.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabItems = [
{
id: "available",
label: "Available",
},
{
id: "processing",
label: "Processing",
disabled: true,
},
{
id: "completed",
label: "Completed",
},
{
id: "archived",
label: "Archived",
disabled: true,
},
] as const;
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="available">
<div slot="available">
<h3>Available Items</h3>
<p>These items are currently available and ready for action.</p>
</div>
<div slot="processing">
<h3>Processing</h3>
<p>Items currently being processed (This tab is disabled).</p>
</div>
<div slot="completed">
<h3>Completed Items</h3>
<p>All completed items are listed here.</p>
</div>
<div slot="archived">
<h3>Archived</h3>
<p>Archived items (This tab is disabled).</p>
</div>
</Tab>
</div>When there are more tabs than can fit in the available width, the navigation scrolls horizontally. A thin styled scrollbar appears to indicate overflow. Focus outlines and group borders remain fully visible during scrolling.
A high-level summary of your store's performance and key metrics.
---
// GENERATED from packages/demos/tab/scrolling.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
const tabItems = [
{ id: "overview", label: "Overview" },
{ id: "analytics", label: "Analytics" },
{ id: "reports", label: "Reports" },
{ id: "customers", label: "Customers" },
{ id: "products", label: "Products" },
{ id: "orders", label: "Orders" },
{ id: "inventory", label: "Inventory" },
{ id: "shipping", label: "Shipping" },
{ id: "returns", label: "Returns" },
{ id: "preferences", label: "Preferences" },
] as const;
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="overview">
<div slot="overview">
<h3>Overview</h3>
<p>A high-level summary of your store's performance and key metrics.</p>
</div>
<div slot="analytics">
<h3>Analytics</h3>
<p>Detailed traffic and conversion analytics for your store.</p>
</div>
<div slot="reports">
<h3>Reports</h3>
<p>Generate and download reports for accounting and compliance.</p>
</div>
<div slot="customers">
<h3>Customers</h3>
<p>Manage customer profiles, segments, and communication preferences.</p>
</div>
<div slot="products">
<h3>Products</h3>
<p>Add, edit, and organize your product catalog.</p>
</div>
<div slot="orders">
<h3>Orders</h3>
<p>View and process incoming orders and track fulfillment status.</p>
</div>
<div slot="inventory">
<h3>Inventory</h3>
<p>Monitor stock levels and set up reorder alerts.</p>
</div>
<div slot="shipping">
<h3>Shipping</h3>
<p>Configure shipping zones, rates, and carrier integrations.</p>
</div>
<div slot="returns">
<h3>Returns</h3>
<p>Process return requests and manage refund policies.</p>
</div>
<div slot="preferences">
<h3>Preferences</h3>
<p>Configure store preferences, payments, and integrations.</p>
</div>
</Tab>
</div>Use leftAction and rightAction snippets to place fixed elements on either side of the scrollable tab strip. The action slots stay anchored while the tabs scroll freely behind them. Tabs use their natural width by default, so a long strip scrolls; fullWidth stretches them to fill the strip instead.
A high-level summary of your store's performance and key metrics.
---
// GENERATED from packages/demos/tab/with-actions.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const tabItems = [
{ id: "overview", label: "Overview" },
{ id: "analytics", label: "Analytics" },
{ id: "reports", label: "Reports" },
{ id: "customers", label: "Customers" },
{ id: "products", label: "Products" },
{ id: "orders", label: "Orders" },
{ id: "inventory", label: "Inventory" },
{ id: "shipping", label: "Shipping" },
{ id: "returns", label: "Returns" },
{ id: "preferences", label: "Preferences" },
] as const;
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="overview">
<Button slot="left-action" emphasis="secondary" aria-label="Scroll left">
Action 1
</Button>
<div
slot="right-action"
class={css({
display: "flex",
gap: "xs",
})}
>
<Button emphasis="secondary" shape="square" aria-label="Add tab">
+
</Button>
<Button emphasis="secondary" aria-label="Add tab">
Action 2
</Button>
</div>
<div slot="overview">
<h3>Overview</h3>
<p>A high-level summary of your store's performance and key metrics.</p>
</div>
<div slot="analytics">
<h3>Analytics</h3>
<p>Detailed traffic and conversion analytics for your store.</p>
</div>
<div slot="reports">
<h3>Reports</h3>
<p>Generate and download reports for accounting and compliance.</p>
</div>
<div slot="customers">
<h3>Customers</h3>
<p>Manage customer profiles, segments, and communication preferences.</p>
</div>
<div slot="products">
<h3>Products</h3>
<p>Add, edit, and organize your product catalog.</p>
</div>
<div slot="orders">
<h3>Orders</h3>
<p>View and process incoming orders and track fulfillment status.</p>
</div>
<div slot="inventory">
<h3>Inventory</h3>
<p>Monitor stock levels and set up reorder alerts.</p>
</div>
<div slot="shipping">
<h3>Shipping</h3>
<p>Configure shipping zones, rates, and carrier integrations.</p>
</div>
<div slot="returns">
<h3>Returns</h3>
<p>Process return requests and manage refund policies.</p>
</div>
<div slot="preferences">
<h3>Preferences</h3>
<p>Configure store preferences, payments, and integrations.</p>
</div>
</Tab>
</div>Use the passThrough prop to customize styles for any tab slot — root, nav, navScroll, panel, panelsContainer, and panelEmpty. Each key accepts a Panda CSS SystemStyleObject.
This tab component uses custom styling via the passThrough prop.
---
// GENERATED from packages/demos/tab/custom-styling.demo.tsx — edit the source, not this file.
import { Tab } from "@pindoba/astro-tab";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const tabItems = [
{
id: "summary",
label: "Summary",
},
{
id: "metrics",
label: "Metrics",
},
{
id: "exports",
label: "Exports",
},
] as const;
const customStyles = {
root: {
style: css.raw({
borderWidth: "2px",
borderStyle: "solid",
borderColor: "primary.border.accent",
borderRadius: "lg",
padding: "md",
bg: "neutral.surface.valley",
}),
},
nav: {
style: css.raw({
mb: "md",
}),
},
panel: {
style: css.raw({
padding: "xl",
bg: "neutral.surface.peak",
borderRadius: "md",
boxShadow: "sm",
}),
},
panelsContainer: {
style: css.raw({
mt: "md",
}),
},
};
---
<div
class={stack({
gap: "xl",
direction: "column",
justify: "center",
width: "100%",
})}
>
<Tab items={tabItems} defaultTab="summary" passThrough={customStyles}>
<div slot="summary">
<h3>Summary</h3>
<p>This tab component uses custom styling via the passThrough prop.</p>
</div>
<div slot="metrics">
<h3>Metrics</h3>
<p>View your metrics data with custom styled panels.</p>
</div>
<div slot="exports">
<h3>Exports</h3>
<p>Generate and view exports with enhanced styling.</p>
</div>
</Tab>
</div>Tabs follow the WAI-ARIA tabs pattern with automatic activation. Each tab is
the native radio <input> inside its label, carrying role="tab",
aria-selected and aria-controls, inside a role="tablist" that holds only
the tabs (the action slots sit outside it). Panels are role="tabpanel",
labelled by their tab.