Skip to content

feat: TabsScrollArea + TabsScrollAreaIndicator#586

Open
adrienzheng-cb wants to merge 6 commits intomasterfrom
adrien/tabs-with-paddle
Open

feat: TabsScrollArea + TabsScrollAreaIndicator#586
adrienzheng-cb wants to merge 6 commits intomasterfrom
adrien/tabs-with-paddle

Conversation

@adrienzheng-cb
Copy link
Copy Markdown
Contributor

@adrienzheng-cb adrienzheng-cb commented Apr 3, 2026

What changed? Why?

Summary

⭐️⭐️⭐️ This change fully bridges the gap between Tabs and TabNavigation. ⭐️⭐️⭐️
Introduces TabsScrollArea on web and mobile as the supported way to host a horizontally scrolling Tabs row when content overflows. The API uses a render prop (children as a function) so consumers pass onActiveTabElementChange into Tabs for scroll-into-view behavior. Alpha TabbedChips is updated to compose TabsScrollArea so chip filters get the same overflow behavior and shared styling hooks.


Web (packages/web)

  • TabsScrollArea
    • Root is an HStack; root props are based on BoxBaseProps (and related types) so layout props (e.g. width, maxWidth) can be set on the component without an extra wrapper.
    • Render prop receives onActiveTabElementChange for wiring to Tabs.
    • Default overflow UI via TabsScrollAreaOverflowIndicator (fade gradient + chevron controls).
    • Supports compact on TabsScrollArea for smaller default overflow **IconButton**s (used when TabbedChips passes compact through).
    • Optional OverflowIndicatorComponent to replace the default indicator; styles / classNames slots for root, scroll container, and overflow subparts.
  • TabbedChips (alpha)
    • Wraps the tab row in TabsScrollArea; maps styles / classNames into TabsScrollArea slots (including deprecated styles.paddle merged into overflow button styles where applicable for backward compatibility).
  • Exports
    • tabs/index.ts exports TabsScrollArea / overflow indicator types as needed.
  • componentConfig
    • Registers TabsScrollArea for theme/config overrides.

Mobile (packages/mobile)

  • TabsScrollArea
    • Horizontal ScrollView for the tab row; root layout uses HStack (or equivalent stack) with **BoxBaseProps-style props for dimensions on the root.
    • TabsScrollAreaOverflowIndicator implemented with OverflowGradient and an explicit direction ('left' | 'right') API aligned with web semantics.
    • OverflowGradient updated to support the pinning/gradient direction needed for left/right edges.
    • Invalid children: if children is not a function, the component does not throw; it renders non-function children (or nothing) so misuse fails softly (scroll-into-view wiring is skipped in that case).
  • TabbedChips (alpha)
    • Composes TabsScrollArea and passes through overflow-related styles.
  • Exports & componentConfig
    • Same pattern as web: public exports and TabsScrollArea entry in componentConfig.

Documentation (apps/docs)

  • New component doc: components/navigation/TabsScrollArea/
    • index.mdx, web/mobile examples, props tables, styles tables, webMetadata.json / mobileMetadata.json (short descriptions, related components such as Tabs / TabbedChips where applicable).
    • Examples use width / maxWidth on TabsScrollArea instead of an extra Box wrapper where appropriate.
  • docgen.config.js
    • Includes tabs/TabsScrollArea for API extraction.
  • sidebars.ts
    • Navigation entry for TabsScrollArea next to Tabs.
  • Tabs docs
    • Scrolling with TabsScrollArea sections (web + mobile examples) and metadata tweaks (related components, etc.).

Apps

  • apps/mobile-app
    • Routes updated so the in-app catalog includes TabsScrollArea where other navigation components are registered.

UI changes

storybook
docs

Simulator.Screen.Recording.-.iPhone.16.Pro.-.2026-04-03.at.17.02.27.mov
Screen.Recording.2026-04-03.at.5.03.14.PM.mov

Testing

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Illustrations/Icons Checklist

Required if this PR changes files under packages/illustrations/** or packages/icons/**

  • verified visreg changes with Terran (include link to visreg run/approval)
  • all illustration/icons names have been reviewed by Dom and/or Terran

Change management

type=routine
risk=low
impact=sev5

automerge=false

@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Apr 3, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1
CODEOWNERS 🟡 See below

🟡 CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 🟡 0/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

@cb-ekuersch cb-ekuersch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the scrolling functionality was removed from TabbedChips but I guess the main thing im thinking is why? Also isn't NavigationTabs deprecated? I saw it mentioned in the PR description as being a motivating reason for these changes

onActiveTabElementChange: (element: View | null) => void;
};

export type TabsScrollAreaBaseProps = Omit<BoxBaseProps, 'children' | 'ref'> &
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dang we should probably remove 'children' from BoxBaseProps at some point

* Render function that receives `onActiveTabElementChange` (wire to `Tabs` as
* `onActiveTabElementChange`).
*/
children: (props: TabsScrollAreaRenderProps) => React.ReactNode;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

children should go on Props not BaseProps - @hcopp wdyt? I recall us coming to that conclusion the other day

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved children to full props

};

export type TabsScrollAreaProps = TabsScrollAreaBaseProps & {
styles?: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the styles/classNAmes helper type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should make an internal lint rule to enforce this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add lint rule in a separate pr

};

const TabsScrollAreaWithRef = forwardRef<View, TabsScrollAreaProps>(
function TabsScrollArea(_props, ref) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, why can't this be a feature of Tabs, enabled by certain props? Having a separate component feels a bit heavy just to support scrolling with some opinionated overflow styles

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about doing it that way and it would be very ideal. however:

  1. Tabs component is a root div wrapping a list of tab, we cannot add additional paddle/overflow indicator inside the root div because the div has a role of "tablist" and having buttons inside makes it inaccessible

  2. we don't want to add additional layers outside of the current root of Tabs because Tabs are already used by consumers and that can lead to drastic behavioral changes and may break their styling if they applies complex custom styles.. We also want to avoid wrapping the component in additional layer based on a scrollable prop conditionally

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tabs component is a root div wrapping a list of tab, we cannot add additional paddle/overflow indicator inside the root div because the div has a role of "tablist" and having buttons inside makes it inaccessible

So do customers use either Tabs or TabScrollArea depending on if they want scroll behavior?

Re: Tabs not rendering TabsScrollArea - why can't TabsScrollArea implement tablist role itself and then in Tabs if it decides it needs to render TabsScrollArea it doesn't apply the role to it's own container?

Copy link
Copy Markdown
Contributor Author

@adrienzheng-cb adrienzheng-cb Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TabsScrollArea is just an add-on to Tabs. It does not render the Tabs itself, it is just a scrollable Box that detects horizontal overflow and displays overflow indicators when overflow happens.

We didn't build this behavior into Tabs because Tabs are currently being used by consumers, and we want to avoid breaking their existing usages. One solution to that is to add an optional scrollable prop to Tabs that only add the overflow detection to it if enabled. However, Tabs' DOM structure becomes inconsistent when the prop is turned on/off if we go with that route. In the past we had issues which components that adds additional layer of Pressable around itself based on availability of certain props. (eg Cell) and we have been trying to avoid that pattern since.

Hence, I decided to keep TabsScrollArea as a separate component and leave Tabs as it is (a primitive component that only takes care of the business logic).

This way, existing usages of Tabs in consumer repos are unaffected by this change. They probably have implemented their own overflow strategy if they needed it. Anyone who are still using the Deprecated TabNavigation or would like to add scroll support to Tabs can switch to the following

<TabsScrollArea>
{
  ({ onActiveTabElementChange }) => <Tabs onActiveTabElementChange={onActiveTabElementChange} {...otherProps} />
}
</TabsScrollArea>

style?: React.CSSProperties;
/** Merged with the root `HStack`. */
className?: string;
styles?: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use style and classNames type helper

@adrienzheng-cb
Copy link
Copy Markdown
Contributor Author

I see the scrolling functionality was removed from TabbedChips but I guess the main thing im thinking is why? Also isn't NavigationTabs deprecated? I saw it mentioned in the PR description as being a motivating reason for these changes

cc:@cb-ekuersch We didn't remove it we just replaced the old scrolling implementation inside TabbedChips with the new TabsScrollArea. TabsScrollArea is essentially the old scroll logic from TabbedChips and TabNavigation extracted into its own component.

We did deprecate TabNavigation, however, Tabs (supposedly the replacement) was too primitive to provide the same functionalities TabNavigation used to offer and TabNavigation was heavily used. We had consumers complaining that it was hard to migrate to Tabs because it didn't have Tab, ActiveIndicator, or scroll indicators built in. I recently added DefaultTab and DefaultTabsActiveIndicator in a previous pr, and this pr is the last piece of puzzle for TabNavigation users to easily migrate to Tabs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants