` props.
+
+## Controlled Usage
+
+```tsx
+const [activeTab, setActiveTab] = useState("overview");
+
+
+
+ Overview
+ Details
+
+ Overview content
+ Details content
+;
+```
+
+## Examples
+
+### With Disabled Tab
+
+```tsx
+
+
+ Active
+ Pending
+
+ Archived
+
+
+ Active items
+ Pending items
+ Archived items
+
+```
+
+### Use Icons in Tab
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview content
+ Projects content
+ Settings content
+
+```
diff --git a/examples/nextjs-app/src/modules/pages/primitives-demo.tsx b/examples/nextjs-app/src/modules/pages/primitives-demo.tsx
index 52549cc2..8cef8fe2 100644
--- a/examples/nextjs-app/src/modules/pages/primitives-demo.tsx
+++ b/examples/nextjs-app/src/modules/pages/primitives-demo.tsx
@@ -10,9 +10,65 @@ import {
Sheet,
Menu,
Table,
+ Tabs,
} from "@tailor-platform/app-shell";
import * as React from "react";
+const LayoutDashboardIcon = () => (
+
+);
+
+const FolderKanbanIcon = () => (
+
+);
+
+const SettingsIcon = () => (
+
+);
+
export const primitiveComponentsDemoResource = defineResource({
path: "primitives-demo",
meta: {
@@ -332,6 +388,104 @@ export const primitiveComponentsDemoResource = defineResource({
+ {/* Tabs */}
+
+
+
+
+
+
+
Default
+
+
+ Overview
+ Projects
+ Settings
+
+ Overview content
+ Projects content
+ Settings content
+
+
+
+
Line
+
+
+ Activity
+ Members
+ Billing
+
+ Activity content
+ Members content
+ Billing content
+
+
+
+
Capsule
+
+
+ All
+ Open
+ Received
+ Closed
+
+ All content
+ Open content
+ Received content
+ Closed content
+
+
+
+
+
+
With Badge
+
+
+
+ Overview
+
+ New
+
+
+
+ Projects
+ 10+
+
+
+ Overview content
+ Projects content
+
+
+
+
Icons
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview content
+ Projects content
+ Settings content
+
+
+
+
+
+
+
{/* Table */}
diff --git a/packages/core/__snapshots__/src__components__tabs.test.tsx.snap b/packages/core/__snapshots__/src__components__tabs.test.tsx.snap
new file mode 100644
index 00000000..d445a73f
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__tabs.test.tsx.snap
@@ -0,0 +1,11 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Tabs > snapshots > capsule variant 1`] = `""`;
+
+exports[`Tabs > snapshots > default tabs 1`] = `""`;
+
+exports[`Tabs > snapshots > line variant 1`] = `""`;
+
+exports[`Tabs > snapshots > tabs with disabled tab 1`] = `""`;
+
+exports[`Tabs > snapshots > tabs with three tabs 1`] = `"Overview content
"`;
diff --git a/packages/core/src/components/tabs.test.tsx b/packages/core/src/components/tabs.test.tsx
new file mode 100644
index 00000000..ffc98aa2
--- /dev/null
+++ b/packages/core/src/components/tabs.test.tsx
@@ -0,0 +1,180 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Tabs } from "./tabs";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("Tabs", () => {
+ // ==========================================================================
+ // Snapshots — verify full DOM structure for tabs variations
+ // ==========================================================================
+
+ describe("snapshots", () => {
+ it("default tabs", () => {
+ const { container } = render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("tabs with three tabs", () => {
+ const { container } = render(
+
+
+ Overview
+ Projects
+ Account
+
+ Overview content
+ Projects content
+ Account content
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("tabs with disabled tab", () => {
+ const { container } = render(
+
+
+ Tab 1
+
+ Tab 2
+
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("line variant", () => {
+ const { container } = render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("capsule variant", () => {
+ const { container } = render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ it("renders all tabs", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Tab 1")).toBeDefined();
+ expect(screen.getByText("Tab 2")).toBeDefined();
+ });
+
+ it("displays the active panel content", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Content 1")).toBeDefined();
+ });
+
+ it("switches panel on tab click", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ await user.click(screen.getByText("Tab 2"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Content 2")).toBeDefined();
+ });
+ });
+
+ it("calls onValueChange when tab is clicked", async () => {
+ const handleChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ await user.click(screen.getByText("Tab 2"));
+
+ await waitFor(() => {
+ expect(handleChange).toHaveBeenCalled();
+ expect(handleChange.mock.calls[0][0]).toBe("tab2");
+ });
+ });
+
+ it("supports controlled value", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Content 2")).toBeDefined();
+ });
+});
diff --git a/packages/core/src/components/tabs.tsx b/packages/core/src/components/tabs.tsx
new file mode 100644
index 00000000..4afe385f
--- /dev/null
+++ b/packages/core/src/components/tabs.tsx
@@ -0,0 +1,121 @@
+import * as React from "react";
+import { Tabs as BaseTabs } from "@base-ui/react/tabs";
+
+import { cn } from "@/lib/utils";
+
+type TabsVariant = "default" | "line" | "capsule";
+
+const TabsVariantContext = React.createContext("default");
+
+// Only the props relevant to the Tabs abstraction are picked from BaseTabs.Root.
+// Base UI-internal props are intentionally excluded so that upstream changes
+// don't leak as breaking changes to consumers.
+type RootProps = Pick<
+ React.ComponentProps,
+ "defaultValue" | "value" | "onValueChange"
+> & {
+ children: React.ReactNode;
+ className?: string;
+ variant?: TabsVariant;
+};
+
+/**
+ * The root component that manages tab selection state.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * Overview
+ * Projects
+ * Account
+ *
+ * Overview content
+ * Projects content
+ * Account content
+ *
+ * ```
+ */
+function Root({ className, children, variant = "default", ...props }: RootProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+Root.displayName = "Tabs.Root";
+
+/** Groups the individual tab buttons. */
+function List({ className, children, ...props }: React.ComponentProps) {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+
+ {children}
+
+ );
+}
+List.displayName = "Tabs.List";
+
+/** An individual interactive tab button that toggles the corresponding panel. */
+function Tab({ className, children, ...props }: React.ComponentProps) {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+
+ {children}
+
+ );
+}
+Tab.displayName = "Tabs.Tab";
+
+/** A panel displayed when the corresponding tab is active. */
+function Panel({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+Panel.displayName = "Tabs.Panel";
+
+export const Tabs = {
+ Root,
+ List,
+ Tab,
+ Panel,
+};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index daef45a1..2ac50c9e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -116,6 +116,7 @@ export { Fieldset } from "./components/fieldset";
export { Form, type FormProps } from "./components/form";
export { Menu } from "./components/menu";
export { Sheet } from "./components/sheet";
+export { Tabs } from "./components/tabs";
export { Tooltip } from "./components/tooltip";
export { Select, type SelectAsyncFetcher } from "./components/select-standalone";
export { Combobox, type ComboboxAsyncFetcher } from "./components/combobox-standalone";