Repository: knocklabs/telegraph
Pull request #437: feat(kno-6107): add tabs component package
Author: login: MikeCarbone
Created: 2025-04-03T18:42:18Z | Merged: 2025-04-04T15:42:27Z

### TL;DR

[https://linear.app/knock/issue/KNO-6107/[telegraph]-tab-component](https://linear.app/knock/issue/KNO-6107/[telegraph]-tab-component)

<img width="1176" alt="Screenshot 2025-04-03 at 4 08 54 PM" src="https://github.com/user-attachments/assets/79b98aff-0956-4b91-b286-5c77140547df" />


[Designs](https://www.figma.com/design/br4Lk9C5qw3UVyGsAbONDY/Telegraph-design-system?node-id=1170-15524&m=dev)

Add a new `@telegraph/tabs` package based on Radix UI Tabs with Telegraph design system styling.

### What changed?

Created a new `@telegraph/tabs` package that provides a composable tabs component for navigation. The package includes:

- A main `Tabs` component with nested components (`Tab`, `TabList`, `TabPanel`)
- Support for horizontal and vertical orientations
- Icon integration with leading and trailing icons
- Styling that matches the Telegraph design system
- CSS customizations for active tab states
- Comprehensive documentation and examples

The implementation is built on top of Radix UI's Tabs primitive and integrates with other Telegraph components like `MenuItem`, `Box`, and `Icon`.

### How to test?

1. Import and use the component in your application:
```jsx
import { Tabs } from '@telegraph/tabs';

<Tabs defaultValue="tab1">
  <Tabs.List>
    <Tabs.Tab value="tab1">First Tab</Tabs.Tab>
    <Tabs.Tab value="tab2">Second Tab</Tabs.Tab>
  </Tabs.List>
  
  <Tabs.Panel value="tab1">
    Content for first tab
  </Tabs.Panel>
  <Tabs.Panel value="tab2">
    Content for second tab
  </Tabs.Panel>
</Tabs>
```

2. Test different configurations:
   - With icons (leading/trailing)
   - Vertical orientation
   - Disabled tabs
   - Controlled usage with state management

### Why make this change?

This adds a standardized tabs component to the Telegraph design system, providing a consistent way to implement tabbed navigation across applications. The component follows the composable pattern used in other Telegraph components, making it easy to integrate with existing codebases while maintaining design consistency.


Diff:
diff --git a/.changeset/many-elephants-sell.md b/.changeset/many-elephants-sell.md
new file mode 100644
index 00000000..d94741d8
--- /dev/null
+++ b/.changeset/many-elephants-sell.md
@@ -0,0 +1,5 @@
+---
+"@telegraph/tabs": patch
+---
+
+feat: add tabs component package
diff --git a/packages/tabs/.eslintrc.js b/packages/tabs/.eslintrc.js
new file mode 100644
index 00000000..0217518c
--- /dev/null
+++ b/packages/tabs/.eslintrc.js
@@ -0,0 +1,10 @@
+/** @type {import("eslint").Linter.Config} */
+module.exports = {
+  root: true,
+  extends: [
+    "@knocklabs/eslint-config/library.js",
+    "plugin:@typescript-eslint/recommended",
+    "plugin:react-hooks/recommended",
+  ],
+  parser: "@typescript-eslint/parser",
+};
diff --git a/packages/tabs/README.md b/packages/tabs/README.md
new file mode 100644
index 00000000..2e7c9774
--- /dev/null
+++ b/packages/tabs/README.md
@@ -0,0 +1,144 @@
+![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)
+
+[![npm version](https://img.shields.io/npm/v/@telegraph/button.svg)](https://www.npmjs.com/package/@telegraph/tabs)
+
+# @telegraph/tabs
+> Standardized tabs component for navigation
+
+A composable tabs component based on [Radix UI Tabs](https://www.radix-ui.com/primitives/docs/components/tabs) with Telegraph design system styling.
+
+## Installation Instructions
+
+```
+npm install @telegraph/tabs
+```
+
+## Usage
+
+```jsx
+import { Tabs } from '@telegraph/tabs';
+import { Box } from '@telegraph/layout';
+import { Lucide } from '@telegraph/icon';
+
+function MyTabs() {
+  return (
+    <Tabs defaultValue="tab1">
+      {/* Composable pattern with nested components */}
+      <Tabs.List>
+        <Tabs.Tab 
+          value="tab1"
+          leadingIcon={{
+            icon: Lucide.Home,
+            alt: "Home icon"
+          }}
+        >
+          First Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab2"
+          leadingIcon={{
+            icon: Lucide.Settings,
+            alt: "Settings icon"
+          }}
+        >
+          Second Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab3"
+          leadingIcon={{
+            icon: Lucide.Bell,
+            alt: "Notifications icon"
+          }}
+        >
+          Third Tab
+        </Tabs.Tab>
+      </Tabs.List>
+      
+      <Tabs.Panel value="tab1">
+        <Box padding="4">Content for the first tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab2">
+        <Box padding="4">Content for the second tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab3">
+        <Box padding="4">Content for the third tab</Box>
+      </Tabs.Panel>
+    </Tabs>
+  );
+}
+
+```
+
+## Controlled usage
+
+The tabs component can be controlled when you need to manage the state yourself:
+
+```jsx
+import { Tabs, useTabsContext } from '@telegraph/tabs';
+import { MenuItem } from '@telegraph/menu';
+import { useState } from 'react';
+
+function ControlledTabs() {
+  const [value, setValue] = useState('tab1');
+  
+  return (
+    <div>
+      <p>Current active tab: {value}</p>
+      <MenuItem 
+        variant="outline" 
+        size="2" 
+        onClick={() => setValue('tab3')}
+      >
+        Switch to third tab programmatically
+      </MenuItem>
+      
+      <Tabs value={value} onValueChange={setValue}>
+        <Tabs.List>
+          <Tabs.Tab value="tab1">First Tab</Tabs.Tab>
+          <Tabs.Tab value="tab2">Second Tab</Tabs.Tab>
+          <Tabs.Tab value="tab3">Third Tab</Tabs.Tab>
+        </Tabs.List>
+        
+        <Tabs.Panel value="tab1">
+          <Box padding="4">Content for the first tab</Box>
+        </Tabs.Panel>
+        <Tabs.Panel value="tab2">
+          <Box padding="4">Content for the second tab</Box>
+        </Tabs.Panel>
+        <Tabs.Panel value="tab3">
+          <Box padding="4">Content for the third tab</Box>
+        </Tabs.Panel>
+      </Tabs>
+    </div>
+  );
+}
+```
+
+## Components API
+
+### Tabs (Root)
+- `defaultValue`: String - ID of the initially active tab
+- `value`: String - Controlled value for the active tab
+- `onValueChange`: Function - Callback when the active tab changes
+- `disabled`: Boolean - Disables all tabs when true
+
+### Tab
+- `value`: String (required) - Unique identifier for the tab
+- `disabled`: Boolean - Disables this specific tab
+- `onClick`: Function - Additional callback when tab is clicked
+- `leadingIcon`: Object - Icon to display at the start of the tab
+  - `icon`: ComponentType - The icon component to render
+  - `alt`: String - Alternative text for the icon
+- `trailingIcon`: Object - Icon to display at the end of the tab
+  - `icon`: ComponentType - The icon component to render
+  - `alt`: String - Alternative text for the icon
+- `icon`: Object - Icon to display at the end of the tab
+  - `icon`: ComponentType - The icon component to render
+  - `alt`: String - Alternative text for the icon
+
+### TabList
+- `loop`: Boolean - Whether keyboard navigation loops from last to first tab
+
+### TabPanel
+- `value`: String (required) - ID of the tab this panel is associated with
+- `forceMount`: Boolean - Whether to force mounting when tab is inactive
diff --git a/packages/tabs/package.json b/packages/tabs/package.json
new file mode 100644
index 00000000..5e20a4e0
--- /dev/null
+++ b/packages/tabs/package.json
@@ -0,0 +1,60 @@
+{
+  "name": "@telegraph/tabs",
+  "version": "0.0.0",
+  "description": "Standardized tabs component for navigation",
+  "repository": "https://github.com/knocklabs/telegraph/tree/main/packages/tabs",
+  "author": "@knocklabs",
+  "license": "MIT",
+  "main": "./dist/cjs/index.js",
+  "module": "./dist/esm/index.mjs",
+  "types": "./dist/types/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/esm/index.mjs",
+      "require": "./dist/cjs/index.js",
+      "types": "./dist/types/index.d.ts",
+      "default": "./dist/css/default.css"
+    },
+    "./default.css": "./dist/css/default.css"
+  },
+  "files": [
+    "dist",
+    "README.md"
+  ],
+  "prettier": "@telegraph/prettier-config",
+  "scripts": {
+    "clean": "rm -rf dist",
+    "dev": "vite build --watch --emptyOutDir false",
+    "build": "yarn clean && vite build",
+    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+    "format": "prettier \"src/**/*.{js,ts,tsx}\" --write",
+    "format:check": "prettier \"src/**/*.{js,ts,tsx}\" --check",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@radix-ui/react-tabs": "^1.1.3",
+    "@telegraph/button": "workspace:^",
+    "@telegraph/helpers": "workspace:^",
+    "@telegraph/icon": "workspace:^",
+    "@telegraph/layout": "workspace:^",
+    "@telegraph/menu": "workspace:^",
+    "@telegraph/typography": "workspace:^"
+  },
+  "devDependencies": {
+    "@knocklabs/eslint-config": "^0.0.3",
+    "@knocklabs/typescript-config": "^0.0.2",
+    "@telegraph/postcss-config": "workspace:^",
+    "@telegraph/prettier-config": "workspace:^",
+    "@telegraph/vite-config": "workspace:^",
+    "@types/react": "^18.3.18",
+    "eslint": "^8.56.0",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "typescript": "^5.7.3",
+    "vite": "^6.0.11"
+  },
+  "peerDependencies": {
+    "react": "^18.0.0 || ^19.0.0",
+    "react-dom": "^18.0.0 || ^19.0.0"
+  }
+}
diff --git a/packages/tabs/postcss.config.mjs b/packages/tabs/postcss.config.mjs
new file mode 100644
index 00000000..7f1f7c29
--- /dev/null
+++ b/packages/tabs/postcss.config.mjs
@@ -0,0 +1,5 @@
+import pkg from "@telegraph/postcss-config";
+
+const { styleEnginePostCssConfig } = pkg;
+
+export default styleEnginePostCssConfig;
diff --git a/packages/tabs/src/Tab/Tab.tsx b/packages/tabs/src/Tab/Tab.tsx
new file mode 100644
index 00000000..481eacc3
--- /dev/null
+++ b/packages/tabs/src/Tab/Tab.tsx
@@ -0,0 +1,80 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import {
+  RefToTgphRef,
+  type TgphComponentProps,
+  type TgphElement,
+} from "@telegraph/helpers";
+import { MenuItem } from "@telegraph/menu";
+import React from "react";
+
+/**
+ * Props for the Tab component
+ * @property {string} value - Unique identifier for the tab
+ * @property {React.ReactNode} children - Content to display in the tab
+ * @property {boolean} disabled - Whether this tab is disabled
+ * @property {() => void} onClick - Additional onClick handler
+ * @property {TabIconProps} leadingIcon - Icon to display on the left side of the tab
+ * @property {TabIconProps} trailingIcon - Icon to display on the right side of the tab
+ */
+export type TabProps<T extends TgphElement> = {
+  value: string;
+} & TgphComponentProps<typeof MenuItem<T>>;
+
+/**
+ * Tab component that uses RadixTabs.Trigger with MenuItem styling
+ */
+const Tab = <T extends TgphElement>({
+  disabled = false,
+  value,
+  children,
+  onClick,
+  leadingIcon,
+  trailingIcon,
+  icon,
+  ...props
+}: TabProps<T>) => {
+  const defaultIconProps: TgphComponentProps<
+    typeof MenuItem<T>
+  >["leadingIcon"] = {
+    size: "6",
+    color: "gray",
+    variant: "secondary",
+  };
+
+  const combinedLeadingIcon = leadingIcon
+    ? ({ ...defaultIconProps, ...leadingIcon } as const)
+    : icon
+      ? ({ ...defaultIconProps, ...icon } as const)
+      : undefined;
+
+  return (
+    <RadixTabs.Trigger
+      value={value}
+      disabled={disabled}
+      onClick={onClick}
+      asChild
+    >
+      <RefToTgphRef>
+        <MenuItem
+          leadingIcon={combinedLeadingIcon}
+          trailingIcon={
+            trailingIcon && { ...defaultIconProps, ...trailingIcon }
+          }
+          disabled={disabled}
+          py="4"
+          px="2"
+          position="relative"
+          data-tgph-tab=""
+          gap="2"
+          color="gray"
+          size="1"
+          {...props}
+        >
+          {children}
+        </MenuItem>
+      </RefToTgphRef>
+    </RadixTabs.Trigger>
+  );
+};
+
+export { Tab };
diff --git a/packages/tabs/src/Tab/index.ts b/packages/tabs/src/Tab/index.ts
new file mode 100644
index 00000000..70c0b817
--- /dev/null
+++ b/packages/tabs/src/Tab/index.ts
@@ -0,0 +1 @@
+export { Tab } from "./Tab";
diff --git a/packages/tabs/src/TabList/TabList.tsx b/packages/tabs/src/TabList/TabList.tsx
new file mode 100644
index 00000000..b2279e55
--- /dev/null
+++ b/packages/tabs/src/TabList/TabList.tsx
@@ -0,0 +1,35 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { RefToTgphRef, type TgphComponentProps } from "@telegraph/helpers";
+import { Stack } from "@telegraph/layout";
+import React from "react";
+
+export type TabListProps = TgphComponentProps<typeof Stack> &
+  React.ComponentProps<typeof RadixTabs.List>;
+
+/**
+ * Container component for Tab components
+ * Renders a Radix TabsList
+ */
+const TabList = ({ children, loop = true, ...props }: TabListProps) => {
+  return (
+    <RadixTabs.List asChild loop={loop}>
+      <RefToTgphRef>
+        <Stack
+          flexDirection={"row"}
+          spacing="2"
+          gap="1"
+          paddingBottom={"2"}
+          paddingRight={"0"}
+          marginBottom="4"
+          position="relative"
+          data-tgph-tab-list=""
+          {...props}
+        >
+          {children}
+        </Stack>
+      </RefToTgphRef>
+    </RadixTabs.List>
+  );
+};
+
+export { TabList };
diff --git a/packages/tabs/src/TabList/index.ts b/packages/tabs/src/TabList/index.ts
new file mode 100644
index 00000000..05697d53
--- /dev/null
+++ b/packages/tabs/src/TabList/index.ts
@@ -0,0 +1 @@
+export { TabList } from "./TabList";
diff --git a/packages/tabs/src/Tabs/TabPanel.tsx b/packages/tabs/src/Tabs/TabPanel.tsx
new file mode 100644
index 00000000..988fee5d
--- /dev/null
+++ b/packages/tabs/src/Tabs/TabPanel.tsx
@@ -0,0 +1,25 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { RefToTgphRef, type TgphComponentProps } from "@telegraph/helpers";
+import { Box } from "@telegraph/layout";
+import React from "react";
+
+export type TabPanelProps = TgphComponentProps<typeof Box> &
+  React.ComponentProps<typeof RadixTabs.Content>;
+
+/**
+ * Content panel associated with a Tab
+ * Only renders when its associated tab is active
+ */
+const TabPanel = ({ value, children, forceMount, ...props }: TabPanelProps) => {
+  return (
+    <RadixTabs.Content value={value} forceMount={forceMount} asChild>
+      <RefToTgphRef>
+        <Box data-tgph-tab-panel="" {...props}>
+          {children}
+        </Box>
+      </RefToTgphRef>
+    </RadixTabs.Content>
+  );
+};
+
+export { TabPanel };
diff --git a/packages/tabs/src/Tabs/Tabs.tsx b/packages/tabs/src/Tabs/Tabs.tsx
new file mode 100644
index 00000000..c0903b38
--- /dev/null
+++ b/packages/tabs/src/Tabs/Tabs.tsx
@@ -0,0 +1,36 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { RefToTgphRef, TgphComponentProps } from "@telegraph/helpers";
+import { Box } from "@telegraph/layout";
+import React from "react";
+
+export type TabsProps = TgphComponentProps<typeof Box> &
+  React.ComponentProps<typeof RadixTabs.Root>;
+
+/**
+ * Root component for Tabs
+ * Provides context for tab state management and renders child components
+ */
+const Tabs = ({
+  children,
+  defaultValue,
+  value,
+  onValueChange,
+  ...props
+}: TabsProps) => {
+  return (
+    <RadixTabs.Root
+      defaultValue={defaultValue}
+      value={value}
+      onValueChange={onValueChange}
+      asChild
+    >
+      <RefToTgphRef>
+        <Box data-tgph-tabs="" {...props}>
+          {children}
+        </Box>
+      </RefToTgphRef>
+    </RadixTabs.Root>
+  );
+};
+
+export { Tabs };
diff --git a/packages/tabs/src/Tabs/index.ts b/packages/tabs/src/Tabs/index.ts
new file mode 100644
index 00000000..b9bb2783
--- /dev/null
+++ b/packages/tabs/src/Tabs/index.ts
@@ -0,0 +1,2 @@
+export { TabPanel } from "./TabPanel";
+export { Tabs } from "./Tabs";
diff --git a/packages/tabs/src/default.css b/packages/tabs/src/default.css
new file mode 100644
index 00000000..c1fb3589
--- /dev/null
+++ b/packages/tabs/src/default.css
@@ -0,0 +1,45 @@
+/* Active tab style - orange underline */
+[data-tgph-tab][data-state="active"]::after {
+  animation: tabActivate 0.15s cubic-bezier(0.4, 0, 0.2, 1);
+  background-color: var(--tgph-accent-9, #ff4f00);
+  bottom: calc(var(--tgph-spacing-2) * -1);
+  border-radius: 1px;
+  content: '';
+  height: 1px;
+  left: 0;
+  position: absolute;
+  right: 0;
+  transform-origin: center;
+}
+
+@keyframes tabActivate {
+  from {
+    transform: scaleX(0);
+    opacity: 0;
+  }
+
+  to {
+    transform: scaleX(1);
+    opacity: 1;
+  }
+}
+
+/* Active tab text and icon color */
+[data-tgph-tab][data-state="active"] .tgph-text {
+  color: var(--tgph-accent-11, #D13415);
+}
+
+/* Active tab icon color */
+[data-tgph-tab][data-state="active"] svg {
+  color: var(--tgph-accent-10, #DD4425);
+}
+
+/* Disabled tab icon color */
+[data-tgph-tab][data-disabled] svg {
+  color: var(--tgph-gray-7, #CDCED6);
+}
+
+/* Disabled tab text color */
+[data-tgph-tab][data-disabled] .tgph-text {
+  color: var(--tgph-gray-9, #8B8D98);
+}
diff --git a/packages/tabs/src/index.ts b/packages/tabs/src/index.ts
new file mode 100644
index 00000000..0c869ab6
--- /dev/null
+++ b/packages/tabs/src/index.ts
@@ -0,0 +1,12 @@
+import { Tab } from "./Tab";
+import { TabList } from "./TabList";
+import { TabPanel, Tabs } from "./Tabs";
+
+// Main component with nested properties
+const TabsComponent = Object.assign(Tabs, {
+  Tab,
+  List: TabList,
+  Panel: TabPanel,
+});
+
+export { TabsComponent as Tabs };
diff --git a/packages/tabs/src/stories/Tabs.stories.tsx b/packages/tabs/src/stories/Tabs.stories.tsx
new file mode 100644
index 00000000..4590bc18
--- /dev/null
+++ b/packages/tabs/src/stories/Tabs.stories.tsx
@@ -0,0 +1,142 @@
+import { Tabs } from "..";
+import { Lucide } from "@telegraph/icon";
+import { Box } from "@telegraph/layout";
+import React from "react";
+
+/**
+ * Example stories for the Tabs component
+ */
+export default {
+  tags: ["autodocs"],
+  title: "Components/Tabs",
+  component: Tabs,
+};
+
+/**
+ * Basic usage example
+ */
+export const Basic = () => (
+  <Tabs defaultValue="tab1">
+    <Tabs.List>
+      <Tabs.Tab value="tab1">First Tab</Tabs.Tab>
+      <Tabs.Tab value="tab2">Second Tab</Tabs.Tab>
+      <Tabs.Tab value="tab3">Third Tab</Tabs.Tab>
+    </Tabs.List>
+
+    <Tabs.Panel value="tab1">
+      <Box py="4">Content for the first tab</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="tab2">
+      <Box py="4">Content for the second tab</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="tab3">
+      <Box py="4">Content for the third tab</Box>
+    </Tabs.Panel>
+  </Tabs>
+);
+
+/**
+ * Example with the new icon API
+ */
+export const WithIcons = () => (
+  <Tabs defaultValue="home">
+    <Tabs.List>
+      <Tabs.Tab
+        value="home"
+        leadingIcon={{
+          icon: Lucide.Plus,
+          alt: "Home",
+        }}
+      >
+        Tab
+      </Tabs.Tab>
+      <Tabs.Tab
+        value="settings"
+        leadingIcon={{
+          icon: Lucide.Plus,
+          alt: "Settings",
+        }}
+      >
+        Tab
+      </Tabs.Tab>
+      <Tabs.Tab
+        value="notifications"
+        leadingIcon={{
+          icon: Lucide.Plus,
+          alt: "Notifications",
+        }}
+      >
+        Tab
+      </Tabs.Tab>
+    </Tabs.List>
+
+    <Tabs.Panel value="home">
+      <Box py="4">Home content</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="settings">
+      <Box py="4">Settings content</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="notifications">
+      <Box py="4">Notifications content</Box>
+    </Tabs.Panel>
+  </Tabs>
+);
+
+/**
+ * Example with disabled tabs
+ */
+export const Disabled = () => (
+  <Tabs defaultValue="tab1">
+    <Tabs.List>
+      <Tabs.Tab value="tab1">Enabled Tab</Tabs.Tab>
+      <Tabs.Tab value="tab2" disabled icon={{ icon: Lucide.Plus, alt: "Plus" }}>
+        Disabled Tab
+      </Tabs.Tab>
+      <Tabs.Tab value="tab3">Enabled Tab</Tabs.Tab>
+    </Tabs.List>
+
+    <Tabs.Panel value="tab1">
+      <Box py="4">Content for the first tab</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="tab2">
+      <Box py="4">Content for the second tab (disabled)</Box>
+    </Tabs.Panel>
+    <Tabs.Panel value="tab3">
+      <Box py="4">Content for the third tab</Box>
+    </Tabs.Panel>
+  </Tabs>
+);
+
+/**
+ * Example of controlled tabs with state management
+ */
+export const Controlled = () => {
+  const [activeTab, setActiveTab] = React.useState("tab1");
+
+  return (
+    <Tabs value={activeTab} onValueChange={setActiveTab}>
+      <Tabs.List>
+        <Tabs.Tab value="tab1">First Tab</Tabs.Tab>
+        <Tabs.Tab value="tab2">Second Tab</Tabs.Tab>
+        <Tabs.Tab value="tab3">Third Tab</Tabs.Tab>
+      </Tabs.List>
+
+      <Box display="flex" gap="2" py="4">
+        <Box>Current active tab: {activeTab}</Box>
+        <button onClick={() => setActiveTab("tab1")}>Activate Tab 1</button>
+        <button onClick={() => setActiveTab("tab2")}>Activate Tab 2</button>
+        <button onClick={() => setActiveTab("tab3")}>Activate Tab 3</button>
+      </Box>
+
+      <Tabs.Panel value="tab1">
+        <Box py="4">Content for the first tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab2">
+        <Box py="4">Content for the second tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab3">
+        <Box py="4">Content for the third tab</Box>
+      </Tabs.Panel>
+    </Tabs>
+  );
+};
diff --git a/packages/tabs/tsconfig.json b/packages/tabs/tsconfig.json
new file mode 100644
index 00000000..b9d7bd8a
--- /dev/null
+++ b/packages/tabs/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@knocklabs/typescript-config/react-library.json",
+  "display": "@telegraph/tabs",
+  "compilerOptions": {
+    "outDir": "dist",
+    "paths": {
+      "vitest.*": ["../../vitest/*"]
+    },
+    "types": ["@testing-library/jest-dom", "node"]
+  },
+  "include": ["src/**/*"],
+  "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "dist", "node_modules"]
+}
diff --git a/packages/tabs/vite.config.mts b/packages/tabs/vite.config.mts
new file mode 100644
index 00000000..6893b916
--- /dev/null
+++ b/packages/tabs/vite.config.mts
@@ -0,0 +1,7 @@
+import {
+  defaultViteConfig,
+  scopedCssViteConfig,
+} from "@telegraph/vite-config";
+import { mergeConfig } from "vite";
+
+export default mergeConfig(defaultViteConfig, scopedCssViteConfig);
diff --git a/packages/tabs/vitest.config.mts b/packages/tabs/vitest.config.mts
new file mode 100644
index 00000000..6b3d848c
--- /dev/null
+++ b/packages/tabs/vitest.config.mts
@@ -0,0 +1,13 @@
+import { defineProject, mergeConfig } from "vitest/config";
+
+import { sharedConfig } from "../../vitest/config";
+
+export default mergeConfig(
+  { ...sharedConfig },
+  defineProject({
+    test: {
+      name: "tabs",
+      environment: "jsdom",
+    },
+  }),
+);
diff --git a/yarn.lock b/yarn.lock
index 2a1a24d4..556940c5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2771,6 +2771,28 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@radix-ui/react-collection@npm:1.1.2":
+  version: 1.1.2
+  resolution: "@radix-ui/react-collection@npm:1.1.2"
+  dependencies:
+    "@radix-ui/react-compose-refs": "npm:1.1.1"
+    "@radix-ui/react-context": "npm:1.1.1"
+    "@radix-ui/react-primitive": "npm:2.0.2"
+    "@radix-ui/react-slot": "npm:1.1.2"
+  peerDependencies:
+    "@types/react": "*"
+    "@types/react-dom": "*"
+    react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+  peerDependenciesMeta:
+    "@types/react":
+      optional: true
+    "@types/react-dom":
+      optional: true
+  checksum: 10c0/8376aa0c0f38efbb45e5c0a2e8724b0ca2ccdab511f5aee4c3eb62a89959b20be0d4dd410b7068bc13d722751cbc88e916e10573784fb26b084c43f930818715
+  languageName: node
+  linkType: hard
+
 "@radix-ui/react-compose-refs@npm:1.0.1":
   version: 1.0.1
   resolution: "@radix-ui/react-compose-refs@npm:1.0.1"
@@ -3599,6 +3621,33 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@radix-ui/react-roving-focus@npm:1.1.2":
+  version: 1.1.2
+  resolution: "@radix-ui/react-roving-focus@npm:1.1.2"
+  dependencies:
+    "@radix-ui/primitive": "npm:1.1.1"
+    "@radix-ui/react-collection": "npm:1.1.2"
+    "@radix-ui/react-compose-refs": "npm:1.1.1"
+    "@radix-ui/react-context": "npm:1.1.1"
+    "@radix-ui/react-direction": "npm:1.1.0"
+    "@radix-ui/react-id": "npm:1.1.0"
+    "@radix-ui/react-primitive": "npm:2.0.2"
+    "@radix-ui/react-use-callback-ref": "npm:1.1.0"
+    "@radix-ui/react-use-controllable-state": "npm:1.1.0"
+  peerDependencies:
+    "@types/react": "*"
+    "@types/react-dom": "*"
+    react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+  peerDependenciesMeta:
+    "@types/react":
+      optional: true
+    "@types/react-dom":
+      optional: true
+  checksum: 10c0/80e378e1156d5b8af14995e908fe2358c8f4757fbf274e30d2ee3c1cedc3a0c7192524df7e3bb1d5011ee9ab8ab7445b60eff06617370e58abcd1ae97e0e40f6
+  languageName: node
+  linkType: hard
+
 "@radix-ui/react-slot@npm:1.0.2":
   version: 1.0.2
   resolution: "@radix-ui/react-slot@npm:1.0.2"
@@ -3660,6 +3709,32 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@radix-ui/react-tabs@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "@radix-ui/react-tabs@npm:1.1.3"
+  dependencies:
+    "@radix-ui/primitive": "npm:1.1.1"
+    "@radix-ui/react-context": "npm:1.1.1"
+    "@radix-ui/react-direction": "npm:1.1.0"
+    "@radix-ui/react-id": "npm:1.1.0"
+    "@radix-ui/react-presence": "npm:1.1.2"
+    "@radix-ui/react-primitive": "npm:2.0.2"
+    "@radix-ui/react-roving-focus": "npm:1.1.2"
+    "@radix-ui/react-use-controllable-state": "npm:1.1.0"
+  peerDependencies:
+    "@types/react": "*"
+    "@types/react-dom": "*"
+    react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+  peerDependenciesMeta:
+    "@types/react":
+      optional: true
+    "@types/react-dom":
+      optional: true
+  checksum: 10c0/2f621c43a8e1dd0d54c828f8b4d88414c9114af6b720a650ad9587cc0a7a7536da778f2fe5181a38494cc2956f2b238fbe64790f6daad1d058b34f4acaee520e
+  languageName: node
+  linkType: hard
+
 "@radix-ui/react-toggle-group@npm:^1.1.0":
   version: 1.1.0
   resolution: "@radix-ui/react-toggle-group@npm:1.1.0"
@@ -5432,6 +5507,34 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@telegraph/tabs@workspace:packages/tabs":
+  version: 0.0.0-use.local
+  resolution: "@telegraph/tabs@workspace:packages/tabs"
+  dependencies:
+    "@knocklabs/eslint-config": "npm:^0.0.3"
+    "@knocklabs/typescript-config": "npm:^0.0.2"
+    "@radix-ui/react-tabs": "npm:^1.1.3"
+    "@telegraph/button": "workspace:^"
+    "@telegraph/helpers": "workspace:^"
+    "@telegraph/icon": "workspace:^"
+    "@telegraph/layout": "workspace:^"
+    "@telegraph/menu": "workspace:^"
+    "@telegraph/postcss-config": "workspace:^"
+    "@telegraph/prettier-config": "workspace:^"
+    "@telegraph/typography": "workspace:^"
+    "@telegraph/vite-config": "workspace:^"
+    "@types/react": "npm:^18.3.18"
+    eslint: "npm:^8.56.0"
+    react: "npm:^18.3.1"
+    react-dom: "npm:^18.3.1"
+    typescript: "npm:^5.7.3"
+    vite: "npm:^6.0.11"
+  peerDependencies:
+    react: ^18.0.0 || ^19.0.0
+    react-dom: ^18.0.0 || ^19.0.0
+  languageName: unknown
+  linkType: soft
+
 "@telegraph/tag@workspace:^, @telegraph/tag@workspace:packages/tag":
   version: 0.0.0-use.local
   resolution: "@telegraph/tag@workspace:packages/tag"


Reviews:
user: login: kylemcd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T21:35:56Z
user: login: kylemcd
body: I think this looks like a great V1 let's do it!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T15:20:16Z

Review comments:
diff_hunk: @@ -0,0 +1,201 @@
+![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)
+
+[![npm version](https://img.shields.io/npm/v/@telegraph/button.svg)](https://www.npmjs.com/package/@telegraph/tabs)
+
+# @telegraph/tabs
+> Standardized tabs component for navigation
+
+A composable tabs component based on [Radix UI Tabs](https://www.radix-ui.com/primitives/docs/components/tabs) with Telegraph design system styling.
+
+## Installation Instructions
+
+```
+npm install @telegraph/tabs
+```
+
+## Usage
+
+```jsx
+import { Tabs } from '@telegraph/tabs';
+import { Box } from '@telegraph/layout';
+import { Lucide } from '@telegraph/icon';
+
+function MyTabs() {
+  return (
+    <Tabs defaultValue="tab1">
+      {/* Composable pattern with nested components */}
+      <Tabs.List>
+        <Tabs.Tab 
+          value="tab1"
+          leadingIcon={{
+            icon: Lucide.Home,
+            alt: "Home icon"
+          }}
+        >
+          First Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab2"
+          leadingIcon={{
+            icon: Lucide.Settings,
+            alt: "Settings icon"
+          }}
+        >
+          Second Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab3"
+          leadingIcon={{
+            icon: Lucide.Bell,
+            alt: "Notifications icon"
+          }}
+        >
+          Third Tab
+        </Tabs.Tab>
+      </Tabs.List>
+      
+      <Tabs.Panel value="tab1">
+        <Box padding={4}>Content for the first tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab2">
+        <Box padding={4}>Content for the second tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab3">
+        <Box padding={4}>Content for the third tab</Box>
+      </Tabs.Panel>
path: packages/tabs/README.md
user: login: kylemcd
body: ```suggestion
      <Tabs.Panel value="tab1">
        <Box padding="4">Content for the first tab</Box>
      </Tabs.Panel>
      <Tabs.Panel value="tab2">
        <Box padding="4">Content for the second tab</Box>
      </Tabs.Panel>
      <Tabs.Panel value="tab3">
        <Box padding="4">Content for the third tab</Box>
      </Tabs.Panel>
```
created_at: 2025-04-03T21:24:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,201 @@
+![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)
+
+[![npm version](https://img.shields.io/npm/v/@telegraph/button.svg)](https://www.npmjs.com/package/@telegraph/tabs)
+
+# @telegraph/tabs
+> Standardized tabs component for navigation
+
+A composable tabs component based on [Radix UI Tabs](https://www.radix-ui.com/primitives/docs/components/tabs) with Telegraph design system styling.
+
+## Installation Instructions
+
+```
+npm install @telegraph/tabs
+```
+
+## Usage
+
+```jsx
+import { Tabs } from '@telegraph/tabs';
+import { Box } from '@telegraph/layout';
+import { Lucide } from '@telegraph/icon';
+
+function MyTabs() {
+  return (
+    <Tabs defaultValue="tab1">
+      {/* Composable pattern with nested components */}
+      <Tabs.List>
+        <Tabs.Tab 
+          value="tab1"
+          leadingIcon={{
+            icon: Lucide.Home,
+            alt: "Home icon"
+          }}
+        >
+          First Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab2"
+          leadingIcon={{
+            icon: Lucide.Settings,
+            alt: "Settings icon"
+          }}
+        >
+          Second Tab
+        </Tabs.Tab>
+        <Tabs.Tab 
+          value="tab3"
+          leadingIcon={{
+            icon: Lucide.Bell,
+            alt: "Notifications icon"
+          }}
+        >
+          Third Tab
+        </Tabs.Tab>
+      </Tabs.List>
+      
+      <Tabs.Panel value="tab1">
+        <Box padding={4}>Content for the first tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab2">
+        <Box padding={4}>Content for the second tab</Box>
+      </Tabs.Panel>
+      <Tabs.Panel value="tab3">
+        <Box padding={4}>Content for the third tab</Box>
+      </Tabs.Panel>
path: packages/tabs/README.md
user: login: kylemcd
body: Would want to make these updates to all of the examples since `<Box/>` accepts strings.
created_at: 2025-04-03T21:25:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { TgphComponentProps, TgphElement } from "@telegraph/helpers";
+import { MenuItem } from "@telegraph/menu";
+import React from "react";
+
+/**
+ * Props for the Tab component
+ * @property {string} value - Unique identifier for the tab
+ * @property {React.ReactNode} children - Content to display in the tab
+ * @property {boolean} disabled - Whether this tab is disabled
+ * @property {() => void} onClick - Additional onClick handler
+ * @property {string} className - Additional CSS class names
+ * @property {TabIconProps} leadingIcon - Icon to display on the left side of the tab
+ * @property {TabIconProps} trailingIcon - Icon to display on the right side of the tab
+ * @property {string} variant - Visual style variant of the tab
+ * @property {string} size - Size of the tab
path: packages/tabs/src/Tab/Tab.tsx
user: login: kylemcd
body: I think you can purge a lot of these now. 
created_at: 2025-04-03T21:26:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { TgphComponentProps, TgphElement } from "@telegraph/helpers";
+import { MenuItem } from "@telegraph/menu";
+import React from "react";
+
+/**
+ * Props for the Tab component
+ * @property {string} value - Unique identifier for the tab
+ * @property {React.ReactNode} children - Content to display in the tab
+ * @property {boolean} disabled - Whether this tab is disabled
+ * @property {() => void} onClick - Additional onClick handler
+ * @property {string} className - Additional CSS class names
+ * @property {TabIconProps} leadingIcon - Icon to display on the left side of the tab
+ * @property {TabIconProps} trailingIcon - Icon to display on the right side of the tab
+ * @property {string} variant - Visual style variant of the tab
+ * @property {string} size - Size of the tab
+ */
+export type TabProps<T extends TgphElement> = {
+  value: string;
+} & TgphComponentProps<typeof MenuItem<T>>;
+
+/**
+ * Tab component that uses RadixTabs.Trigger with MenuItem styling
+ */
+const Tab = <T extends TgphElement>({
+  value,
+  children,
+  disabled = false,
+  onClick,
+  className = "",
+  variant = "ghost",
+  size = "1",
path: packages/tabs/src/Tab/Tab.tsx
user: login: kylemcd
body: Nit: can you order the defaulted variables to the top. Makes it easier to keep track of them that way. 
created_at: 2025-04-03T21:27:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { TgphComponentProps, TgphElement } from "@telegraph/helpers";
+import { MenuItem } from "@telegraph/menu";
+import React from "react";
+
+/**
+ * Props for the Tab component
+ * @property {string} value - Unique identifier for the tab
+ * @property {React.ReactNode} children - Content to display in the tab
+ * @property {boolean} disabled - Whether this tab is disabled
+ * @property {() => void} onClick - Additional onClick handler
+ * @property {string} className - Additional CSS class names
+ * @property {TabIconProps} leadingIcon - Icon to display on the left side of the tab
+ * @property {TabIconProps} trailingIcon - Icon to display on the right side of the tab
+ * @property {string} variant - Visual style variant of the tab
+ * @property {string} size - Size of the tab
+ */
+export type TabProps<T extends TgphElement> = {
+  value: string;
+} & TgphComponentProps<typeof MenuItem<T>>;
+
+/**
+ * Tab component that uses RadixTabs.Trigger with MenuItem styling
+ */
+const Tab = <T extends TgphElement>({
+  value,
+  children,
+  disabled = false,
+  onClick,
+  className = "",
+  variant = "ghost",
+  size = "1",
+  leadingIcon,
+  trailingIcon,
+  icon,
+  ...props
+}: TabProps<T>) => {
+  const defaultIconProps: TgphComponentProps<
+    typeof MenuItem<T>
+  >["leadingIcon"] = {
+    size: "6",
+    color: "gray",
+    variant: "secondary",
+  };
+
+  const combinedLeadingIcon = (
+    leadingIcon
+      ? { ...defaultIconProps, ...leadingIcon }
+      : icon
+        ? { ...defaultIconProps, ...icon }
+        : undefined
+  ) as TgphComponentProps<typeof MenuItem<T>>["leadingIcon"] | undefined;
path: packages/tabs/src/Tab/Tab.tsx
user: login: kylemcd
body: Curious if you still need to typecast if you put `as const` behind these instead?
created_at: 2025-04-03T21:27:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+import { Tab } from "./Tab";
+
+export { Tab };
path: packages/tabs/src/Tab/index.ts
user: login: kylemcd
body: ```suggestion
export { Tab } from "./Tab";
```
created_at: 2025-04-03T21:28:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { Stack } from "@telegraph/layout";
+import React from "react";
+
+/**
+ * Props for the TabList component
+ * @property {React.ReactNode} children - Tab components to render
+ * @property {string} className - Additional CSS class names
+ * @property {boolean} loop - Whether keyboard navigation should loop from last tab to first
+ * @property {boolean} hasBorder - Whether to show a bottom border
+ */
+export type TabListProps = {
+  children: React.ReactNode;
+  className?: string;
+  loop?: boolean;
+  hasBorder?: boolean;
+};
+
path: packages/tabs/src/TabList/TabList.tsx
user: login: kylemcd
body: ```suggestion
export type TabListProps = TgphComponentProps<typeof Stack> & React.ComponentProps<typeof RadixTabs.List>
```
created_at: 2025-04-03T21:29:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { Stack } from "@telegraph/layout";
+import React from "react";
+
+/**
+ * Props for the TabList component
+ * @property {React.ReactNode} children - Tab components to render
+ * @property {string} className - Additional CSS class names
+ * @property {boolean} loop - Whether keyboard navigation should loop from last tab to first
+ * @property {boolean} hasBorder - Whether to show a bottom border
+ */
+export type TabListProps = {
+  children: React.ReactNode;
+  className?: string;
+  loop?: boolean;
+  hasBorder?: boolean;
+};
+
+/**
+ * Container component for Tab components
+ * Renders a Radix TabsList
+ */
+const TabList = ({ children, className = "", loop = true }: TabListProps) => {
+  return (
+    <RadixTabs.List asChild loop={loop}>
+      <Stack
+        flexDirection={"row"}
+        spacing="2"
+        gap="1"
+        paddingBottom={"2"}
+        paddingRight={"0"}
+        marginBottom="4"
+        className={className}
+        position="relative"
+        data-tgph-tab-list=""
path: packages/tabs/src/TabList/TabList.tsx
user: login: kylemcd
body: ```suggestion
        flexDirection="row"
        spacing="2"
        gap="1"
        paddingBottom="2"
        paddingRight="0"
        marginBottom="4"
        position="relative"
        data-tgph-tab-list=""
        {...props}
```
created_at: 2025-04-03T21:30:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+import { TabList } from "./TabList";
+
+export { TabList };
path: packages/tabs/src/TabList/index.ts
user: login: kylemcd
body: export { TabList } from "./TabList"
created_at: 2025-04-03T21:30:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,40 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { type TgphComponentProps } from "@telegraph/helpers";
+import { Box } from "@telegraph/layout";
+import React from "react";
+
path: packages/tabs/src/Tabs/TabPanel.tsx
user: login: kylemcd
body: This file should be modified the same as TabList. We want to utilize existing types as much as possible and make sure we're extending our built in style engine by passing props to `Box`.
created_at: 2025-04-03T21:31:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import React from "react";
+
+/**
+ * Props for the Tabs (Root) component
+ * @property {React.ReactNode} children - Child components
+ * @property {string} defaultValue - The ID of the default active tab
+ * @property {string} value - The controlled value of the tab to activate
+ * @property {(value: string) => void} onValueChange - Callback when the active tab changes
+ * @property {string} className - Additional CSS class names
+ */
+export type TabsProps = {
+  children: React.ReactNode;
+  defaultValue?: string;
+  value?: string;
+  onValueChange?: (value: string) => void;
+  className?: string;
+  disabled?: boolean;
+};
path: packages/tabs/src/Tabs/Tabs.tsx
user: login: kylemcd
body: Same here. We should almost never be declaring our own props unless we're working on a totally new primitive. 
created_at: 2025-04-03T21:32:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+import React, { createContext, useContext, useState } from "react";
+
+/**
+ * Props for the TabsContext provider
+ * @property {string} activeTab - The ID of the active tab
+ * @property {(id: string) => void} setActiveTab - Function to set the active tab by ID
+ * @property {boolean} isDisabled - Whether all tabs are disabled
+ */
+type TabsContextProps = {
+  activeTab: string;
+  setActiveTab: (id: string) => void;
+  isDisabled?: boolean;
+};
+
+const TabsContext = createContext<TabsContextProps | undefined>(undefined);
+
+/**
+ * Props for the TabsContextProvider component
+ * @property {React.ReactNode} children - Child components
+ * @property {string} defaultTab - The default active tab ID
+ * @property {boolean} isDisabled - Whether all tabs are disabled
+ */
+type TabsContextProviderProps = {
+  children: React.ReactNode;
+  defaultTab?: string;
+  isDisabled?: boolean;
+};
+
+/**
+ * Provider component for the Tabs context
+ */
+const TabsContextProvider = ({
+  children,
+  defaultTab = "",
+  isDisabled = false,
+}: TabsContextProviderProps) => {
+  const [activeTab, setActiveTab] = useState<string>(defaultTab);
path: packages/tabs/src/Tabs/TabsContext.tsx
user: login: kylemcd
body: ```suggestion
const TabsContextProvider = ({
  value, 
  defaultValue,
  onValueChange,
  disabled,
  children
}: TabsContextProviderProps) => {
  const [activeTab, setActiveTab] = useControllableState({
    prop: value,
    defaultProp: defaultValue,
    onChange: onValueChange
  })
```
created_at: 2025-04-03T21:35:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+/* Active tab style - orange underline */
+[data-tgph-tab][data-state="active"]::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: calc(var(--tgph-spacing-2) * -1);
+  height: 1px;
+  background-color: var(--tgraph-accent-9, #ff4f00);
+  border-radius: 1px;
+}
+
+/* Active tab text and icon color */
+[data-tgph-tab][data-state="active"] .tgph-text {
+  color: var(--tgraph-accent-11, #D13415);
+}
+
+/* Active tab icon color */
+[data-tgph-tab][data-state="active"] svg {
+  color: var(--tgraph-accent-10, #DD4425);
+}
+/* Disabled tab icon color */
+[data-tgph-tab][data-disabled] svg {
+  color: var(--tgraph-gray-7, #CDCED6);
+}
+
+/* Disabled tab text color */
+[data-tgph-tab][data-disabled] .tgph-text {
+  color: var(--tgraph-gray-9, #8B8D98);
+}
path: packages/tabs/src/default.css
user: login: kylemcd
body: These need to be `var(--tgph ... )`
created_at: 2025-04-03T21:35:37Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-04-03T18:42:19Z
body: [vc]: #J7kxV26noDD6tHQIDyy67Wp5n27JtJFniXjIb7i4zio=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0ZWxlZ3JhcGgtc3Rvcnlib29rIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tub2NrbGFicy90ZWxlZ3JhcGgtc3Rvcnlib29rLzhpdWJmNXlFQTZhaEdUTUV5TGpBM2lBM21mNUoiLCJwcmV2aWV3VXJsIjoidGVsZWdyYXBoLXN0b3J5Ym9vay1naXQtMDQtMDMtdGFicy1jb21wb25lbnQta25vY2tsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidGVsZWdyYXBoLXN0b3J5Ym9vay1naXQtMDQtMDMtdGFicy1jb21wb25lbnQta25vY2tsYWJzLnZlcmNlbC5hcHAifSwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **telegraph-storybook** | ✅ Ready ([Inspect](https://vercel.com/knocklabs/telegraph-storybook/8iubf5yEA6ahGTMEyLjA3iA3mf5J)) | [Visit Preview](https://telegraph-storybook-git-04-03-tabs-component-knocklabs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/telegraph-storybook-git-04-03-tabs-component-knocklabs.vercel.app?via=pr-comment-feedback-link) | Apr 4, 2025 3:10pm |


author_association: NONE
user: login: changeset-bot[bot]
created_at: 2025-04-03T18:42:21Z
body: ###  🦋  Changeset detected

Latest commit: 9576dd8f9ad43d327859d74563cb07155195559d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name            | Type  |
| --------------- | ----- |
| @telegraph/tabs | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/knocklabs/telegraph/new/04-03-tabs-component?filename=.changeset/tidy-mice-care.md&value=---%0A%22%40telegraph%2Ftabs%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20tabs%20component%20package%0A)


author_association: NONE
user: login: MikeCarbone
created_at: 2025-04-03T18:42:35Z
body: * **#437** <a href="https://app.graphite.dev/github/pr/knocklabs/telegraph/437?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/knocklabs/telegraph/437?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: knock-eng-bot
created_at: 2025-04-03T20:02:07Z
body: ### Package size differences

The following packages have size differences greater than 20 KB
    
- `@telegraph/tabs`: +24.59 KB
    


<details>
<summary>All package size differences</summary>

- `@telegraph/appearance`: 0.00 KB
- `@telegraph/button`: 0.00 KB
- `@telegraph/combobox`: 0.00 KB
- `@telegraph/compose-refs`: 0.00 KB
- `@telegraph/filter`: 0.00 KB
- `@telegraph/helpers`: 0.00 KB
- `@telegraph/icon`: 0.00 KB
- `@telegraph/input`: 0.00 KB
- `@telegraph/kbd`: 0.00 KB
- `@telegraph/layout`: 0.00 KB
- `@telegraph/menu`: 0.00 KB
- `@telegraph/modal`: -0.20 KB
- `@telegraph/motion`: 0.00 KB
- `@telegraph/nextjs`: 0.00 KB
- `@telegraph/popover`: 0.00 KB
- `@telegraph/postcss-config`: 0.00 KB
- `@telegraph/prettier-config`: 0.00 KB
- `@telegraph/radio`: 0.00 KB
- `@telegraph/segmented-control`: 0.00 KB
- `@telegraph/select`: 0.00 KB
- `@telegraph/style-engine`: 0.00 KB
- `@telegraph/tabs`: +24.59 KB
- `@telegraph/tag`: 0.00 KB
- `@telegraph/tailwind-config`: 0.00 KB
- `@telegraph/textarea`: 0.00 KB
- `@telegraph/tokens`: 0.00 KB
- `@telegraph/tooltip`: 0.00 KB
- `@telegraph/typography`: 0.00 KB
- `@telegraph/vite-config`: 0.00 KB

</details>
author_association: CONTRIBUTOR
user: login: linear[bot]
created_at: 2025-04-04T15:40:31Z
body: <p><a href="https://linear.app/knock/issue/KNO-6107/[telegraph]-tab-component">KNO-6107 [Telegraph] Tab component</a></p>
author_association: NONE