diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js
index e8f8879fef..ff1312da22 100644
--- a/apps/docs/docgen.config.js
+++ b/apps/docs/docgen.config.js
@@ -176,6 +176,7 @@ module.exports = {
'tabs/TabLabel',
'tabs/TabNavigation',
'tabs/Tabs',
+ 'tabs/TabsScrollArea',
'tag/Tag',
'tour/Tour',
'typography/Link',
diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx b/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx
index 5b58593e98..353bd54e30 100644
--- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx
+++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx
@@ -15,7 +15,7 @@ function ExampleDefault() {
### Compact
-```jsx lived
+```jsx live
function ExampleCompactNoStart() {
const tabs = [
{ id: 'all', label: 'All' },
@@ -48,7 +48,7 @@ function ExampleWithPaddles() {
### With autoScrollOffset
:::tip Auto-scroll offset
-The `autoScrollOffset` prop controls the X position offset when auto-scrolling to the active tab. This prevents the active tab from being covered by the paddle on the left side. Try clicking tabs near the edges to see the difference.
+The `autoScrollOffset` prop controls the horizontal offset when auto-scrolling the active tab into view, so it stays clear of the leading overflow control. Try selecting tabs near the edges to see the difference.
:::
```jsx live
@@ -78,14 +78,14 @@ function ExampleAutoScrollOffset() {
}
```
-### With custom sized paddles
+### With custom sized overflow controls
-:::tip Paddle styling
-You can adjust the size of the paddles via `styles.paddle`.
+:::tip Overflow control styling
+Target the chevron buttons with **`styles.overflowIndicatorButton`** (or **`overflowIndicator`** / **`overflowIndicatorGradient`**).
:::
```jsx live
-function ExampleCustomPaddles() {
+function ExampleCustomOverflowControls() {
const tabs = Array.from({ length: 10 }).map((_, i) => ({
id: `t_${i + 1}`,
label: `Item ${i + 1}`,
@@ -96,7 +96,7 @@ function ExampleCustomPaddles() {
activeTab={activeTab}
onChange={setActiveTab}
tabs={tabs}
- styles={{ paddle: { transform: 'scale(0.8)' } }}
+ styles={{ overflowIndicatorButton: { transform: 'scale(0.8)' } }}
/>
);
}
diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json
index 8ccb13f533..d339789b3a 100644
--- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json
+++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json
@@ -22,6 +22,10 @@
"label": "Tabs",
"url": "/components/navigation/Tabs/"
},
+ {
+ "label": "TabsScrollArea",
+ "url": "/components/navigation/TabsScrollArea/"
+ },
{
"label": "SelectChip",
"url": "/components/inputs/SelectChip/"
diff --git a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
index 71db0dade9..a02126e679 100644
--- a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
+++ b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
@@ -200,6 +200,42 @@ function Example() {
}
```
+## Scrolling with TabsScrollArea
+
+When the tab row can overflow horizontally, wrap **`Tabs`** in [TabsScrollArea](/components/navigation/TabsScrollArea/). Pass the render prop’s **`onActiveTabElementChange`** into **`Tabs`**. Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the row overflows and edge gradients appear.
+
+```jsx
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
## 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 3e288890bd..cffe532559 100644
--- a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx
+++ b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx
@@ -206,6 +206,42 @@ function Example() {
}
```
+## Scrolling with TabsScrollArea
+
+When the tab row can overflow horizontally, wrap **`Tabs`** in [TabsScrollArea](/components/navigation/TabsScrollArea/). Pass the render prop’s **`onActiveTabElementChange`** into **`Tabs`** so the active tab scrolls into view. Narrow the viewport or set **`width`** / **`maxWidth`** on **`TabsScrollArea`** to see overflow controls.
+
+```jsx live
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
## 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/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
index c0db513f49..9ba62fcaff 100644
--- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
+++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json
@@ -4,6 +4,10 @@
"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": "TabsScrollArea",
+ "url": "/components/navigation/TabsScrollArea/"
+ },
{
"label": "SegmentedTabs",
"url": "/components/navigation/SegmentedTabs/"
diff --git a/apps/docs/docs/components/navigation/Tabs/webMetadata.json b/apps/docs/docs/components/navigation/Tabs/webMetadata.json
index f5a729f2a7..89ccbeeb0e 100644
--- a/apps/docs/docs/components/navigation/Tabs/webMetadata.json
+++ b/apps/docs/docs/components/navigation/Tabs/webMetadata.json
@@ -4,6 +4,10 @@
"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": "TabsScrollArea",
+ "url": "/components/navigation/TabsScrollArea/"
+ },
{
"label": "SegmentedTabs",
"url": "/components/navigation/SegmentedTabs"
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx
new file mode 100644
index 0000000000..6db6e853a1
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx
@@ -0,0 +1,125 @@
+TabsScrollArea wraps a horizontal [Tabs](/components/navigation/Tabs/) row in a `ScrollView` and shows edge gradients when content overflows. Use a **function child** that receives `onActiveTabElementChange` and pass it through to **`Tabs`** so the active tab can scroll into view.
+
+## Basics
+
+Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the tab row overflows on smaller screens; gradients appear when there is offscreen content.
+
+```jsx
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
+## Overflow indicator
+
+On React Native, the default **`TabsScrollAreaOverflowIndicator`** renders an **`OverflowGradient`** at each edge when there is offscreen content. Scrolling is **gesture-based** (horizontal pan on the **`ScrollView`**); the default gradient is **visual-only** and does not receive press handlers from **`TabsScrollArea`**.
+
+### Custom `OverflowIndicatorComponent`
+
+Provide a component that satisfies **`TabsScrollAreaOverflowIndicatorProps`**: **`direction`** (`'left'` \| `'right'`), **`show`**, optional **`style`**, and **`testID`**. The default implementation maps **`direction`** to **`OverflowGradient`**’s **`pin`**.
+
+The example below replaces the gradient with a simple edge strip (visual-only; no scroll wiring).
+
+```tsx
+import { Box } from '@coinbase/cds-mobile/layout';
+import {
+ DefaultTab,
+ DefaultTabsActiveIndicator,
+ Tabs,
+ TabsScrollArea,
+ type TabsScrollAreaOverflowIndicatorProps,
+} from '@coinbase/cds-mobile/tabs';
+
+function CustomOverflowIndicator({
+ direction,
+ show,
+ style,
+ testID,
+}: TabsScrollAreaOverflowIndicatorProps) {
+ if (!show) {
+ return null;
+ }
+ const isLeft = direction === 'left';
+ return (
+
+ );
+}
+
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
+## Styling
+
+Use **`styles.root`**, **`styles.scrollContainer`**, and **`styles.overflowIndicator`** to align with layout tokens. Mobile uses a single **`overflowIndicator`** style slot for both edges (see **Styles** tab).
+
+## Accessibility
+
+Set **`accessibilityLabel`** on **`TabsScrollArea`** (root container) and on **`Tabs`** for screen readers.
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx
new file mode 100644
index 0000000000..8c3e7aa42e
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx
@@ -0,0 +1,10 @@
+import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
+import mobilePropsData from ':docgen/mobile/tabs/TabsScrollArea/data';
+import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
+import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';
+
+
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx
new file mode 100644
index 0000000000..bede3238ea
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx
@@ -0,0 +1,7 @@
+import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable';
+
+import mobileStylesData from ':docgen/mobile/tabs/TabsScrollArea/styles-data';
+
+## Selectors
+
+
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx
new file mode 100644
index 0000000000..a6282c88f4
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx
@@ -0,0 +1,119 @@
+TabsScrollArea is the recommended wrapper when a [Tabs](/components/navigation/Tabs/) row may overflow horizontally. Pass a **function child** that receives `onActiveTabElementChange` and spread that onto **`Tabs`** (along with shared accessibility props) so the scroll region can measure tab positions and scroll the active tab into view.
+
+## Basics
+
+Pair **`TabsScrollArea`** with **`Tabs`**, **`DefaultTab`**, and **`DefaultTabsActiveIndicator`**. Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the row overflows on smaller viewports; chevron controls and edge gradients appear when there is offscreen content.
+
+```jsx live
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
+## Overflow indicator
+
+On web, the default **`TabsScrollAreaOverflowIndicator`** combines a fade gradient with chevron controls.
+
+### Custom `OverflowIndicatorComponent`
+
+Provide your own component that matches **`TabsScrollAreaOverflowIndicatorProps`**: **`direction`** (`'left'` \| `'right'`), **`show`**, **`onClick`**, plus optional **`styles`** / **`classNames`** slots. **`TabsScrollArea`** passes **`accessibilityLabel`** for the side control—forward it to a button or **`IconButton`** when you build a custom control.
+
+```jsx live
+function Example() {
+ const tabs = [
+ { id: 't1', label: 'Overview' },
+ { id: 't2', label: 'Markets' },
+ { id: 't3', label: 'Trade' },
+ { id: 't4', label: 'Earn' },
+ { id: 't5', label: 'Learn' },
+ { id: 't6', label: 'More' },
+ ];
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ const CustomOverflowIndicator = useCallback(
+ ({ accessibilityLabel, direction, show, onClick, style, className }) => {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ },
+ [],
+ );
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+}
+```
+
+## Styling
+
+Use **`styles`** / **`classNames`** to target the root, scroll container, and overflow controls (`overflowIndicator`, `overflowIndicatorButton`, `overflowIndicatorButtonContainer`, `overflowIndicatorGradient`, and related slots on web).
+
+## Accessibility
+
+Set **`accessibilityLabel`** on **`TabsScrollArea`** for the scrollable tab list region. On web, customize **`previousArrowAccessibilityLabel`** and **`nextArrowAccessibilityLabel`** for the default overflow controls. Ensure **`Tabs`** also has a meaningful **`accessibilityLabel`** for the tablist, as in the [Tabs](/components/navigation/Tabs/) examples.
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx
new file mode 100644
index 0000000000..436b33f48e
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx
@@ -0,0 +1,10 @@
+import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
+import webPropsData from ':docgen/web/tabs/TabsScrollArea/data';
+import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
+import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';
+
+
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx
new file mode 100644
index 0000000000..1aa20844f6
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx
@@ -0,0 +1,55 @@
+import { useMemo, useState } from 'react';
+import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable';
+import { StylesExplorer } from '@site/src/components/page/StylesExplorer';
+import {
+ DefaultTab,
+ DefaultTabsActiveIndicator,
+ Tabs,
+ TabsScrollArea,
+} from '@coinbase/cds-web/tabs';
+
+import webStylesData from ':docgen/web/tabs/TabsScrollArea/styles-data';
+
+export const TabsScrollAreaStylesExample = ({ classNames }) => {
+ const longTabs = useMemo(
+ () =>
+ Array.from({ length: 9 }, (_, i) => ({
+ id: `tab-${i}`,
+ label: `Section ${i + 1}`,
+ })),
+ [],
+ );
+ const [activeTab, setActiveTab] = useState(longTabs[0]);
+ return (
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+ );
+};
+
+## Explorer
+
+
+ {(classNames) => }
+
+
+## Selectors
+
+
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx
new file mode 100644
index 0000000000..667a4b76ae
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx
@@ -0,0 +1,44 @@
+---
+id: tabsScrollArea
+title: TabsScrollArea
+platform_switcher_options: { web: true, mobile: true }
+hide_title: true
+---
+
+import { VStack } from '@coinbase/cds-web/layout';
+import { ComponentHeader } from '@site/src/components/page/ComponentHeader';
+import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer';
+
+import webPropsToc from ':docgen/web/tabs/TabsScrollArea/toc-props';
+import mobilePropsToc from ':docgen/mobile/tabs/TabsScrollArea/toc-props';
+
+import WebPropsTable from './_webPropsTable.mdx';
+import MobilePropsTable from './_mobilePropsTable.mdx';
+import WebStyles, { toc as webStylesToc } from './_webStyles.mdx';
+import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx';
+import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx';
+import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx';
+import webMetadata from './webMetadata.json';
+import mobileMetadata from './mobileMetadata.json';
+
+
+
+ }
+ mobileExamplesToc={mobileExamplesToc}
+ mobilePropsTable={}
+ mobilePropsToc={mobilePropsToc}
+ mobileStyles={}
+ mobileStylesToc={mobileStylesToc}
+ webExamples={}
+ webExamplesToc={webExamplesToc}
+ webPropsTable={}
+ webPropsToc={webPropsToc}
+ webStyles={}
+ webStylesToc={webStylesToc}
+ />
+
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json b/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json
new file mode 100644
index 0000000000..3e779ef998
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json
@@ -0,0 +1,15 @@
+{
+ "import": "import { TabsScrollArea } from '@coinbase/cds-mobile/tabs'",
+ "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabsScrollArea.tsx",
+ "description": "Horizontal scroll container for tab rows on React Native, with edge overflow gradients and automatic scroll-into-view for the active tab.",
+ "relatedComponents": [
+ {
+ "label": "Tabs",
+ "url": "/components/navigation/Tabs/"
+ },
+ {
+ "label": "TabbedChips (Alpha)",
+ "url": "/components/navigation/TabbedChipsAlpha/"
+ }
+ ]
+}
diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json b/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json
new file mode 100644
index 0000000000..0f11b77bf6
--- /dev/null
+++ b/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json
@@ -0,0 +1,22 @@
+{
+ "import": "import { TabsScrollArea } from '@coinbase/cds-web/tabs'",
+ "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabsScrollArea.tsx",
+ "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabsscrollarea--default",
+ "description": "Horizontal scroll container for tab rows on web, with chevron controls, edge gradients, and automatic scroll-into-view for the active tab.",
+ "relatedComponents": [
+ {
+ "label": "Tabs",
+ "url": "/components/navigation/Tabs/"
+ },
+ {
+ "label": "TabbedChips (Alpha)",
+ "url": "/components/navigation/TabbedChipsAlpha/"
+ }
+ ],
+ "dependencies": [
+ {
+ "name": "framer-motion",
+ "version": "^10.18.0"
+ }
+ ]
+}
diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts
index 8764d60d32..998405ef7d 100644
--- a/apps/docs/sidebars.ts
+++ b/apps/docs/sidebars.ts
@@ -603,6 +603,11 @@ const sidebars: SidebarsConfig = {
label: 'BrowserBar',
},
{ type: 'doc', id: 'components/navigation/Tabs/tabs', label: 'Tabs' },
+ {
+ type: 'doc',
+ id: 'components/navigation/TabsScrollArea/tabsScrollArea',
+ label: 'TabsScrollArea',
+ },
{
type: 'doc',
id: 'components/navigation/Coachmark/coachmark',
diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts
index 9aaf028390..ab340ac97b 100644
--- a/apps/mobile-app/src/routes.ts
+++ b/apps/mobile-app/src/routes.ts
@@ -720,6 +720,11 @@ export const routes = [
key: 'Tabs',
getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default,
},
+ {
+ key: 'TabsScrollArea',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/tabs/__stories__/TabsScrollArea.stories').default,
+ },
{
key: 'Tag',
getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default,
diff --git a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx
index e87d31e63c..afcda922e9 100644
--- a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx
+++ b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx
@@ -1,5 +1,5 @@
-import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
-import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native';
+import React, { forwardRef, memo, useCallback, useMemo } from 'react';
+import type { StyleProp, View, ViewStyle } from 'react-native';
import type { SharedAccessibilityProps, SharedProps, ThemeVars } from '@coinbase/cds-common';
import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
@@ -7,9 +7,8 @@ import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
import type { ChipProps } from '../../chips/ChipProps';
import { MediaChip } from '../../chips/MediaChip';
import { useComponentConfig } from '../../hooks/useComponentConfig';
-import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget';
-import { Box, type BoxProps, OverflowGradient } from '../../layout';
-import { Tabs, type TabsBaseProps, type TabsProps } from '../../tabs';
+import { type BoxProps } from '../../layout';
+import { Tabs, type TabsBaseProps, type TabsProps, TabsScrollArea } from '../../tabs';
const DefaultTabComponent = ({
label = '',
@@ -31,7 +30,7 @@ const DefaultTabComponent = ({
);
};
-const TabsActiveIndicatorComponent = () => {
+const DefaultTabsActiveIndicatorComponent = () => {
return null;
};
@@ -76,12 +75,16 @@ export type TabbedChipsProps = TabbedChipsBasePro
gap?: ThemeVars.Space;
/**
* The width of the scroll container, defaults to 100% of the parent container
- * If the tabs are wider than the width of the container, paddles will be shown to scroll the tabs.
+ * If the tabs are wider than the width of the container, overflow gradients are shown at the edges.
*/
width?: BoxProps['width'];
styles?: {
/** Root container element */
root?: StyleProp;
+ /** Horizontal scroll region wrapping the tab row (aligned with {@link TabsScrollArea}). */
+ scrollContainer?: StyleProp;
+ /** Single overflow affordance (gradient); applied to both edges (aligned with {@link TabsScrollArea}). */
+ overflowIndicator?: StyleProp;
/** Tabs root element */
tabs?: StyleProp;
};
@@ -102,6 +105,7 @@ const TabbedChipsComponent = memo(
activeTab = tabs[0],
testID = 'tabbed-chips',
TabComponent = DefaultTabComponent,
+ TabsActiveIndicatorComponent = DefaultTabsActiveIndicatorComponent,
onChange,
width,
gap = 1,
@@ -110,16 +114,6 @@ const TabbedChipsComponent = memo(
autoScrollOffset = 30,
...accessibilityProps
} = mergedProps;
- const [scrollTarget, setScrollTarget] = useState(null);
- const {
- scrollRef,
- isScrollContentOverflowing,
- isScrollContentOffscreenLeft,
- isScrollContentOffscreenRight,
- handleScroll,
- handleScrollContainerLayout,
- handleScrollContentSizeChange,
- } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset });
const TabComponentWithCompact = useCallback(
(props: TabValue) => {
@@ -128,42 +122,37 @@ const TabbedChipsComponent = memo(
[TabComponent, compact],
);
+ const tabsScrollAreaStyles = useMemo(
+ () => ({
+ root: styles?.root,
+ scrollContainer: styles?.scrollContainer,
+ overflowIndicator: styles?.overflowIndicator,
+ }),
+ [styles],
+ );
+
return (
-
-
+ {(props) => (
-
- {isScrollContentOverflowing && isScrollContentOffscreenLeft && (
-
- )}
- {isScrollContentOverflowing && isScrollContentOffscreenRight && (
-
)}
-
+
);
}),
);
diff --git a/packages/mobile/src/core/componentConfig.ts b/packages/mobile/src/core/componentConfig.ts
index 1b3abba7d9..bf70922248 100644
--- a/packages/mobile/src/core/componentConfig.ts
+++ b/packages/mobile/src/core/componentConfig.ts
@@ -67,6 +67,7 @@ import type { StepperBaseProps } from '../stepper/Stepper';
import type { SegmentedTabBaseProps } from '../tabs/SegmentedTab';
import type { SegmentedTabsBaseProps } from '../tabs/SegmentedTabs';
import type { TabsBaseProps } from '../tabs/Tabs';
+import type { TabsScrollAreaBaseProps } from '../tabs/TabsScrollArea';
import type { TagBaseProps } from '../tag/Tag';
import type { TourBaseProps } from '../tour/Tour';
import type { LinkBaseProps } from '../typography/Link';
@@ -157,6 +158,7 @@ export type ComponentConfig = {
Stepper?: ConfigResolver;
Switch?: ConfigResolver>;
Tabs?: ConfigResolver;
+ TabsScrollArea?: ConfigResolver;
Tag?: ConfigResolver;
TextInput?: ConfigResolver;
Toast?: ConfigResolver;
diff --git a/packages/mobile/src/layout/OverflowGradient.tsx b/packages/mobile/src/layout/OverflowGradient.tsx
index a3290f52f4..ccade75695 100644
--- a/packages/mobile/src/layout/OverflowGradient.tsx
+++ b/packages/mobile/src/layout/OverflowGradient.tsx
@@ -1,6 +1,5 @@
import React, { memo, useMemo } from 'react';
-import { StyleSheet } from 'react-native';
-import type { ViewStyle } from 'react-native';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import type { PinningDirection, SharedProps } from '@coinbase/cds-common';
import { LinearGradient } from '../gradients/LinearGradient';
@@ -9,7 +8,7 @@ import { pinStyles } from '../styles/pinStyles';
export type OverflowGradientProps = {
pin?: Exclude;
- style?: ViewStyle;
+ style?: StyleProp;
} & SharedProps;
const gradient = {
diff --git a/packages/mobile/src/tabs/TabsScrollArea.tsx b/packages/mobile/src/tabs/TabsScrollArea.tsx
new file mode 100644
index 0000000000..c368c1dc8c
--- /dev/null
+++ b/packages/mobile/src/tabs/TabsScrollArea.tsx
@@ -0,0 +1,130 @@
+import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
+import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native';
+import type { SharedAccessibilityProps } from '@coinbase/cds-common';
+
+import { useComponentConfig } from '../hooks/useComponentConfig';
+import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget';
+import { type BoxBaseProps, HStack } from '../layout';
+
+import {
+ TabsScrollAreaOverflowIndicator,
+ type TabsScrollAreaOverflowIndicatorProps,
+} from './TabsScrollAreaOverflowIndicator';
+
+/**
+ * Values passed to `TabsScrollArea`'s function child. Pass `onActiveTabElementChange` to `Tabs` as
+ * `onActiveTabElementChange` so the scroll area can scroll the active tab into view.
+ */
+export type TabsScrollAreaRenderProps = {
+ /**
+ * Pass to `Tabs` as `onActiveTabElementChange={onActiveTabElementChange}`.
+ */
+ onActiveTabElementChange: (element: View | null) => void;
+};
+
+export type TabsScrollAreaBaseProps = Omit &
+ SharedAccessibilityProps & {
+ /**
+ * Horizontal offset when auto-scrolling to the active tab (e.g. so the active tab is not under the overflow gradient).
+ * @default 30
+ */
+ autoScrollOffset?: number;
+ /**
+ * Rendered at each end when content overflows. Defaults to {@link TabsScrollAreaOverflowIndicator}
+ * ({@link OverflowGradient}). Props must extend {@link TabsScrollAreaOverflowIndicatorProps}.
+ */
+ OverflowIndicatorComponent?: React.FC;
+ };
+
+export type TabsScrollAreaProps = TabsScrollAreaBaseProps & {
+ /**
+ * Render function that receives `onActiveTabElementChange` (wire to `Tabs` as
+ * `onActiveTabElementChange`).
+ */
+ children: (props: TabsScrollAreaRenderProps) => React.ReactNode;
+ styles?: {
+ /** Root layout element */
+ root?: StyleProp;
+ /** Horizontal `ScrollView` wrapping `Tabs` */
+ scrollContainer?: StyleProp;
+ /**
+ * applied to overflow indicators.
+ */
+ overflowIndicator?: StyleProp;
+ };
+};
+
+const TabsScrollAreaWithRef = forwardRef(
+ function TabsScrollArea(_props, ref) {
+ const mergedProps = useComponentConfig('TabsScrollArea', _props);
+ const {
+ children,
+ testID,
+ width,
+ autoScrollOffset = 30,
+ OverflowIndicatorComponent = TabsScrollAreaOverflowIndicator,
+ style,
+ styles: {
+ root: rootStyle,
+ scrollContainer: scrollContainerStyle,
+ overflowIndicator: overflowIndicatorStyle,
+ } = {},
+ ...props
+ } = mergedProps;
+
+ const [scrollTarget, setScrollTarget] = useState(null);
+ const {
+ scrollRef,
+ isScrollContentOverflowing,
+ isScrollContentOffscreenLeft,
+ isScrollContentOffscreenRight,
+ handleScroll,
+ handleScrollContainerLayout,
+ handleScrollContentSizeChange,
+ } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset });
+
+ const renderedChildren = useMemo(() => {
+ if (typeof children === 'function') {
+ return children({ onActiveTabElementChange: setScrollTarget });
+ }
+ console.warn(
+ 'TabsScrollArea expects a function child `({ onActiveTabElementChange }) => `.',
+ );
+ return null;
+ }, [children]);
+
+ const leftShow = isScrollContentOverflowing && isScrollContentOffscreenLeft;
+ const rightShow = isScrollContentOverflowing && isScrollContentOffscreenRight;
+
+ return (
+
+
+ {renderedChildren}
+
+
+
+
+ );
+ },
+);
+
+export const TabsScrollArea = memo(TabsScrollAreaWithRef);
+
+TabsScrollAreaWithRef.displayName = 'TabsScrollArea';
diff --git a/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx b/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx
new file mode 100644
index 0000000000..62478c9219
--- /dev/null
+++ b/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx
@@ -0,0 +1,38 @@
+import { memo } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+import type { SharedProps } from '@coinbase/cds-common';
+
+import { OverflowGradient } from '../layout';
+
+export type TabsScrollAreaOverflowIndicatorBaseProps = SharedProps & {
+ /**
+ * Direction of the indicator.
+ */
+ direction?: 'left' | 'right';
+ /**
+ * When false, nothing is rendered.
+ */
+ show: boolean;
+};
+
+export type TabsScrollAreaOverflowIndicatorProps = TabsScrollAreaOverflowIndicatorBaseProps & {
+ style?: StyleProp;
+};
+
+/**
+ * Default overflow affordance for {@link TabsScrollArea} on React Native: a single-layer
+ * {@link OverflowGradient} (no separate button / container slots).
+ */
+export const TabsScrollAreaOverflowIndicator = memo(function TabsScrollAreaOverflowIndicator({
+ show,
+ direction = 'left',
+ ...props
+}: TabsScrollAreaOverflowIndicatorProps) {
+ if (!show) {
+ return null;
+ }
+
+ return ;
+});
+
+TabsScrollAreaOverflowIndicator.displayName = 'TabsScrollAreaOverflowIndicator';
diff --git a/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx b/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx
new file mode 100644
index 0000000000..370dfcbe54
--- /dev/null
+++ b/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx
@@ -0,0 +1,159 @@
+import React, { memo, useCallback, useMemo, useState } from 'react';
+import { StyleSheet } from 'react-native';
+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 { LinearGradient } from '../../gradients/LinearGradient';
+import { useTheme } from '../../hooks/useTheme';
+import { type BoxProps, VStack } from '../../layout';
+import { pinStyles } from '../../styles/pinStyles';
+import { ThemeProvider } from '../../system/ThemeProvider';
+import { defaultTheme } from '../../themes/defaultTheme';
+import { Text } from '../../typography/Text';
+import { DefaultTab } from '../DefaultTab';
+import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator';
+import { Tabs } from '../Tabs';
+import { TabsScrollArea } from '../TabsScrollArea';
+import type { TabsScrollAreaOverflowIndicatorProps } from '../TabsScrollAreaOverflowIndicator';
+
+const basicTabs: (TabValue & { testID?: string })[] = [
+ { 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 storyShadowGradientDirections = {
+ right: {
+ start: { x: 1, y: 0 },
+ end: { x: 0, y: 0 },
+ },
+ left: {
+ start: { x: 0, y: 0 },
+ end: { x: 1, y: 0 },
+ },
+} as const;
+
+const StoryCustomOverflowIndicator = memo(function StoryCustomOverflowIndicator({
+ direction = 'left',
+ show,
+ style,
+ testID,
+}: TabsScrollAreaOverflowIndicatorProps) {
+ const theme = useTheme();
+ const shadowGradientColors = useMemo(
+ () => ['rgba(0, 0, 0, 0.22)', 'rgba(0, 0, 0, 0.06)', theme.color.transparent],
+ [theme.color.transparent],
+ );
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+ );
+});
+
+const styles = StyleSheet.create({
+ gradientShadow: {
+ width: 44,
+ },
+});
+
+StoryCustomOverflowIndicator.displayName = 'StoryCustomOverflowIndicator';
+
+type TabsScrollAreaExampleProps = {
+ title: string;
+ description?: string;
+ width?: BoxProps['width'];
+ maxWidth?: BoxProps['maxWidth'];
+ tabs: TabValue[];
+ OverflowIndicatorComponent?: React.FC;
+};
+
+const TabsScrollAreaExample = ({
+ title,
+ description = 'Use a narrow width so the tab row overflows and edge gradients appear. Scroll horizontally to move the row.',
+ width,
+ maxWidth,
+ tabs,
+ OverflowIndicatorComponent,
+}: TabsScrollAreaExampleProps) => {
+ const [activeTab, setActiveTab] = useState | null>(tabs[0]);
+ const handleChange = useCallback((next: TabValue | null) => setActiveTab(next), []);
+
+ return (
+
+
+
+ {description}
+
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+
+
+ );
+};
+
+const TabsScrollAreaStoriesScreen = () => (
+
+
+
+
+
+
+
+
+
+);
+
+export default TabsScrollAreaStoriesScreen;
diff --git a/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx b/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx
new file mode 100644
index 0000000000..566c8069b9
--- /dev/null
+++ b/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx
@@ -0,0 +1,74 @@
+import { useState } from 'react';
+import { Text } from 'react-native';
+import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs';
+import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+
+import { DefaultThemeProvider } from '../../utils/testHelpers';
+import { DefaultTab } from '../DefaultTab';
+import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator';
+import { Tabs } from '../Tabs';
+import { TabsScrollArea } from '../TabsScrollArea';
+
+const tabs = sampleTabs.slice(0, 5);
+
+const testID = 'tabs-scroll-area';
+
+const Demo = () => {
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+
+ {({ onActiveTabElementChange }) => (
+
+ )}
+
+
+ );
+};
+
+describe('TabsScrollArea', () => {
+ it('passes a11y', () => {
+ render();
+ expect(screen.getByTestId(testID)).toBeAccessible();
+ });
+
+ it('renders the scroll area and tabs', () => {
+ render();
+ expect(screen.getByTestId(testID)).toBeVisible();
+ expect(screen.getByText('Tab one')).toBeVisible();
+ });
+
+ it('forwards accessibilityLabel to the root', () => {
+ render();
+ expect(screen.getByLabelText('Scrollable tab list')).toBeVisible();
+ });
+
+ it('updates selected tab on press', async () => {
+ render();
+ const firstTestId = tabs[0].testID ?? tabs[0].id;
+ const secondTestId = tabs[1].testID ?? tabs[1].id;
+
+ expect(screen.getByTestId(firstTestId)).toHaveAccessibilityState({ selected: true });
+
+ fireEvent.press(screen.getByTestId(secondTestId));
+
+ await waitFor(() =>
+ expect(screen.getByTestId(secondTestId)).toHaveAccessibilityState({ selected: true }),
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId(firstTestId)).toHaveAccessibilityState({ selected: false }),
+ );
+ });
+});
diff --git a/packages/mobile/src/tabs/index.ts b/packages/mobile/src/tabs/index.ts
index 4d05fca873..b0aaf3ad1d 100644
--- a/packages/mobile/src/tabs/index.ts
+++ b/packages/mobile/src/tabs/index.ts
@@ -5,3 +5,5 @@ export * from './TabIndicator';
export * from './TabLabel';
export * from './TabNavigation';
export * from './Tabs';
+export * from './TabsScrollArea';
+export * from './TabsScrollAreaOverflowIndicator';
diff --git a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx
index 4bb4a67b41..a3f5503206 100644
--- a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx
+++ b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx
@@ -1,34 +1,20 @@
-import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import type { SharedAccessibilityProps, SharedProps, ThemeVars } 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 type { ChipProps } from '../../chips/ChipProps';
import { MediaChip } from '../../chips/MediaChip';
-import { cx } from '../../cx';
import { useComponentConfig } from '../../hooks/useComponentConfig';
-import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget';
-import { HStack, type HStackDefaultElement, type HStackProps } from '../../layout';
+import { type HStackDefaultElement, type HStackProps } from '../../layout';
import {
- Paddle,
Tabs,
type TabsActiveIndicatorComponent,
type TabsBaseProps,
type TabsProps,
+ TabsScrollArea,
} from '../../tabs';
-const containerCss = css`
- isolation: isolate;
-`;
-
-const scrollContainerCss = css`
- &::-webkit-scrollbar {
- display: none;
- }
- scrollbar-width: none;
-`;
-
const DefaultTabComponent = ({
label = '',
id,
@@ -91,12 +77,13 @@ export type TabbedChipsBaseProps = Omit<
TabsActiveIndicatorComponent?: TabsProps['TabsActiveIndicatorComponent'];
tabs: TabbedChipProps[];
/**
- * Turn on to use a compact Chip component for each tab.
+ * Turn on to use a compact `MediaChip` for each tab. On web, this is also passed to
+ * {@link TabsScrollArea} as `compact` so the overflow chevron `IconButton`s use compact sizing.
* @default false
*/
compact?: boolean;
/**
- * X position offset when auto-scrolling to active tab (to avoid active tab being covered by the paddle on the left side, default: 50px)
+ * X position offset when auto-scrolling to active tab (to avoid active tab being covered by the overflow indicator on the left side, default: 50px)
* @default 50
*/
autoScrollOffset?: number;
@@ -114,28 +101,47 @@ export type TabbedChipsProps = TabbedChipsBasePro
*/
gap?: HStackProps['gap'];
/**
- * The width of the scroll container, defaults to 100% of the parent container
- * If the tabs are wider than the width of the container, paddles will be shown to scroll the tabs.
+ * Width of the scroll region; defaults to the full width of the parent. When the tab row is wider
+ * than this container, overflow indicators appear.
* @default 100%
*/
width?: HStackProps['width'];
styles?: {
/** Root container element */
root?: React.CSSProperties;
- /** Scroll container element */
+ /** Horizontal scroll region wrapping the tab row (aligned with {@link TabsScrollArea}). */
scrollContainer?: React.CSSProperties;
- /** Paddle icon buttons */
+ /**
+ * @deprecated Use `overflowIndicatorButton` (or other `overflowIndicator*` style slots).
+ * @deprecationExpectedRemoval v10
+ */
paddle?: React.CSSProperties;
/** Tabs root element */
tabs?: React.CSSProperties;
+ /** Overflow indicator root */
+ overflowIndicator?: React.CSSProperties;
+ /** Overflow indicator icon button. */
+ overflowIndicatorButton?: React.CSSProperties;
+ /** Overflow indicator icon button container. */
+ overflowIndicatorButtonContainer?: React.CSSProperties;
+ /** Overflow indicator gradient. */
+ overflowIndicatorGradient?: React.CSSProperties;
};
classNames?: {
/** Root container element */
root?: string;
- /** Scroll container element */
+ /** Horizontal scroll region wrapping the tab row */
scrollContainer?: string;
/** Tabs root element */
tabs?: string;
+ /** Overflow control outer wrapper (each side). */
+ overflowIndicator?: string;
+ /** Overflow indicator icon button. */
+ overflowIndicatorButton?: string;
+ /** Overflow indicator icon button container. */
+ overflowIndicatorButtonContainer?: string;
+ /** Overflow indicator gradient. */
+ overflowIndicatorGradient?: string;
};
};
@@ -168,19 +174,6 @@ const TabbedChipsComponent = memo(
autoScrollOffset = 50,
...accessibilityProps
} = mergedProps;
- const [scrollTarget, setScrollTarget] = useState(null);
- const { scrollRef, isScrollContentOffscreenLeft, isScrollContentOffscreenRight, handleScroll } =
- useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset });
-
- const handleScrollLeft = useCallback(() => {
- scrollRef?.current?.scrollTo({ left: 0, behavior: 'smooth' });
- }, [scrollRef]);
-
- const handleScrollRight = useCallback(() => {
- if (!scrollRef.current) return;
- const maxScroll = scrollRef.current.scrollWidth - scrollRef.current.clientWidth;
- scrollRef.current.scrollTo({ left: maxScroll, behavior: 'smooth' });
- }, [scrollRef]);
const TabComponentWithCompact = useCallback(
(props: TabValue) => {
@@ -189,58 +182,62 @@ const TabbedChipsComponent = memo(
[TabComponent, compact],
);
+ const tabsScrollAreaStyles = useMemo(
+ () => ({
+ root: styles?.root,
+ scrollContainer: styles?.scrollContainer,
+ overflowIndicator: styles?.overflowIndicator,
+ overflowIndicatorButton: {
+ ...styles?.paddle,
+ ...styles?.overflowIndicatorButton,
+ },
+ overflowIndicatorButtonContainer: styles?.overflowIndicatorButtonContainer,
+ overflowIndicatorGradient: styles?.overflowIndicatorGradient,
+ }),
+ [styles],
+ );
+
+ const tabsScrollAreaClassNames = useMemo(
+ () => ({
+ root: classNames?.root,
+ scrollContainer: classNames?.scrollContainer,
+ overflowIndicator: classNames?.overflowIndicator,
+ overflowIndicatorButton: classNames?.overflowIndicatorButton,
+ overflowIndicatorButtonContainer: classNames?.overflowIndicatorButtonContainer,
+ overflowIndicatorGradient: classNames?.overflowIndicatorGradient,
+ }),
+ [classNames],
+ );
+
return (
-
-
-
+ {(props) => (
-
-
-
+ )}
+
);
}),
);
diff --git a/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx b/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx
index 729ecc5575..a4453ae41c 100644
--- a/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx
+++ b/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx
@@ -84,13 +84,13 @@ export const Default = () => {
- With paddles
+ With overflow (many tabs)
- With custom sized paddles
+ With custom sized overflow controls
-
+
With long text
diff --git a/packages/web/src/core/componentConfig.ts b/packages/web/src/core/componentConfig.ts
index 3780807a3f..84c459304f 100644
--- a/packages/web/src/core/componentConfig.ts
+++ b/packages/web/src/core/componentConfig.ts
@@ -80,6 +80,7 @@ import type { TableRowBaseProps } from '../tables/TableRow';
import type { SegmentedTabBaseProps } from '../tabs/SegmentedTab';
import type { SegmentedTabsBaseProps } from '../tabs/SegmentedTabs';
import type { TabsBaseProps } from '../tabs/Tabs';
+import type { TabsScrollAreaBaseProps } from '../tabs/TabsScrollArea';
import type { TagBaseProps } from '../tag/Tag';
import type { TourBaseProps } from '../tour/Tour';
import type { LinkBaseProps } from '../typography/Link';
@@ -181,6 +182,7 @@ export type ComponentConfig = {
TableCellFallback?: ConfigResolver;
TableRow?: ConfigResolver;
Tabs?: ConfigResolver;
+ TabsScrollArea?: ConfigResolver;
Tag?: ConfigResolver;
TextInput?: ConfigResolver;
Tile?: ConfigResolver;
diff --git a/packages/web/src/tabs/TabsScrollArea.tsx b/packages/web/src/tabs/TabsScrollArea.tsx
new file mode 100644
index 0000000000..7c446be56e
--- /dev/null
+++ b/packages/web/src/tabs/TabsScrollArea.tsx
@@ -0,0 +1,222 @@
+import React, { memo, useCallback, useMemo, useState } from 'react';
+import type { SharedAccessibilityProps } from '@coinbase/cds-common';
+import { css } from '@linaria/core';
+
+import { cx } from '../cx';
+import { useComponentConfig } from '../hooks/useComponentConfig';
+import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget';
+import { HStack } from '../layout';
+import type { BoxBaseProps } from '../layout/Box';
+import type { StylesAndClassNames } from '../types';
+
+import {
+ TabsScrollAreaOverflowIndicator,
+ type TabsScrollAreaOverflowIndicatorProps,
+} from './TabsScrollAreaOverflowIndicator';
+
+/**
+ * Values passed to `TabsScrollArea`'s function child. Pass `onActiveTab` to `Tabs` as
+ * `onActiveTabElementChange` so the scroll area can scroll the active tab into view.
+ */
+export type TabsScrollAreaRenderProps = {
+ /**
+ * Pass to `Tabs` as `onActiveTabElementChange={onActiveTab}`.
+ */
+ onActiveTabElementChange: (element: HTMLElement | null) => void;
+};
+
+/**
+ * Static class names for TabsScrollArea component parts.
+ * Use these selectors to target specific elements with CSS.
+ */
+export const tabsScrollAreaClassNames = {
+ /** Root layout element */
+ root: 'cds-TabsScrollArea',
+ /** Horizontal scroll region wrapping `Tabs` */
+ scrollContainer: 'cds-TabsScrollArea-scrollContainer',
+ /** Applied to each overflow indicator's root */
+ overflowIndicator: 'cds-TabsScrollArea-overflowIndicator',
+ /** Applied to each overflow indicator's icon button */
+ overflowIndicatorButton: 'cds-TabsScrollArea-overflowIndicatorButton',
+ /** Applied to each overflow indicator's icon button container */
+ overflowIndicatorButtonContainer: 'cds-TabsScrollArea-overflowIndicatorButtonContainer',
+ /** Applied to each overflow indicator's gradient */
+ overflowIndicatorGradient: 'cds-TabsScrollArea-overflowIndicatorGradient',
+} as const;
+
+export type TabsScrollAreaBaseProps = Omit &
+ Pick & {
+ previousArrowAccessibilityLabel?: string;
+ nextArrowAccessibilityLabel?: string;
+ /**
+ * Horizontal offset when auto-scrolling to the active tab (e.g. so the active tab is not under a paddle).
+ * @default 50
+ */
+ autoScrollOffset?: number;
+ /**
+ * Passed to the {@link TabsScrollAreaOverflowIndicator} to render compact sub-components (`IconButton` `compact`).
+ */
+ compact?: boolean;
+ /**
+ * Component rendered at each end when content overflows (left / right). Defaults to
+ * {@link TabsScrollAreaOverflowIndicator}. Props must extend {@link TabsScrollAreaOverflowIndicatorProps}.
+ */
+ OverflowIndicatorComponent?: React.FC;
+ };
+
+export type TabsScrollAreaProps = TabsScrollAreaBaseProps &
+ StylesAndClassNames & {
+ /**
+ * Render function that receives `onActiveTabElementChange` (wire to `Tabs` as `onActiveTabElementChange`).
+ */
+ children: (props: TabsScrollAreaRenderProps) => React.ReactNode;
+ /** Merged with the root `HStack`. */
+ style?: React.CSSProperties;
+ /** Merged with the root `HStack`. */
+ className?: string;
+ };
+
+const containerCss = css`
+ isolation: isolate;
+`;
+
+const scrollContainerCss = css`
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ scrollbar-width: none;
+`;
+export const TabsScrollArea = memo(function TabsScrollArea(_props: TabsScrollAreaProps) {
+ const mergedProps = useComponentConfig('TabsScrollArea', _props);
+ const {
+ children,
+ position = 'relative',
+ testID,
+ width = '100%',
+ previousArrowAccessibilityLabel = 'Previous',
+ nextArrowAccessibilityLabel = 'Next',
+ autoScrollOffset = 50,
+ compact,
+ OverflowIndicatorComponent = TabsScrollAreaOverflowIndicator,
+ style,
+ styles,
+ className,
+ classNames,
+ ...props
+ } = mergedProps;
+
+ if (typeof children !== 'function') {
+ throw new Error('TabsScrollArea expects a function child `(props) => `.');
+ }
+
+ const [scrollTarget, setScrollTarget] = useState(null);
+ const { scrollRef, isScrollContentOffscreenLeft, isScrollContentOffscreenRight, handleScroll } =
+ useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset });
+
+ const handleScrollLeft = useCallback(() => {
+ scrollRef.current?.scrollTo({ left: 0, behavior: 'smooth' });
+ }, [scrollRef]);
+
+ const handleScrollRight = useCallback(() => {
+ if (!scrollRef.current) return;
+ const maxScroll = scrollRef.current.scrollWidth - scrollRef.current.clientWidth;
+ scrollRef.current.scrollTo({ left: maxScroll, behavior: 'smooth' });
+ }, [scrollRef]);
+
+ const renderedChildren = useMemo(() => {
+ if (typeof children === 'function') {
+ return children({ onActiveTabElementChange: setScrollTarget });
+ }
+ console.warn(
+ 'TabsScrollArea expects a function child `({ onActiveTabElementChange }) => `.',
+ );
+ return null;
+ }, [children]);
+
+ const overflowIndicatorClassNames = useMemo(
+ () => ({
+ root: cx(tabsScrollAreaClassNames.overflowIndicator, classNames?.overflowIndicator),
+ button: cx(
+ tabsScrollAreaClassNames.overflowIndicatorButton,
+ classNames?.overflowIndicatorButton,
+ ),
+ buttonContainer: cx(
+ tabsScrollAreaClassNames.overflowIndicatorButtonContainer,
+ classNames?.overflowIndicatorButtonContainer,
+ ),
+ gradient: cx(
+ tabsScrollAreaClassNames.overflowIndicatorGradient,
+ classNames?.overflowIndicatorGradient,
+ ),
+ }),
+ [
+ classNames?.overflowIndicator,
+ classNames?.overflowIndicatorButton,
+ classNames?.overflowIndicatorButtonContainer,
+ classNames?.overflowIndicatorGradient,
+ ],
+ );
+
+ const overflowIndicatorStyles = useMemo(
+ () => ({
+ root: styles?.overflowIndicator,
+ button: styles?.overflowIndicatorButton,
+ buttonContainer: styles?.overflowIndicatorButtonContainer,
+ gradient: styles?.overflowIndicatorGradient,
+ }),
+ [
+ styles?.overflowIndicator,
+ styles?.overflowIndicatorButton,
+ styles?.overflowIndicatorButtonContainer,
+ styles?.overflowIndicatorGradient,
+ ],
+ );
+
+ return (
+
+
+
+ {renderedChildren}
+
+
+
+ );
+});
+
+TabsScrollArea.displayName = 'TabsScrollArea';
diff --git a/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx b/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx
new file mode 100644
index 0000000000..5756e5fd96
--- /dev/null
+++ b/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx
@@ -0,0 +1,201 @@
+import React, { memo, useMemo } from 'react';
+import {
+ animateGradientScaleConfig,
+ animatePaddleOpacityConfig,
+ animatePaddleScaleConfig,
+ paddleHidden,
+ paddleVisible,
+} from '@coinbase/cds-common/animation/paddle';
+import { durations } from '@coinbase/cds-common/motion/tokens';
+import { zIndex } from '@coinbase/cds-common/tokens/zIndex';
+import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types';
+import { css } from '@linaria/core';
+import { m as motion } from 'framer-motion';
+
+import { NewAnimatePresence } from '../animation/NewAnimatePresence';
+import { IconButton } from '../buttons/IconButton';
+import { cx } from '../cx';
+import { Box } from '../layout/Box';
+import { useMotionProps } from '../motion/useMotionProps';
+
+import { paddleWidth } from './Paddle';
+
+const MotionBox = motion(Box);
+
+export type TabsScrollAreaOverflowIndicatorBaseProps = SharedProps &
+ SharedAccessibilityProps & {
+ direction?: 'left' | 'right';
+ show: boolean;
+ compact?: boolean;
+ onClick: () => void;
+ };
+
+export type TabsScrollAreaOverflowIndicatorProps = TabsScrollAreaOverflowIndicatorBaseProps & {
+ style?: React.CSSProperties;
+ className?: string;
+ classNames?: {
+ root?: string;
+ button?: string;
+ buttonContainer?: string;
+ gradient?: string;
+ };
+ styles?: {
+ root?: React.CSSProperties;
+ button?: React.CSSProperties;
+ buttonContainer?: React.CSSProperties;
+ gradient?: React.CSSProperties;
+ };
+};
+
+const tabLabelOffset = '7px';
+
+const gradientCss = css`
+ display: block;
+ position: absolute;
+ pointer-events: none;
+ z-index: ${zIndex.interactable};
+ top: 0;
+ width: calc(${paddleWidth}px + var(--space-2));
+ height: 100%;
+`;
+
+const gradientLeftCss = css`
+ background: linear-gradient(to right, currentColor 50%, var(--color-transparent) 100%);
+ left: 0px;
+ transform-origin: left;
+`;
+
+const gradientRightCss = css`
+ background: linear-gradient(to left, currentColor 50%, var(--color-transparent) 100%);
+ right: 0px;
+ transform-origin: right;
+`;
+
+const containerCss = css`
+ display: block;
+ position: absolute;
+ z-index: ${zIndex.navigation + 1};
+ padding-top: calc(var(--space-2) - ${tabLabelOffset});
+ padding-bottom: calc(var(--space-2) - ${tabLabelOffset});
+`;
+
+const buttonCss = css`
+ display: block;
+ position: relative;
+ z-index: ${zIndex.navigation};
+`;
+
+const paddleLeftCss = css`
+ left: calc(var(--space-2) * -1);
+ padding-inline-start: var(--space-2);
+ padding-inline-end: var(--space-2);
+`;
+
+const paddleRightCss = css`
+ right: calc(var(--space-2) * -1);
+ padding-inline-start: var(--space-2);
+ padding-inline-end: var(--space-2);
+`;
+
+const tabsScrollAreaOverflowBackground = 'bg' as const;
+
+/**
+ * Default scroll overflow control for {@link TabsScrollArea}: same visuals as {@link Paddle} with
+ * fixed background token and secondary icon (no `background` / `variant` props on this API).
+ */
+export const TabsScrollAreaOverflowIndicator = memo(function TabsScrollAreaOverflowIndicator({
+ direction = 'left',
+ show,
+ onClick,
+ testID = `cds-paddle--${direction}`,
+ accessibilityLabel,
+ styles,
+ classNames,
+ style,
+ className,
+ compact,
+}: TabsScrollAreaOverflowIndicatorProps) {
+ const buttonStyle = useMemo(
+ () => ({
+ ...styles?.button,
+ }),
+ [styles?.button],
+ );
+
+ const rootStyle = useMemo(
+ () => ({
+ ...styles?.root,
+ ...style,
+ }),
+ [style, styles?.root],
+ );
+
+ /** Opacity on the motion root so {@link NewAnimatePresence} can run exit on the direct child. */
+ const containerPresenceMotionProps = useMotionProps({
+ enterConfigs: [{ ...animatePaddleOpacityConfig, toValue: paddleVisible }],
+ exitConfigs: [{ ...animatePaddleOpacityConfig, toValue: paddleHidden }],
+ exit: 'exit',
+ });
+
+ const buttonScaleMotionProps = useMotionProps({
+ enterConfigs: [{ ...animatePaddleScaleConfig, toValue: paddleVisible }],
+ exitConfigs: [{ ...animatePaddleScaleConfig, toValue: paddleHidden }],
+ exit: 'exit',
+ });
+
+ const gradientMotionProps = useMotionProps({
+ enterConfigs: [{ ...animateGradientScaleConfig, toValue: paddleVisible }],
+ exitConfigs: [{ ...animateGradientScaleConfig, toValue: paddleHidden }],
+ exit: 'exit',
+ });
+
+ return (
+
+ {show && (
+
+
+
+
+
+
+ )}
+
+ );
+});
+
+TabsScrollAreaOverflowIndicator.displayName = 'TabsScrollAreaOverflowIndicator';
diff --git a/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx b/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx
new file mode 100644
index 0000000000..21e911f4d5
--- /dev/null
+++ b/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx
@@ -0,0 +1,192 @@
+import { type FC, memo, 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 { css } from '@linaria/core';
+
+import { IconButton } from '../../buttons/IconButton';
+import { cx } from '../../cx';
+import { Box, VStack } from '../../layout';
+import { ThemeProvider } from '../../system/ThemeProvider';
+import { defaultTheme } from '../../themes/defaultTheme';
+import { Text } from '../../typography/Text';
+import { DefaultTab } from '../DefaultTab';
+import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator';
+import { Tabs } from '../Tabs';
+import { TabsScrollArea } from '../TabsScrollArea';
+import type { TabsScrollAreaOverflowIndicatorProps } from '../TabsScrollAreaOverflowIndicator';
+
+const customOverflowIndicatorRootCss = css`
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: ${zIndex.navigation + 2};
+`;
+
+const StoryCustomOverflowIndicator = memo(function StoryCustomOverflowIndicator({
+ direction,
+ show,
+ onClick,
+ style,
+ className,
+}: TabsScrollAreaOverflowIndicatorProps) {
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+});
+
+StoryCustomOverflowIndicator.displayName = 'StoryCustomOverflowIndicator';
+
+export default {
+ title: 'Components/Tabs/TabsScrollArea',
+ parameters: {
+ a11y: {
+ context: {
+ include: ['body'],
+ exclude: ['.no-a11y-checks'],
+ },
+ },
+ },
+};
+
+const basicTabs: (TabValue & { testID?: string })[] = [
+ { 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 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 },
+ },
+ },
+ },
+};
+
+type TabsScrollAreaExampleProps = {
+ title: string;
+ description?: string;
+ maxWidth: number | string;
+ tabs: TabValue[];
+ OverflowIndicatorComponent?: FC;
+};
+
+const TabsScrollAreaExample = ({
+ title,
+ description = 'Narrow the Storybook viewport or use the constrained width below so the tab row overflows and the side paddles appear.',
+ maxWidth,
+ tabs,
+ OverflowIndicatorComponent,
+}: TabsScrollAreaExampleProps) => {
+ const [activeTab, setActiveTab] = useState | null>(tabs[0]);
+ const handleChange = useCallback((next: TabValue | null) => setActiveTab(next), []);
+
+ return (
+
+
+ {title}
+
+
+ {description}
+
+
+ {({ onActiveTabElementChange: onActiveTab }) => (
+
+ )}
+
+
+ );
+};
+
+export const Default = () => (
+
+);
+
+export const ManyTabs = () => (
+
+);
+
+export const FitsWithoutOverflow = () => (
+
+);
+
+export const LightAndDark = () => (
+
+
+
+
+
+
+
+
+);
+
+export const CustomOverflowIndicator = () => (
+
+);
+
+Default.parameters = tabsTabListOnlyA11y;
+ManyTabs.parameters = tabsTabListOnlyA11y;
+FitsWithoutOverflow.parameters = tabsTabListOnlyA11y;
+LightAndDark.parameters = tabsTabListOnlyA11y;
+CustomOverflowIndicator.parameters = tabsTabListOnlyA11y;
diff --git a/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx b/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx
new file mode 100644
index 0000000000..c98979d9d1
--- /dev/null
+++ b/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx
@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import useMeasure from 'react-use-measure';
+import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap';
+import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs';
+import { render, screen } from '@testing-library/react';
+
+import { DefaultThemeProvider } from '../../utils/test';
+import { Tabs } from '../Tabs';
+import { TabsScrollArea } from '../TabsScrollArea';
+
+jest.mock('react-use-measure');
+jest.mock('@coinbase/cds-common/hooks/useRefMap');
+
+const NoopFn = () => {};
+
+const mockUseMeasure = (mocks: Partial>) => {
+ (useMeasure as jest.Mock).mockReturnValue(mocks);
+};
+
+const mockUseRefMap = (mocks: ReturnType) => {
+ (useRefMap as jest.Mock).mockReturnValue(mocks);
+};
+
+const mockDimensions: Partial> = [
+ jest.fn(),
+ {
+ width: 400,
+ x: 0,
+ y: 0,
+ height: 40,
+ top: 0,
+ right: 0,
+ left: 0,
+ bottom: 0,
+ },
+];
+
+const refMap: ReturnType = {
+ refs: { current: {} },
+ registerRef: NoopFn,
+ getRef: jest.fn(() => ({
+ getBoundingClientRect: jest.fn(() => ({
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 40,
+ })),
+ offsetLeft: 0,
+ offsetTop: 0,
+ offsetWidth: 80,
+ offsetHeight: 40,
+ offsetParent: {},
+ })),
+};
+
+const tabs = sampleTabs.slice(0, 3);
+
+const MockTabsScrollArea = () => {
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+ return (
+
+ {({ onActiveTabElementChange: onActiveTab }) => (
+ {
+ if (tab) setActiveTab(tab);
+ }}
+ tabs={tabs}
+ />
+ )}
+
+ );
+};
+
+describe('TabsScrollArea', () => {
+ const mockResizeObserver = jest.fn(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ }));
+
+ beforeAll(() => {
+ global.ResizeObserver = mockResizeObserver;
+ Element.prototype.scrollTo = jest.fn();
+ });
+
+ beforeEach(() => {
+ mockUseMeasure(mockDimensions);
+ mockUseRefMap(refMap);
+ });
+
+ it('renders the scroll area and tabs', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('tabs-scroll-area')).toBeVisible();
+ expect(screen.getByText('Tab one')).toBeVisible();
+ });
+
+ it('throws when children is not a function', () => {
+ expect(() =>
+ render(
+
+
+ {/* @ts-expect-error Intentionally invalid: `children` must be a render function */}
+ invalid
+
+ ,
+ ),
+ ).toThrow('TabsScrollArea expects a function child');
+ });
+
+ it('forwards accessibilityLabel to the root', () => {
+ render(
+
+
+ {({ onActiveTabElementChange: onActiveTab }) => (
+
+ )}
+
+ ,
+ );
+
+ expect(screen.getByLabelText('Scrollable tab list')).toBeVisible();
+ });
+});
diff --git a/packages/web/src/tabs/index.ts b/packages/web/src/tabs/index.ts
index 31b5f23c91..aee60fe931 100644
--- a/packages/web/src/tabs/index.ts
+++ b/packages/web/src/tabs/index.ts
@@ -7,3 +7,5 @@ export * from './TabIndicator';
export * from './TabLabel';
export * from './TabNavigation';
export * from './Tabs';
+export * from './TabsScrollArea';
+export * from './TabsScrollAreaOverflowIndicator';