diff --git a/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json b/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json
index 2ed5f0b36d..248d5d185e 100644
--- a/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json
+++ b/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json
@@ -2,6 +2,7 @@
"import": "import { TabIndicator } from '@coinbase/cds-mobile/tabs/TabIndicator'",
"source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabIndicator.tsx",
"description": "A visual indicator that shows the active tab position.",
+ "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTabsActiveIndicator instead.",
"relatedComponents": [
{
"label": "TabNavigation",
diff --git a/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json b/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json
index 26caadaa9e..023854ed94 100644
--- a/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json
+++ b/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json
@@ -2,6 +2,7 @@
"import": "import { TabIndicator } from '@coinbase/cds-web/tabs/TabIndicator'",
"source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabIndicator.tsx",
"storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabindicator--default",
+ "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTabsActiveIndicator instead.",
"description": "A visual indicator that shows the active tab position.",
"relatedComponents": [
{
diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json
index 322496548a..af4c63704f 100644
--- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json
+++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json
@@ -2,6 +2,7 @@
"import": "import { TabLabel } from '@coinbase/cds-mobile/tabs/TabLabel'",
"source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabLabel.tsx",
"description": "A text label component used within tab navigation.",
+ "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTab instead.",
"relatedComponents": [
{
"label": "TabNavigation",
diff --git a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json
index 0cfca5a265..2020c5bd4c 100644
--- a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json
+++ b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json
@@ -3,6 +3,7 @@
"source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabLabel.tsx",
"storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tablabel--default",
"description": "A text label component used within tab navigation.",
+ "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTab instead.",
"relatedComponents": [
{
"label": "TabNavigation",
diff --git a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
index 83d8764979..71db0dade9 100644
--- a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
+++ b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
@@ -1,10 +1,8 @@
-Tabs is a low-level primitive for building custom tab interfaces. It requires a `TabComponent` and `TabsActiveIndicatorComponent` to render. For a ready-to-use tab experience, see [SegmentedTabs](/components/navigation/SegmentedTabs).
+Tabs manages which tab is active and positions the animated indicator. For the common **underline** pattern, pass **`TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}`** and rely on the default **`TabComponent` (`DefaultTab`)**. Use a custom **`TabComponent`** when you need layout or content beyond what `DefaultTab` provides. For **pill / segmented** controls, use [SegmentedTabs](/components/navigation/SegmentedTabs/) instead.
## Basics
-### Initial Value
-
-Use `useTabsContext` inside your `TabComponent` to access the active tab state. Pair with [TabLabel](/components/navigation/TabLabel) for consistent label styling and [TabsActiveIndicator](/components/navigation/TabIndicator) for the animated indicator.
+Out of the box, **`Tabs`** uses **`DefaultTab`** for each row (headline text, optional [DotCount](/components/other/DotCount/) via `count` / `max` on each tab) and **`DefaultTabsActiveIndicator`** for the animated underline. **`activeBackground`** sets the **underline color** (it is forwarded to the indicator as its `background` token).
```jsx
function Example() {
@@ -13,44 +11,28 @@ function Example() {
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- accessibilityRole="tab"
- accessibilityState={{ selected: isActive, disabled }}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(tabs[0]);
return (
);
}
```
-Tabs can also start with no active selection by passing `null`.
+You can omit `TabComponent` explicitly: **`Tabs`** defaults it to **`DefaultTab`**.
+
+### No initial selection
```jsx
function Example() {
@@ -59,38 +41,39 @@ function Example() {
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- accessibilityRole="tab"
- accessibilityState={{ selected: isActive, disabled }}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(null);
return (
+ );
+}
+```
+
+### Dot counts
+
+```jsx
+function Example() {
+ const tabs = [
+ { id: 'inbox', label: 'Inbox', count: 3, max: 99 },
+ { id: 'sent', label: 'Sent' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
);
}
@@ -98,60 +81,37 @@ function Example() {
### Disabled
-The entire component can be disabled with the `disabled` prop.
-
```jsx
function Example() {
const tabs = [
{ id: 'tab1', label: 'Tab 1' },
- { id: 'tab2', label: 'Tab 2' },
+ { id: 'tab2', label: 'Tab 2', disabled: true },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- accessibilityRole="tab"
- accessibilityState={{ selected: isActive, disabled }}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(tabs[0]);
return (
);
}
```
-Individual tabs can also be disabled while keeping others interactive.
+## Custom `TabComponent`
+
+Use **`useTabsContext`** with your own **`Pressable`** and **`Text`** for labels (and a custom **`TabsActiveIndicatorComponent`** if needed) when you need more control than `DefaultTab`.
```jsx
function Example() {
const tabs = [
{ id: 'tab1', label: 'Tab 1' },
- { id: 'tab2', label: 'Tab 2', disabled: true },
+ { id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
@@ -165,9 +125,9 @@ function Example() {
accessibilityRole="tab"
accessibilityState={{ selected: isActive, disabled }}
>
-
+
{label}
-
+
);
}, []);
@@ -191,11 +151,7 @@ function Example() {
}
```
-## Custom Components
-
-### Tab
-
-Pass additional data through the tab definitions and access it in your `TabComponent` to render custom content like icons.
+### Custom label content
```jsx
function Example() {
@@ -217,9 +173,9 @@ function Example() {
>
-
+
{label}
-
+
);
@@ -243,3 +199,7 @@ function Example() {
);
}
```
+
+## Accessibility
+
+Set **`accessibilityLabel`** on **`Tabs`**. **`DefaultTab`** wires `accessibilityRole="tab"` and selection state; keep tab panels in sync in your screen content.
diff --git a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx
index 239731e356..3e288890bd 100644
--- a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx
+++ b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx
@@ -1,10 +1,8 @@
-Tabs is a low-level primitive for building custom tab interfaces. It requires a `TabComponent` and `TabsActiveIndicatorComponent` to render. For a ready-to-use tab experience, see [SegmentedTabs](/components/navigation/SegmentedTabs).
+Tabs manages which tab is active and positions the animated indicator. For the common **underline** pattern, pass **`TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}`** and rely on the default **`TabComponent` (`DefaultTab`)**. Use a custom **`TabComponent`** when you need layout or content beyond what `DefaultTab` provides. For **pill / segmented** controls, use [SegmentedTabs](/components/navigation/SegmentedTabs) instead.
## Basics
-### Initial Value
-
-Use `useTabsContext` inside your `TabComponent` to access the active tab state. Pair with [TabLabel](/components/navigation/TabLabel) for consistent label styling and [TabsActiveIndicator](/components/navigation/TabIndicator) for the animated indicator.
+Out of the box, **`Tabs`** uses **`DefaultTab`** for each row (headline text, optional [DotCount](/components/other/DotCount/) via `count` / `max` on each tab) and **`DefaultTabsActiveIndicator`** for the animated underline. **`activeBackground`** sets the **underline color** (it is forwarded to the indicator as its `background` token).
```jsx live
function Example() {
@@ -13,44 +11,28 @@ function Example() {
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled, ...props }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- aria-pressed={isActive}
- {...props}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(tabs[0]);
return (
);
}
```
-Tabs can also start with no active selection by passing `null`.
+You can omit `TabComponent` explicitly: **`Tabs`** defaults it to **`DefaultTab`**.
+
+### No initial selection
```jsx live
function Example() {
@@ -59,38 +41,41 @@ function Example() {
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled, ...props }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- aria-pressed={isActive}
- {...props}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(null);
return (
+ );
+}
+```
+
+### Dot counts
+
+Optional **`count`** and **`max`** on each tab are forwarded to the badge next to the label (see [DotCount](/components/other/DotCount/)).
+
+```jsx live
+function Example() {
+ const tabs = [
+ { id: 'inbox', label: 'Inbox', count: 3, max: 99 },
+ { id: 'sent', label: 'Sent' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
);
}
@@ -98,60 +83,39 @@ function Example() {
### Disabled
-The entire component can be disabled with the `disabled` prop.
+Disable the whole row with **`disabled`**, or set **`disabled: true`** on individual tab items.
```jsx live
function Example() {
const tabs = [
{ id: 'tab1', label: 'Tab 1' },
- { id: 'tab2', label: 'Tab 2' },
+ { id: 'tab2', label: 'Tab 2', disabled: true },
{ id: 'tab3', label: 'Tab 3' },
];
-
- const TabComponent = useCallback(({ id, label, disabled, ...props }) => {
- const { activeTab, updateActiveTab } = useTabsContext();
- const isActive = activeTab?.id === id;
- return (
- updateActiveTab(id)}
- disabled={disabled}
- aria-pressed={isActive}
- {...props}
- >
-
- {label}
-
-
- );
- }, []);
-
- const ActiveIndicator = useCallback(
- (props) => ,
- [],
- );
-
const [activeTab, setActiveTab] = useState(tabs[0]);
return (
);
}
```
-Individual tabs can also be disabled while keeping others interactive.
+## Custom `TabComponent`
+
+Use **`useTabsContext`** inside your own tab button.
```jsx live
function Example() {
const tabs = [
{ id: 'tab1', label: 'Tab 1' },
- { id: 'tab2', label: 'Tab 2', disabled: true },
+ { id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
@@ -165,9 +129,9 @@ function Example() {
aria-pressed={isActive}
{...props}
>
-
+
{label}
-
+
);
}, []);
@@ -191,11 +155,9 @@ function Example() {
}
```
-## Custom Components
+### Custom label content
-### Tab
-
-Pass additional data through the tab definitions and access it in your `TabComponent` to render custom content like icons.
+Pass extra fields on each tab and read them in your `TabComponent` (for example icons).
```jsx live
function Example() {
@@ -217,9 +179,9 @@ function Example() {
>
-
+
{label}
-
+
);
@@ -243,3 +205,7 @@ function Example() {
);
}
```
+
+## Accessibility
+
+Provide a descriptive **`accessibilityLabel`** on **`Tabs`** for the tab list. **`DefaultTab`** sets `aria-controls` / `aria-selected` for each tab; pair tabs with **`role="tabpanel"`** regions in your page content when you switch panels.
diff --git a/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx b/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx
index ee3c6a6678..45c03dac67 100644
--- a/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx
+++ b/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx
@@ -1,10 +1,7 @@
-import { useState, useCallback } from 'react';
-import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
+import { useState } from 'react';
import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable';
import { StylesExplorer } from '@site/src/components/page/StylesExplorer';
-import { Tabs, TabsActiveIndicator } from '@coinbase/cds-web/tabs/Tabs';
-import { Pressable } from '@coinbase/cds-web/system/Pressable';
-import { TabLabel } from '@coinbase/cds-web/tabs/TabLabel';
+import { DefaultTabsActiveIndicator, Tabs } from '@coinbase/cds-web/tabs';
import webStylesData from ':docgen/web/tabs/Tabs/styles-data';
@@ -14,36 +11,17 @@ export const TabsExample = ({ classNames }) => {
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
];
- const TabComponent = useCallback(({ id, label, disabled, ...props }) => {
- const api = useTabsContext();
- const isActive = api.activeTab?.id === id;
- return (
- api.updateActiveTab(id)}
- disabled={disabled}
- aria-pressed={isActive}
- {...props}
- >
-
- {label}
-
-
- );
- }, []);
- const CustomIndicator = useCallback(
- (props) => ,
- [],
- );
const [activeTab, setActiveTab] = useState(tabs[0]);
return (
);
};
diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
index 851ba23c75..c0db513f49 100644
--- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
+++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
@@ -1,16 +1,20 @@
{
- "import": "import { Tabs } from '@coinbase/cds-mobile/tabs/Tabs'",
+ "import": "import { Tabs, DefaultTabsActiveIndicator } from '@coinbase/cds-mobile/tabs'",
"source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/Tabs.tsx",
- "description": "Tabs is a flexible, accessible tab navigation component for React Native, supporting animated indicators, custom tab components, and full accessibility.",
+ "description": "Tabs is a flexible, accessible tab list for switching between related views. Use `DefaultTab` and `DefaultTabsActiveIndicator` for a standard underline tab row without custom tab wiring, or provide your own `TabComponent` and `TabsActiveIndicatorComponent`. For pill-style selection, see SegmentedTabs.",
"figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=25128-9889&t=7bpcjquwgXNk9lnN-4",
"relatedComponents": [
- {
- "label": "TabNavigation",
- "url": "/components/navigation/TabNavigation/"
- },
{
"label": "SegmentedTabs",
"url": "/components/navigation/SegmentedTabs/"
+ },
+ {
+ "label": "TabIndicator",
+ "url": "/components/navigation/TabIndicator/"
+ },
+ {
+ "label": "TabLabel",
+ "url": "/components/navigation/TabLabel/"
}
],
"dependencies": [
diff --git a/apps/docs/docs/components/navigation/Tabs/webMetadata.json b/apps/docs/docs/components/navigation/Tabs/webMetadata.json
index cefe3817ee..f5a729f2a7 100644
--- a/apps/docs/docs/components/navigation/Tabs/webMetadata.json
+++ b/apps/docs/docs/components/navigation/Tabs/webMetadata.json
@@ -1,15 +1,20 @@
{
- "import": "import { Tabs } from '@coinbase/cds-web/tabs/Tabs'",
+ "import": "import { Tabs, DefaultTabsActiveIndicator } from '@coinbase/cds-web/tabs'",
"source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/Tabs.tsx",
- "description": "Tabs is a flexible, accessible tab navigation component for switching between content sections. It supports custom tab components, animated active indicators, and full keyboard navigation.",
+ "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabs--all",
+ "description": "Tabs is a flexible, accessible tab list for switching between related views. Use `DefaultTab` and `DefaultTabsActiveIndicator` for a standard underline tab row without wiring custom components, or supply your own `TabComponent` and `TabsActiveIndicatorComponent` for full control. For pill-style selection, see SegmentedTabs.",
"relatedComponents": [
- {
- "label": "TabNavigation",
- "url": "/components/navigation/TabNavigation/"
- },
{
"label": "SegmentedTabs",
"url": "/components/navigation/SegmentedTabs"
+ },
+ {
+ "label": "TabIndicator",
+ "url": "/components/navigation/TabIndicator/"
+ },
+ {
+ "label": "TabLabel",
+ "url": "/components/navigation/TabLabel/"
}
],
"dependencies": [
diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md
index 47aa6bcc66..fe69471111 100644
--- a/packages/common/CHANGELOG.md
+++ b/packages/common/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
+## 8.64.0 (4/2/2026 PST)
+
+#### 🚀 Updates
+
+- UseTabs: Added an optional second generic TTab extends TabValue so tabs, activeTab, and onChange can be typed with custom tab row shapes (defaults preserve the old behavior). [[#558](https://github.com/coinbase/cds/pull/558)]
+
## 8.63.0 ((4/1/2026, 03:43 PM PST))
This is an artificial version bump with no new change.
diff --git a/packages/common/package.json b/packages/common/package.json
index 987fc4c223..a5100a0fdd 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
- "version": "8.63.0",
+ "version": "8.64.0",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
diff --git a/packages/common/src/tabs/TabsContext.ts b/packages/common/src/tabs/TabsContext.ts
index 766c51b4a0..ae6e0e261a 100644
--- a/packages/common/src/tabs/TabsContext.ts
+++ b/packages/common/src/tabs/TabsContext.ts
@@ -1,13 +1,19 @@
import { createContext, useContext } from 'react';
-import { type TabsApi } from './useTabs';
+import { type TabsApi, type TabValue } from './useTabs';
-export type TabsContextValue = TabsApi;
+export type TabsContextValue<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = TabsApi;
export const TabsContext = createContext(undefined);
-export const useTabsContext = (): TabsContextValue => {
- const context = useContext(TabsContext) as TabsContextValue | undefined;
+export const useTabsContext = <
+ TabId extends string,
+ TTab extends TabValue = TabValue,
+>(): TabsContextValue => {
+ const context = useContext(TabsContext) as TabsContextValue | undefined;
if (!context) throw Error('useTabsContext must be used within a TabsContext.Provider');
return context;
};
diff --git a/packages/common/src/tabs/useTabs.ts b/packages/common/src/tabs/useTabs.ts
index 2b0543c61f..c5f4ce11b4 100644
--- a/packages/common/src/tabs/useTabs.ts
+++ b/packages/common/src/tabs/useTabs.ts
@@ -9,18 +9,24 @@ export type TabValue = {
disabled?: boolean;
};
-export type TabsOptions = {
+export type TabsOptions<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = {
/** The array of tabs data. */
- tabs: TabValue[];
+ tabs: TTab[];
/** React state for the currently active tab. Setting it to `null` results in no active tab. */
- activeTab: TabValue | null;
+ activeTab: TTab | null;
/** Callback that is fired when the active tab changes. Use this callback to update the `activeTab` state. */
- onChange: (activeTab: TabValue | null) => void;
+ onChange: (activeTab: TTab | null) => void;
/** Disable interactions on all the tabs. */
disabled?: boolean;
};
-export type TabsApi = Omit, 'onChange'> & {
+export type TabsApi<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = Omit, 'onChange'> & {
/** Update the currently active tab to the tab with `tabId`. */
updateActiveTab: (tabId: TabId | null) => void;
/** Update the currently active tab to the next enabled tab in the tabs array. Does nothing if the last tab is already active. */
@@ -30,15 +36,15 @@ export type TabsApi = Omit, 'o
};
/** A controlled hook for managing tabs state, such as the currently active tab. */
-export const useTabs = ({
+export const useTabs = = TabValue>({
tabs,
activeTab,
disabled,
onChange,
-}: TabsOptions): TabsApi => {
+}: TabsOptions): TabsApi => {
const updateActiveTab = useCallback(
(tabId: TabId | null) => {
- let newActiveTab: TabValue | null = null;
+ let newActiveTab: TTab | null = null;
if (typeof tabId === 'string' && tabId !== '') {
newActiveTab = tabs.find((tab) => tab.id === tabId) ?? tabs[0];
}
diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md
index 86fc9e0538..26f599eb75 100644
--- a/packages/mcp-server/CHANGELOG.md
+++ b/packages/mcp-server/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.
+## 8.64.0 ((4/2/2026, 07:51 AM PST))
+
+This is an artificial version bump with no new change.
+
## 8.63.0 ((4/1/2026, 03:43 PM PST))
This is an artificial version bump with no new change.
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index 7bf248eb69..d63fb6ac71 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
- "version": "8.63.0",
+ "version": "8.64.0",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md
index d1b3c06ab2..a24da1c14e 100644
--- a/packages/mobile/CHANGELOG.md
+++ b/packages/mobile/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
+## 8.64.0 (4/2/2026 PST)
+
+#### 🚀 Updates
+
+- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)]
+
## 8.63.0 ((4/1/2026, 03:43 PM PST))
This is an artificial version bump with no new change.
diff --git a/packages/mobile/package.json b/packages/mobile/package.json
index 2c7f91e888..e4340a638e 100644
--- a/packages/mobile/package.json
+++ b/packages/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
- "version": "8.63.0",
+ "version": "8.64.0",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
diff --git a/packages/mobile/src/tabs/DefaultTab.tsx b/packages/mobile/src/tabs/DefaultTab.tsx
new file mode 100644
index 0000000000..82173dc3ca
--- /dev/null
+++ b/packages/mobile/src/tabs/DefaultTab.tsx
@@ -0,0 +1,111 @@
+import React, { forwardRef, memo, useCallback, useMemo } from 'react';
+import {
+ type GestureResponderEvent,
+ Pressable,
+ type PressableProps,
+ type StyleProp,
+ type View,
+ type ViewStyle,
+} from 'react-native';
+import type { SharedAccessibilityProps } from '@coinbase/cds-common';
+import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
+import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable';
+
+import { DotCount, type DotCountBaseProps } from '../dots/DotCount';
+import { useTheme } from '../hooks/useTheme';
+import { HStack } from '../layout';
+import { Text } from '../typography/Text';
+
+import type { TabComponentProps } from './Tabs';
+
+/** Optional dot count and a11y overrides for the default tab row. */
+export type DefaultTabLabelProps = Partial> &
+ Pick;
+
+export type DefaultTabProps = Omit<
+ PressableProps,
+ 'children' | 'onPress' | 'style'
+> &
+ TabComponentProps & DefaultTabLabelProps> & {
+ /** Callback that is fired when the tab is pressed, after the active tab updates. */
+ onPress?: (id: TabId, event: GestureResponderEvent) => void;
+ style?: StyleProp;
+ };
+
+type DefaultTabComponent = (
+ props: DefaultTabProps & { ref?: React.ForwardedRef },
+) => React.ReactElement;
+
+const DefaultTabComponent = memo(
+ forwardRef(
+ (
+ {
+ id,
+ label,
+ disabled: disabledProp,
+ onPress,
+ count,
+ max,
+ accessibilityLabel,
+ style,
+ testID,
+ ...props
+ }: DefaultTabProps,
+ ref: React.ForwardedRef,
+ ) => {
+ const theme = useTheme();
+ const {
+ activeTab,
+ updateActiveTab,
+ disabled: allTabsDisabled,
+ } = useTabsContext & DefaultTabLabelProps>();
+ const isActive = activeTab?.id === id;
+ const isDisabled = disabledProp || allTabsDisabled;
+
+ const handlePress = useCallback(
+ (event: GestureResponderEvent) => {
+ updateActiveTab(id);
+ onPress?.(id, event);
+ },
+ [id, onPress, updateActiveTab],
+ );
+
+ const labelPaddingStyle = useMemo(
+ () => ({
+ paddingTop: theme.space[2],
+ paddingBottom: theme.space[2] - 2,
+ }),
+ [theme.space],
+ );
+
+ return (
+
+
+
+ {label}
+
+ {!!count && }
+
+
+ );
+ },
+ ),
+);
+
+DefaultTabComponent.displayName = 'DefaultTab';
+
+export const DefaultTab = DefaultTabComponent as DefaultTabComponent;
diff --git a/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx
new file mode 100644
index 0000000000..50cbd3e266
--- /dev/null
+++ b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx
@@ -0,0 +1,58 @@
+import { memo, useEffect } from 'react';
+import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
+
+import { Box } from '../layout';
+
+import { type TabsActiveIndicatorProps, tabsSpringConfig } from './Tabs';
+
+/**
+ * Default underline-style indicator for mobile `Tabs`. Pass as
+ * `TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}` with `TabComponent={DefaultTab}`.
+ */
+const AnimatedBox = Animated.createAnimatedComponent(Box);
+
+export const DefaultTabsActiveIndicator = memo(
+ ({
+ activeTabRect,
+ background = 'bgPrimary',
+ style,
+ testID,
+ ...props
+ }: TabsActiveIndicatorProps) => {
+ const { width, x } = activeTabRect;
+ const rect = useSharedValue({ width, x });
+
+ useEffect(() => {
+ if (!width) return;
+ rect.value = withSpring({ x, width }, tabsSpringConfig);
+ }, [rect, width, x]);
+
+ const animatedBoxStyle = useAnimatedStyle(
+ () => ({
+ transform: [{ translateX: rect.value.x }],
+ width: rect.value.width,
+ }),
+ [],
+ );
+
+ if (!width) return null;
+
+ return (
+
+ );
+ },
+);
+
+DefaultTabsActiveIndicator.displayName = 'DefaultTabsActiveIndicator';
diff --git a/packages/mobile/src/tabs/TabIndicator.tsx b/packages/mobile/src/tabs/TabIndicator.tsx
index c21aa1c0fa..8ea545eec7 100644
--- a/packages/mobile/src/tabs/TabIndicator.tsx
+++ b/packages/mobile/src/tabs/TabIndicator.tsx
@@ -19,6 +19,8 @@ export type TabIndicatorProps = SharedProps & {
background?: ThemeVars.Color;
};
+/** @deprecated Use DefaultTabsActiveIndicator instead. This will be removed in a future major release. */
+/** @deprecationExpectedRemoval v10 */
export const TabIndicator = memo(
forwardRef(
(
diff --git a/packages/mobile/src/tabs/TabLabel.tsx b/packages/mobile/src/tabs/TabLabel.tsx
index 73475aa465..923b7fe2c4 100644
--- a/packages/mobile/src/tabs/TabLabel.tsx
+++ b/packages/mobile/src/tabs/TabLabel.tsx
@@ -34,6 +34,8 @@ export type TabLabelBaseProps = SharedProps &
export type TabLabelProps = TabLabelBaseProps & TextProps;
+/** @deprecated Use DefaultTab instead. This will be removed in a future major release. */
+/** @deprecationExpectedRemoval v10 */
export const TabLabel = memo(
({ active, variant = 'primary', count = 0, max, ...props }: TabLabelProps) => {
const theme = useTheme();
diff --git a/packages/mobile/src/tabs/TabNavigation.tsx b/packages/mobile/src/tabs/TabNavigation.tsx
index 0cb14c837a..5c680058cf 100644
--- a/packages/mobile/src/tabs/TabNavigation.tsx
+++ b/packages/mobile/src/tabs/TabNavigation.tsx
@@ -15,6 +15,10 @@ import { Pressable } from '../system/Pressable';
import { TabIndicator } from './TabIndicator';
import { TabLabel } from './TabLabel';
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabProps = SharedProps &
Partial> & {
/** The id should be a meaningful and useful identifier like "watchlist" or "forSale" */
@@ -35,6 +39,10 @@ export type TabProps = SharedProps &
Component?: (props: CustomTabProps) => React.ReactNode;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type CustomTabProps = Pick & {
/**
* @default false
@@ -45,6 +53,10 @@ export type CustomTabProps = Pick & {
label?: React.ReactNode;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabNavigationBaseProps = BoxBaseProps &
Pick &
Pick & {
@@ -88,6 +100,10 @@ export type TabNavigationBaseProps =
id?: string;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabNavigationProps =
TabNavigationBaseProps;
diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx
index 1dd1a42c6a..2e632e4c49 100644
--- a/packages/mobile/src/tabs/Tabs.tsx
+++ b/packages/mobile/src/tabs/Tabs.tsx
@@ -22,6 +22,9 @@ import { useComponentConfig } from '../hooks/useComponentConfig';
import type { BoxBaseProps, BoxProps, HStackProps } from '../layout';
import { Box, HStack } from '../layout';
+import { DefaultTab } from './DefaultTab';
+import { DefaultTabsActiveIndicator } from './DefaultTabsActiveIndicator';
+
const AnimatedBox = Animated.createAnimatedComponent(Box);
type TabContainerProps = {
@@ -49,31 +52,44 @@ export type TabsActiveIndicatorProps = {
activeTabRect: Rect;
} & BoxProps;
-export type TabComponentProps = TabValue & {
+export type TabComponentProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = Omit & {
+ id: TabId;
style?: StyleProp;
};
-export type TabComponent = React.FC>;
+export type TabComponent<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = React.FC>;
export type TabsActiveIndicatorComponent = React.FC;
-export type TabsBaseProps = Omit &
- Omit, 'tabs'> & {
+export type TabsBaseProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = Omit &
+ Omit, 'tabs'> & {
/** The array of tabs data. Each tab may optionally define a custom Component to render. */
- tabs: (TabValue & { Component?: TabComponent })[];
+ tabs: (TTab & { Component?: TabComponent })[];
/** The default Component to render each tab. */
- TabComponent: TabComponent;
+ TabComponent?: TabComponent;
/** The default Component to render the tabs active indicator. */
- TabsActiveIndicatorComponent: TabsActiveIndicatorComponent;
+ TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent;
/** Background color passed to the TabsActiveIndicatorComponent. */
activeBackground?: ThemeVars.Color;
/** Optional callback to receive the active tab element. */
onActiveTabElementChange?: (element: View | null) => void;
- /** Custom styles for individual elements of the Tabs component */
};
-export type TabsProps = TabsBaseProps &
+export type TabsProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = TabsBaseProps &
Omit & {
+ /** Custom styles for individual elements of the Tabs component */
styles?: {
/** Root container element */
root?: StyleProp;
@@ -84,106 +100,114 @@ export type TabsProps = TabsBaseProps &
};
};
-type TabsFC = (
- props: TabsProps & { ref?: React.ForwardedRef },
+type TabsFC = = TabValue>(
+ props: TabsProps & { ref?: React.ForwardedRef },
) => React.ReactElement;
const TabsComponent = memo(
- forwardRef((_props: TabsProps, ref: React.ForwardedRef) => {
- const mergedProps = useComponentConfig('Tabs', _props);
- const {
- tabs,
- TabComponent,
- TabsActiveIndicatorComponent,
- activeBackground,
- activeTab,
- disabled,
- onChange,
- styles,
- style,
- role = 'tablist',
- position = 'relative',
- alignSelf = 'flex-start',
- opacity,
- onActiveTabElementChange,
- borderRadius,
- borderTopLeftRadius,
- borderTopRightRadius,
- borderBottomLeftRadius,
- borderBottomRightRadius,
- ...props
- } = mergedProps;
- const tabsContainerRef = useRef(null);
- useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref
-
- const refMap = useRefMap();
- const api = useTabs({ tabs, activeTab, disabled, onChange });
-
- const [activeTabRect, setActiveTabRect] = useState(defaultRect);
- const previousActiveRef = useRef(activeTab);
-
- const updateActiveTabRect = useCallback(() => {
- const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null;
- if (!activeTabRef || !tabsContainerRef.current) return;
- activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) =>
- setActiveTabRect({ x, y, width, height }),
+ forwardRef(
+ = TabValue>(
+ _props: TabsProps,
+ ref: React.ForwardedRef,
+ ) => {
+ const mergedProps = useComponentConfig('Tabs', _props);
+ const {
+ tabs,
+ TabComponent = DefaultTab,
+ TabsActiveIndicatorComponent = DefaultTabsActiveIndicator,
+ activeBackground,
+ activeTab,
+ disabled,
+ onChange,
+ styles,
+ style,
+ role = 'tablist',
+ position = 'relative',
+ alignSelf = 'flex-start',
+ opacity,
+ onActiveTabElementChange,
+ borderRadius,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ testID,
+ ...props
+ } = mergedProps;
+ const tabsContainerRef = useRef(null);
+ useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref
+
+ const refMap = useRefMap();
+ const api = useTabs({ tabs, activeTab, disabled, onChange });
+
+ const [activeTabRect, setActiveTabRect] = useState(defaultRect);
+ const previousActiveRef = useRef(activeTab);
+
+ const updateActiveTabRect = useCallback(() => {
+ const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null;
+ if (!activeTabRef || !tabsContainerRef.current) return;
+ activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) =>
+ setActiveTabRect({ x, y, width, height }),
+ );
+ }, [activeTab, refMap]);
+
+ const registerRef = useCallback(
+ (tabId: string, ref: View) => {
+ refMap.registerRef(tabId, ref);
+ if (activeTab?.id === tabId) {
+ onActiveTabElementChange?.(ref);
+ }
+ },
+ [activeTab, onActiveTabElementChange, refMap],
+ );
+
+ if (previousActiveRef.current !== activeTab) {
+ previousActiveRef.current = activeTab;
+ updateActiveTabRect();
+ }
+
+ return (
+
+ }>
+
+ {tabs.map(({ id, Component: CustomTabComponent, ...props }) => {
+ const RenderedTab = CustomTabComponent ?? TabComponent;
+ return (
+
+
+
+ );
+ })}
+
+
);
- }, [activeTab, refMap]);
-
- const registerRef = useCallback(
- (tabId: string, ref: View) => {
- refMap.registerRef(tabId, ref);
- if (activeTab?.id === tabId) {
- onActiveTabElementChange?.(ref);
- }
- },
- [activeTab, onActiveTabElementChange, refMap],
- );
-
- if (previousActiveRef.current !== activeTab) {
- previousActiveRef.current = activeTab;
- updateActiveTabRect();
- }
-
- return (
-
- }>
-
- {tabs.map(({ id, Component: CustomTabComponent, disabled: tabDisabled, ...props }) => {
- const RenderedTab = CustomTabComponent ?? TabComponent;
- return (
-
-
-
- );
- })}
-
-
- );
- }),
+ },
+ ),
);
TabsComponent.displayName = 'Tabs';
@@ -194,6 +218,7 @@ export const TabsActiveIndicator = ({
activeTabRect,
position = 'absolute',
style,
+ testID = 'tabs-active-indicator',
...props
}: TabsActiveIndicatorProps) => {
const previousActiveTabRect = useRef(activeTabRect);
@@ -224,7 +249,7 @@ export const TabsActiveIndicator = ({
position={position}
role="none"
style={[animatedBoxStyle, style]}
- testID="tabs-active-indicator"
+ testID={testID}
{...props}
/>
);
diff --git a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx
index 59925fd5d7..bc3d0fa7a6 100644
--- a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx
+++ b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx
@@ -1,67 +1,187 @@
-import React, { useState } from 'react';
+import { useCallback, useState } from 'react';
+import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs';
+import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
import { gutter } from '@coinbase/cds-common/tokens/sizing';
+import { zIndex } from '@coinbase/cds-common/tokens/zIndex';
import { Example, ExampleScreen } from '../../examples/ExampleScreen';
-import { VStack } from '../../layout/VStack';
+import { VStack } from '../../layout';
+import { ThemeProvider } from '../../system/ThemeProvider';
+import { defaultTheme } from '../../themes/defaultTheme';
import { Text } from '../../typography/Text';
-import { TabNavigation, type TabNavigationProps, type TabProps } from '../TabNavigation';
-
-const tabs: TabProps[] = [
- {
- id: 'first_item',
- label: 'First item',
- onPress: console.warn,
- },
- {
- id: 'second_item',
- label: 'Second item',
- },
- {
- id: 'third_item',
- label: 'Third item',
- onPress: console.warn,
- },
- {
- id: 'fourth_item',
- label: 'Fourth item',
- },
- {
- id: 'fifth_item',
- label: 'Fifth item',
- },
+import { DefaultTab, type DefaultTabLabelProps } from '../DefaultTab';
+import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator';
+import {
+ type TabComponent,
+ Tabs,
+ TabsActiveIndicator,
+ type TabsActiveIndicatorComponent,
+ type TabsActiveIndicatorProps,
+ type TabsProps,
+} from '../Tabs';
+
+type TradingAction = 'buy' | 'sell' | 'convert';
+
+type TabRowWithTestId = TabValue & { testID?: string };
+
+const basicTabs: TabRowWithTestId[] = [
+ { id: 'buy', label: 'Buy', testID: 'buy-tab' },
+ { id: 'sell', label: 'Sell', testID: 'sell-tab' },
+ { id: 'convert', label: 'Convert', testID: 'convert-tab' },
];
-// TODO update once _Tabs_ component is complete
-const TabScreen = () => {
- const [activeTabOne, setActiveTabOne] = useState(tabs[0].id);
+const longTabs = sampleTabs.slice(0, 9);
+
+const tabsWithDisabled = [
+ { id: 'buy', label: 'Buy' },
+ { id: 'sell', label: 'Sell', disabled: true },
+ { id: 'convert', label: 'Convert' },
+];
+
+const typedTabs: TabValue[] = [
+ { id: 'buy', label: 'Buy' },
+ { id: 'sell', label: 'Sell' },
+ { id: 'convert', label: 'Convert' },
+];
+
+type TradingTab = TabValue & DefaultTabLabelProps;
+const tabsWithDotCounts: TradingTab[] = basicTabs.map((tab, index) =>
+ index === 0 ? { ...tab, count: 3, max: 99 } : tab,
+);
+
+const CustomSpringIndicator = (props: TabsActiveIndicatorProps) => (
+
+);
+
+type TabsExampleProps = TabValue> = {
+ title: string;
+ defaultActiveTab: TTab | null;
+ TabComponent?: TabComponent;
+ TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent;
+} & Omit<
+ TabsProps,
+ 'activeTab' | 'onChange' | 'TabComponent' | 'TabsActiveIndicatorComponent'
+>;
+
+const TabsExample = = TabValue>({
+ title,
+ defaultActiveTab,
+ TabComponent = DefaultTab,
+ TabsActiveIndicatorComponent = DefaultTabsActiveIndicator,
+ ...props
+}: TabsExampleProps) => {
+ const [activeTab, setActiveTab] = useState(defaultActiveTab);
+ const handleChange = useCallback((next: TTab | null) => setActiveTab(next), []);
return (
-
-
-
-
- Static preview
-
- {activeTabOne}
-
-
-
-
-
+
+
+ );
+};
+
+const panelTabs = sampleTabs.slice(0, 3);
+
+const TabsWithPanelsExample = () => {
+ const [activeTab, setActiveTab] = useState | null>(panelTabs[0]);
+
+ return (
+
+
+
+ Pair tab buttons with content regions that follow the active tab (see panel below).
+
+
-
- Static preview
-
- {activeTabOne}
-
-
-
-
+ {panelTabs.map((tab) =>
+ activeTab?.id === tab.id ? (
+
+ Panel: {tab.label}
+ Content for this tab.
+
+ ) : null,
+ )}
+
+
);
};
-export default TabScreen;
+const DefaultTabsScreen = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default DefaultTabsScreen;
diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
index e53dfd049e..5b461814c5 100644
--- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
+++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
@@ -89,7 +89,7 @@ describe('SegmentedTabs', () => {
});
jest.advanceTimersByTime(300);
- expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({
+ expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({
width: 68,
height: 40,
transform: [{ translateX: 0 }, { translateY: 0 }],
@@ -129,7 +129,7 @@ describe('SegmentedTabs', () => {
jest.advanceTimersByTime(300);
- expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({
+ expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({
width: 68,
height: 40,
transform: [{ translateX: 68 }, { translateY: 0 }],
@@ -208,7 +208,7 @@ describe('SegmentedTabs', () => {
jest.advanceTimersByTime(300);
- expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({
+ expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({
width: 68,
height: 40,
transform: [{ translateX: 20 }, { translateY: 0 }],
@@ -242,7 +242,7 @@ describe('SegmentedTabs', () => {
jest.advanceTimersByTime(300);
- expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({
+ expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({
width: 68,
height: 40,
transform: [{ translateX: 0 }, { translateY: 8 }],
@@ -276,7 +276,7 @@ describe('SegmentedTabs', () => {
jest.advanceTimersByTime(300);
- expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({
+ expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({
width: 68,
height: 40,
transform: [{ translateX: 20 }, { translateY: 8 }],
diff --git a/packages/mobile/src/tabs/index.ts b/packages/mobile/src/tabs/index.ts
index 498c1d29a4..4d05fca873 100644
--- a/packages/mobile/src/tabs/index.ts
+++ b/packages/mobile/src/tabs/index.ts
@@ -1,3 +1,5 @@
+export * from './DefaultTab';
+export * from './DefaultTabsActiveIndicator';
export * from './SegmentedTabs';
export * from './TabIndicator';
export * from './TabLabel';
diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md
index 3d6a49b413..3c394c4bf0 100644
--- a/packages/web/CHANGELOG.md
+++ b/packages/web/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
+## 8.64.0 (4/2/2026 PST)
+
+#### 🚀 Updates
+
+- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)]
+
## 8.63.0 (4/1/2026 PST)
#### 🚀 Updates
diff --git a/packages/web/package.json b/packages/web/package.json
index 3e2fa92e12..c9dd2da71b 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
- "version": "8.63.0",
+ "version": "8.64.0",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
diff --git a/packages/web/src/tabs/DefaultTab.tsx b/packages/web/src/tabs/DefaultTab.tsx
new file mode 100644
index 0000000000..0fe0a6c30e
--- /dev/null
+++ b/packages/web/src/tabs/DefaultTab.tsx
@@ -0,0 +1,125 @@
+import React, { forwardRef, memo, useCallback } from 'react';
+import type { SharedAccessibilityProps } from '@coinbase/cds-common';
+import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
+import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { css } from '@linaria/core';
+
+import { cx } from '../cx';
+import { DotCount, type DotCountBaseProps } from '../dots/DotCount';
+import { HStack } from '../layout';
+import { Pressable, type PressableBaseProps } from '../system/Pressable';
+import { Text } from '../typography/Text';
+
+import type { TabComponentProps } from './Tabs';
+
+/** Optional dot count and a11y overrides for the default tab row. */
+export type DefaultTabLabelProps = Partial> &
+ Pick;
+
+const pressableCss = css`
+ margin: 0;
+ padding: 0;
+ white-space: nowrap;
+`;
+
+const insetFocusRingCss = css`
+ position: relative;
+ &:focus {
+ outline: none;
+ }
+ &:focus-visible {
+ outline-style: solid;
+ outline-width: 2px;
+ outline-color: var(--color-bgPrimary);
+ outline-offset: -3px;
+ border-radius: 4px;
+ }
+`;
+
+const labelPaddingCss = css`
+ padding-top: var(--space-2);
+ padding-bottom: calc(var(--space-2) - 2px); /* Account for the 2px tab indicator */
+`;
+
+export type DefaultTabProps = Omit &
+ TabComponentProps & DefaultTabLabelProps> & {
+ /** Callback that is fired when the tab is pressed, after the active tab updates. */
+ onClick?: (id: TabId) => void;
+ };
+
+type DefaultTabComponent = (
+ props: DefaultTabProps & { ref?: React.ForwardedRef },
+) => React.ReactElement;
+
+const DefaultTabComponent = memo(
+ forwardRef(
+ (
+ {
+ id,
+ label,
+ disabled: disabledProp,
+ onClick,
+ count,
+ max,
+ accessibilityLabel,
+ className,
+ ...props
+ }: DefaultTabProps,
+ ref: React.ForwardedRef,
+ ) => {
+ const {
+ activeTab,
+ updateActiveTab,
+ disabled: allTabsDisabled,
+ } = useTabsContext & DefaultTabLabelProps>();
+ const isActive = activeTab?.id === id;
+ const isDisabled = disabledProp || allTabsDisabled;
+
+ const handlePress = useCallback(() => {
+ updateActiveTab(id);
+ onClick?.(id);
+ }, [id, onClick, updateActiveTab]);
+
+ return (
+
+
+
+ {label}
+
+ {!!count && (
+
+ )}
+
+
+ );
+ },
+ ),
+);
+
+DefaultTabComponent.displayName = 'DefaultTab';
+
+export const DefaultTab = DefaultTabComponent as DefaultTabComponent;
diff --git a/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx b/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx
new file mode 100644
index 0000000000..df74a326a9
--- /dev/null
+++ b/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx
@@ -0,0 +1,47 @@
+import { memo } from 'react';
+import { m as motion } from 'framer-motion';
+
+import { Box } from '../layout/Box';
+
+import { type TabsActiveIndicatorProps, tabsTransitionConfig } from './Tabs';
+
+const MotionBox = motion(Box);
+
+/**
+ * Default underline-style indicator for `Tabs`. Pass as
+ * `TabsActiveIndicatorComponent={DefaultTabIndicator}` with `TabComponent={DefaultTab}`.
+ */
+export const DefaultTabsActiveIndicator = memo(
+ ({
+ activeTabRect,
+ background = 'bgPrimary',
+ className,
+ style,
+ testID,
+ ...props
+ }: TabsActiveIndicatorProps) => {
+ const { width, x } = activeTabRect;
+
+ if (!width) return null;
+
+ return (
+
+ );
+ },
+);
+
+DefaultTabsActiveIndicator.displayName = 'DefaultTabsActiveIndicator';
diff --git a/packages/web/src/tabs/TabIndicator.tsx b/packages/web/src/tabs/TabIndicator.tsx
index a02ac66b36..350dd6c7be 100644
--- a/packages/web/src/tabs/TabIndicator.tsx
+++ b/packages/web/src/tabs/TabIndicator.tsx
@@ -20,6 +20,8 @@ export type TabIndicatorProps = SharedProps & {
background?: ThemeVars.Color;
};
+/** @deprecated Use DefaultTabsActiveIndicator instead. This will be removed in a future major release. */
+/** @deprecationExpectedRemoval v10 */
export const TabIndicator = memo(
forwardRef(
(
diff --git a/packages/web/src/tabs/TabLabel.tsx b/packages/web/src/tabs/TabLabel.tsx
index 97de8c742e..1e68a667a5 100644
--- a/packages/web/src/tabs/TabLabel.tsx
+++ b/packages/web/src/tabs/TabLabel.tsx
@@ -58,6 +58,9 @@ export type TabLabelBaseProps = SharedProps &
export type TabLabelProps = TabLabelBaseProps &
TextProps<'h2'> & { onLayout?: (key: string, props: TabIndicatorProps) => void };
+/** @deprecated Use DefaultTab instead. This will be removed in a future major release. */
+/** @deprecationExpectedRemoval v10 */
+
export const TabLabel = memo(
({
id = '',
diff --git a/packages/web/src/tabs/TabNavigation.tsx b/packages/web/src/tabs/TabNavigation.tsx
index e94f463718..fb17752bc8 100644
--- a/packages/web/src/tabs/TabNavigation.tsx
+++ b/packages/web/src/tabs/TabNavigation.tsx
@@ -75,6 +75,10 @@ const insetFocusRingCss = css`
}
`;
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabProps = SharedProps &
Partial> & {
/** The id should be a meaningful and useful identifier like "watchlist" or "forSale" */
@@ -95,6 +99,10 @@ export type TabProps = SharedProps &
Component?: (props: CustomTabProps) => React.ReactNode;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type CustomTabProps = Pick & {
/**
* @default false
@@ -105,6 +113,10 @@ export type CustomTabProps = Pick & {
label?: React.ReactNode;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabNavigationBaseProps = SharedProps &
BoxBaseProps &
Pick, 'variant' | 'Component'> & {
@@ -146,6 +158,10 @@ export type TabNavigationBaseProps =
paddleStyle?: React.CSSProperties;
};
+/**
+ * @deprecated Use Tabs instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v10
+ */
export type TabNavigationProps =
TabNavigationBaseProps;
diff --git a/packages/web/src/tabs/Tabs.tsx b/packages/web/src/tabs/Tabs.tsx
index 027c7cbfef..e3dede0784 100644
--- a/packages/web/src/tabs/Tabs.tsx
+++ b/packages/web/src/tabs/Tabs.tsx
@@ -19,6 +19,9 @@ import { useComponentConfig } from '../hooks/useComponentConfig';
import { Box, type BoxBaseProps, type BoxDefaultElement, type BoxProps } from '../layout/Box';
import { HStack, type HStackDefaultElement, type HStackProps } from '../layout/HStack';
+import { DefaultTab } from './DefaultTab';
+import { DefaultTabsActiveIndicator } from './DefaultTabsActiveIndicator';
+
const MotionBox = motion>(Box);
type TabContainerProps = {
@@ -48,7 +51,10 @@ export type TabsActiveIndicatorProps = {
} & BoxProps &
MotionProps;
-export type TabComponentProps = TabValue & {
+export type TabComponentProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = Omit & {
/** The tab index for the tab. Automatically set to manage focus behavior. */
tabIndex?: number;
/**
@@ -58,27 +64,37 @@ export type TabComponentProps = TabValue &
role?: string;
className?: string;
style?: React.CSSProperties;
+ 'data-rendered-tab'?: boolean;
};
-export type TabComponent = React.FC>;
+export type TabComponent<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = React.FC>;
export type TabsActiveIndicatorComponent = React.FC;
-export type TabsBaseProps = Omit &
- Omit, 'tabs'> & {
+export type TabsBaseProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = Omit &
+ Omit, 'tabs'> & {
/** The array of tabs data. Each tab may optionally define a custom Component to render. */
- tabs: (TabValue & { Component?: TabComponent })[];
+ tabs: (TTab & { Component?: TabComponent })[];
/** The default Component to render each tab. */
- TabComponent: TabComponent;
+ TabComponent?: TabComponent;
/** The default Component to render the tabs active indicator. */
- TabsActiveIndicatorComponent: TabsActiveIndicatorComponent;
+ TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent;
/** Background color passed to the TabsActiveIndicatorComponent. */
activeBackground?: ThemeVars.Color;
/** Optional callback to receive the active tab element. */
onActiveTabElementChange?: (element: HTMLElement | null) => void;
};
-export type TabsProps = TabsBaseProps &
+export type TabsProps<
+ TabId extends string = string,
+ TTab extends TabValue = TabValue,
+> = TabsBaseProps &
Omit, 'onChange' | 'ref'> & {
/** Custom styles for individual elements of the Tabs component */
styles?: {
@@ -100,18 +116,21 @@ export type TabsProps = TabsBaseProps &
};
};
-type TabsFC = (
- props: TabsProps & { ref?: React.ForwardedRef },
+type TabsFC = = TabValue>(
+ props: TabsProps & { ref?: React.ForwardedRef },
) => React.ReactElement;
const TabsComponent = memo(
forwardRef(
- (_props: TabsProps, ref: React.ForwardedRef) => {
+ = TabValue>(
+ _props: TabsProps,
+ ref: React.ForwardedRef,
+ ) => {
const mergedProps = useComponentConfig('Tabs', _props);
const {
tabs,
- TabComponent,
- TabsActiveIndicatorComponent,
+ TabComponent = DefaultTab,
+ TabsActiveIndicatorComponent = DefaultTabsActiveIndicator,
activeBackground,
activeTab,
onActiveTabElementChange,
@@ -129,9 +148,10 @@ const TabsComponent = memo(
borderBottomLeftRadius,
borderBottomRightRadius,
style,
+ testID,
...props
} = mergedProps;
- const api = useTabs({ tabs, activeTab, disabled, onChange });
+ const api = useTabs({ tabs, activeTab, disabled, onChange });
const [tabsContainerRef, tabsContainerRect] = useMeasure({
debounce: 20,
@@ -206,10 +226,7 @@ const TabsComponent = memo(
[tabs, refMap],
);
- const containerStyle = useMemo(
- () => ({ opacity: disabled ? accessibleOpacityDisabled : 1, ...style, ...styles?.root }),
- [disabled, style, styles?.root],
- );
+ const containerStyle = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]);
const registerRef = useCallback(
(tabId: string, ref: HTMLElement) => {
@@ -231,13 +248,15 @@ const TabsComponent = memo(
borderTopRightRadius={borderTopRightRadius}
className={cx(className, classNames?.root)}
onKeyDown={handleTabsContainerKeyDown}
+ opacity={disabled ? accessibleOpacityDisabled : 1}
position={position}
role={role}
style={containerStyle}
+ testID={testID}
width={width}
{...props}
>
- }>
+ }>
- {tabs.map(({ id, Component: CustomTabComponent, disabled: tabDisabled, ...props }) => {
- const RenderedTab = CustomTabComponent ?? TabComponent;
+ {tabs.map((props) => {
+ const RenderedTab = props.Component ?? TabComponent;
+ const renderedTabProps = {
+ ...props,
+ 'data-rendered-tab': true,
+ className: classNames?.tab,
+ role: 'tab',
+ style: styles?.tab,
+ tabIndex: activeTab?.id === props.id || !activeTab ? 0 : -1,
+ };
return (
-
-
+
+
);
})}
@@ -280,6 +299,7 @@ export const Tabs = TabsComponent as TabsFC;
export const TabsActiveIndicator = ({
activeTabRect,
position = 'absolute',
+ testID = 'tabs-active-indicator',
...props
}: TabsActiveIndicatorProps) => {
const { width, height, x } = activeTabRect;
@@ -288,12 +308,12 @@ export const TabsActiveIndicator = ({
return (
diff --git a/packages/web/src/tabs/__stories__/Tabs.stories.tsx b/packages/web/src/tabs/__stories__/Tabs.stories.tsx
new file mode 100644
index 0000000000..3bf5af8288
--- /dev/null
+++ b/packages/web/src/tabs/__stories__/Tabs.stories.tsx
@@ -0,0 +1,228 @@
+import { useCallback, useState } from 'react';
+import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs';
+import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { zIndex } from '@coinbase/cds-common/tokens/zIndex';
+
+import { Box, VStack } from '../../layout';
+import { ThemeProvider } from '../../system/ThemeProvider';
+import { defaultTheme } from '../../themes/defaultTheme';
+import { Text } from '../../typography/Text';
+import { DefaultTab, type DefaultTabLabelProps } from '../DefaultTab';
+import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator';
+import {
+ type TabComponent,
+ Tabs,
+ TabsActiveIndicator,
+ type TabsActiveIndicatorComponent,
+ type TabsActiveIndicatorProps,
+ type TabsProps,
+} from '../Tabs';
+
+import { MockTabPanel } from './MockTabPanel';
+
+export default {
+ title: 'Components/Tabs/Tabs',
+ parameters: {
+ a11y: {
+ context: {
+ include: ['body'],
+ exclude: ['.no-a11y-checks'],
+ },
+ },
+ },
+};
+
+type TradingAction = 'buy' | 'sell' | 'convert';
+
+type TabRowWithTestId = TabValue & { testID?: string };
+
+const basicTabs: TabRowWithTestId[] = [
+ { id: 'buy', label: 'Buy', testID: 'buy-tab' },
+ { id: 'sell', label: 'Sell', testID: 'sell-tab' },
+ { id: 'convert', label: 'Convert', testID: 'convert-tab' },
+];
+
+const longTabs = sampleTabs.slice(0, 9);
+
+const tabsWithDisabled = [
+ { id: 'buy', label: 'Buy' },
+ { id: 'sell', label: 'Sell', disabled: true },
+ { id: 'convert', label: 'Convert' },
+];
+
+const typedTabs: TabValue[] = [
+ { id: 'buy', label: 'Buy' },
+ { id: 'sell', label: 'Sell' },
+ { id: 'convert', label: 'Convert' },
+];
+
+type TradingTab = TabValue & DefaultTabLabelProps;
+const tabsWithDotCounts: TradingTab[] = basicTabs.map((tab, index) =>
+ index === 0 ? { ...tab, count: 3, max: 99 } : tab,
+);
+
+const CustomSpringIndicator = (props: TabsActiveIndicatorProps) => (
+
+);
+
+type TabsExampleProps = TabValue> = {
+ title: string;
+ defaultActiveTab: TTab | null;
+ TabComponent?: TabComponent;
+ TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent;
+} & Omit<
+ TabsProps,
+ 'activeTab' | 'onChange' | 'TabComponent' | 'TabsActiveIndicatorComponent'
+>;
+
+const TabsExample = = TabValue>({
+ title,
+ defaultActiveTab,
+ TabComponent = DefaultTab,
+ TabsActiveIndicatorComponent = DefaultTabsActiveIndicator,
+ ...props
+}: TabsExampleProps) => {
+ const [activeTab, setActiveTab] = useState(defaultActiveTab);
+ const handleChange = useCallback((next: TTab | null) => setActiveTab(next), []);
+
+ return (
+
+
+ {title}
+
+
+
+ );
+};
+
+export const Default = () => (
+
+
+
+);
+
+export const All = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const tabsTabListOnlyA11y = {
+ a11y: {
+ context: {
+ include: ['body'],
+ exclude: ['.no-a11y-checks'],
+ },
+ options: {
+ rules: {
+ 'aria-valid-attr-value': { enabled: false },
+ 'duplicate-id': { enabled: false },
+ 'duplicate-id-active': { enabled: false },
+ },
+ },
+ },
+};
+
+Default.parameters = tabsTabListOnlyA11y;
+All.parameters = tabsTabListOnlyA11y;
+
+const panelTabs = sampleTabs.slice(0, 3);
+
+export const WithTabPanels = () => {
+ const [activeTab, setActiveTab] = useState | null>(panelTabs[0]);
+
+ return (
+
+
+ Pair tab buttons with role="tabpanel" regions (see
+ MockTabPanel).
+
+
+ {panelTabs.map((tab) => (
+
+
+ Panel: {tab.label}
+
+
+ Content for this tab.
+
+
+ ))}
+
+ );
+};
diff --git a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
index 6622af98e7..92887b83a8 100644
--- a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
+++ b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
@@ -103,7 +103,7 @@ describe('SegmentedTabs', () => {
,
);
- const indicator = screen.getByTestId('tabs-active-indicator');
+ const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`);
const style = indicator.getAttribute('style');
expect(style).toContain('--height: 40px');
expect(style).toContain('width: 68px');
@@ -139,7 +139,7 @@ describe('SegmentedTabs', () => {
);
fireEvent.click(screen.getByTestId('sell-tab'));
- const indicator = screen.getByTestId('tabs-active-indicator');
+ const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`);
const style = indicator.getAttribute('style');
expect(style).toContain('--height: 40px');
expect(style).toContain('width: 68px');
@@ -218,7 +218,7 @@ describe('SegmentedTabs', () => {
,
);
- const indicator = screen.getByTestId('tabs-active-indicator');
+ const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`);
const style = indicator.getAttribute('style');
expect(style).toContain('--height: 40px');
expect(style).toContain('width: 68px');
@@ -241,7 +241,7 @@ describe('SegmentedTabs', () => {
,
);
- expect(screen.queryByTestId('tabs-active-indicator')).not.toBeInTheDocument();
+ expect(screen.queryByTestId(`${TEST_ID}-active-indicator`)).not.toBeInTheDocument();
});
it('positions indicator correctly with horizontal padding', () => {
@@ -272,7 +272,7 @@ describe('SegmentedTabs', () => {
,
);
- const indicator = screen.getByTestId('tabs-active-indicator');
+ const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`);
const style = indicator.getAttribute('style');
expect(style).toContain('transform: translateX(24px) translateZ(0)');
expect(style).toContain('left: 0');
diff --git a/packages/web/src/tabs/index.ts b/packages/web/src/tabs/index.ts
index f351a031bd..31b5f23c91 100644
--- a/packages/web/src/tabs/index.ts
+++ b/packages/web/src/tabs/index.ts
@@ -1,3 +1,5 @@
+export * from './DefaultTab';
+export * from './DefaultTabsActiveIndicator';
export * from './Paddle';
export * from './SegmentedTab';
export * from './SegmentedTabs';