Skip to content

Commit 0abde18

Browse files
feat(playground-ui): migrate Tabs to Base UI and add pill variant (#16414)
## Summary - Migrates the `Tabs` component in `packages/playground-ui` from `@radix-ui/react-tabs` to `@base-ui/react/tabs` (already a dep, used by `Combobox`). - Adds a new `pill` variant on `<TabList>` — animated background indicator that slides behind the active trigger, à la the Base UI Tabs demo. - The default `line` variant now animates its underline smoothly between tabs as well, using `<Tabs.Indicator>`. - Public API (`Tabs`, `TabList`, `Tab`, `TabContent`) is unchanged — existing call-sites keep the default `line` variant with no code change required. - Variant propagation is pure CSS via a `data-variant` attribute on `TabList` plus Tailwind `group-data-[variant=...]/tabs-list:` selectors — no React Context, no prop drilling. ### Usage ```tsx <Tabs defaultTab="overview"> <TabList variant="pill"> <Tab value="overview">Overview</Tab> <Tab value="projects">Projects</Tab> <Tab value="account">Account</Tab> </TabList> <TabContent value="overview">…</TabContent> </Tabs> ``` ## Test plan - [ ] `pnpm storybook` (from `packages/playground-ui`) → Navigation/Tabs - [ ] Existing stories (Default, TwoTabs, ManyTabs, WithClosableTabs) render and behave as before — underline now glides between tabs - [ ] New `PillVariant` story — background pill slides fluidly between triggers on click and on arrow-key navigation - [ ] Existing call-sites unchanged visually: - [ ] `CodeBlock` with `selector="tabs"` - [ ] `span-data-panel-view` (traces detail panel) - [ ] Metrics cards on agents page - [ ] Keyboard a11y: Tab to enter, Arrow Left/Right to switch, `data-active` present on active trigger <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 The Tabs component is switched to a different internal library (Base UI) and gets a new "pill" style where a colored background smoothly slides behind the active tab; the default "line" style now animates its underline smoothly. From the outside the component still works the same so existing code doesn't need changes. ## Overview This PR migrates the Tabs implementation in packages/playground-ui from @radix-ui/react-tabs to @base-ui/react/tabs, adds a new TabList `pill` variant with a sliding background indicator, and improves the `line` variant to animate its underline between tabs. Public usage of Tabs (Tabs, TabList, Tab, TabContent) remains compatible with existing call sites. ## Implementation Details - tabs-root.tsx - Replaced Radix Root with BaseTabs.Root and removed internal controlled/uncontrolled state management; defaultValue/value/onValueChange are delegated to BaseTabs. - tabs-list.tsx - Introduces CVA-driven `tabListVariants` with `line` (default) and `pill`. - Adds a `variant` prop and sets `data-variant` on the list for Tailwind group-data selectors. - Renders BaseTabs.Indicator differently for `line` vs `pill` using CSS variables for width/offset. - ClassName handling changed so consumer `className` is applied to the actual BaseTabs.List element (restores previous behavior via a subsequent fix). - tabs-tab.tsx - Replaced Radix Trigger with BaseTabs.Tab and updated styles to rely on Base UI data attributes (`data-[active]`, `data-[disabled]`) and the new variant group selectors. - Close-button behavior (stopPropagation + onClose) preserved. - tabs-content.tsx - Replaced Radix Content with BaseTabs.Panel; preserves `value` and className behavior. - tabs.stories.tsx - Adds a `PillVariant` Storybook story demonstrating the new `variant="pill"` usage. Commit/fix note: - fix(playground-ui): ensure consumer `className` is applied to the actual TabList element (not only the outer scroll wrapper). ## Styling & Variant Propagation Variant behavior is driven by a `data-variant` attribute on TabList and targeted with Tailwind `group-data-[variant=...]/tabs-list:` selectors—no React Context or prop drilling. CSS variables are used to compute the active tab's width and left offset so the BaseTabs.Indicator can animate smoothly for both `line` and `pill` visuals. ## Testing / Review Plan - Visual: Storybook (Navigation/Tabs) including the new PillVariant story; visual checks on existing call sites (CodeBlock selector="tabs", span-data-panel-view, metrics cards). - Interaction: Keyboard accessibility (Tab to focus, Arrow Left/Right to change tabs), verify `data-[active]`/`data-active` state on active trigger. - Sanity: Ensure existing call sites render identically with default styling and that the `pill` story shows the sliding background. ## Notes on API/Types The outward component API remains compatible; callers do not need to change. Internally TabList accepts a `variant` prop (typed via CVA's VariantProps) to opt into `pill`, and className application to the list element was corrected. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/mastra-ai/mastra/pull/16414) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a30ef7b commit 0abde18

6 files changed

Lines changed: 113 additions & 33 deletions

File tree

.changeset/polite-kids-poke.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@mastra/playground-ui': minor
3+
---
4+
5+
Added a new `pill` variant on `TabList` with an animated background indicator that slides behind the active trigger. The default `line` variant now animates its underline smoothly between tabs as well. Implemented by migrating the underlying Tabs component from Radix UI to Base UI.
6+
7+
```tsx
8+
// Before — only the line (underline) style was available
9+
<Tabs defaultTab="overview">
10+
<TabList>
11+
<Tab value="overview">Overview</Tab>
12+
<Tab value="projects">Projects</Tab>
13+
</TabList>
14+
</Tabs>
15+
16+
// After — opt into the new pill style via the `variant` prop on TabList
17+
<Tabs defaultTab="overview">
18+
<TabList variant="pill">
19+
<Tab value="overview">Overview</Tab>
20+
<Tab value="projects">Projects</Tab>
21+
</TabList>
22+
</Tabs>
23+
```
24+
25+
The public API (`Tabs`, `TabList`, `Tab`, `TabContent`) is unchanged; existing call-sites keep the default `line` variant.

packages/playground-ui/src/ds/components/Tabs/tabs-content.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as RadixTabs from '@radix-ui/react-tabs';
1+
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
22
import { focusRing } from '@/ds/primitives/transitions';
33
import { cn } from '@/lib/utils';
44

@@ -10,11 +10,11 @@ export type TabContentProps = {
1010

1111
export const TabContent = ({ children, value, className }: TabContentProps) => {
1212
return (
13-
<RadixTabs.Content
13+
<BaseTabs.Panel
1414
value={value}
1515
className={cn('grid py-3 overflow-y-auto ring-offset-background', focusRing.visible, className)}
1616
>
1717
{children}
18-
</RadixTabs.Content>
18+
</BaseTabs.Panel>
1919
);
2020
};
Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,56 @@
1-
import * as RadixTabs from '@radix-ui/react-tabs';
1+
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
2+
import { cva } from 'class-variance-authority';
3+
import type { VariantProps } from 'class-variance-authority';
24
import { cn } from '@/lib/utils';
35

6+
const tabListVariants = cva('flex items-center relative text-ui-lg', {
7+
variants: {
8+
variant: {
9+
line: 'w-max min-w-full border-b border-border1',
10+
pill: 'w-fit gap-1 rounded-full bg-surface2 p-1',
11+
},
12+
},
13+
defaultVariants: {
14+
variant: 'line',
15+
},
16+
});
17+
418
export type TabListProps = {
519
children: React.ReactNode;
620
className?: string;
7-
};
21+
} & VariantProps<typeof tabListVariants>;
22+
23+
export const TabList = ({ children, className, variant }: TabListProps) => {
24+
const resolvedVariant = variant ?? 'line';
825

9-
export const TabList = ({ children, className }: TabListProps) => {
1026
return (
11-
<div className={cn('w-full overflow-x-auto', className)}>
12-
<RadixTabs.List
13-
className={cn('flex items-center relative w-max min-w-full', 'text-ui-lg border-b border-border1', className)}
27+
<div className="w-full overflow-x-auto">
28+
<BaseTabs.List
29+
data-variant={resolvedVariant}
30+
className={cn('group/tabs-list', tabListVariants({ variant: resolvedVariant }), className)}
1431
>
1532
{children}
16-
</RadixTabs.List>
33+
{resolvedVariant === 'line' && (
34+
<BaseTabs.Indicator
35+
className={cn(
36+
'absolute bottom-0 left-0 bg-neutral3',
37+
'w-[var(--active-tab-width)] h-0.5',
38+
'transition-all duration-200 ease-in-out',
39+
)}
40+
style={{ transform: 'translateX(var(--active-tab-left))' }}
41+
/>
42+
)}
43+
{resolvedVariant === 'pill' && (
44+
<BaseTabs.Indicator
45+
className={cn(
46+
'absolute top-1/2 left-0 z-0 rounded-full bg-surface4',
47+
'w-[var(--active-tab-width)] h-[calc(100%-0.5rem)]',
48+
'transition-all duration-200 ease-in-out',
49+
)}
50+
style={{ transform: 'translateY(-50%) translateX(var(--active-tab-left))' }}
51+
/>
52+
)}
53+
</BaseTabs.List>
1754
</div>
1855
);
1956
};
Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import * as RadixTabs from '@radix-ui/react-tabs';
2-
import { useState } from 'react';
1+
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
32
import { cn } from '@/lib/utils';
43

54
export type TabsRootProps<T extends string> = {
@@ -11,23 +10,14 @@ export type TabsRootProps<T extends string> = {
1110
};
1211

1312
export const Tabs = <T extends string>({ children, defaultTab, value, onValueChange, className }: TabsRootProps<T>) => {
14-
const [internalTab, setInternalTab] = useState<T>(defaultTab);
15-
16-
// Use controlled mode if value and onValueChange are provided
17-
const isControlled = value !== undefined && onValueChange !== undefined;
18-
const currentTab = isControlled ? value : internalTab;
19-
const handleTabChange = (newValue: string) => {
20-
const typedValue = newValue as T;
21-
if (isControlled) {
22-
onValueChange(typedValue);
23-
} else {
24-
setInternalTab(typedValue);
25-
}
26-
};
27-
2813
return (
29-
<RadixTabs.Root value={currentTab} onValueChange={handleTabChange} className={cn('overflow-y-auto', className)}>
14+
<BaseTabs.Root
15+
defaultValue={defaultTab}
16+
value={value}
17+
onValueChange={onValueChange ? next => onValueChange(next as T) : undefined}
18+
className={cn('overflow-y-auto', className)}
19+
>
3020
{children}
31-
</RadixTabs.Root>
21+
</BaseTabs.Root>
3222
);
3323
};

packages/playground-ui/src/ds/components/Tabs/tabs-tab.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as RadixTabs from '@radix-ui/react-tabs';
1+
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
22
import { X } from 'lucide-react';
33
import { transitions, focusRing } from '@/ds/primitives/transitions';
44
import { cn } from '@/lib/utils';
@@ -14,17 +14,24 @@ export type TabProps = {
1414

1515
export const Tab = ({ children, value, onClick, onClose, disabled, className }: TabProps) => {
1616
return (
17-
<RadixTabs.Trigger
17+
<BaseTabs.Tab
1818
value={value}
1919
disabled={disabled}
2020
className={cn(
21-
'py-2 px-5 text-ui-md font-normal text-neutral3 border-b-2 border-transparent',
21+
'text-ui-md font-normal text-neutral3',
2222
'whitespace-nowrap shrink-0 flex items-center justify-center gap-1.5 outline-none cursor-pointer',
2323
transitions.colors,
2424
focusRing.visible,
2525
'hover:text-neutral4',
26-
'data-[state=active]:text-neutral5 data-[state=active]:border-neutral3',
26+
'data-[active]:text-neutral5',
2727
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:hover:text-neutral3',
28+
// Line variant (default) — active state drawn by <Tabs.Indicator> in TabList
29+
'group-data-[variant=line]/tabs-list:py-2 group-data-[variant=line]/tabs-list:px-5',
30+
'group-data-[variant=line]/tabs-list:border-b-2 group-data-[variant=line]/tabs-list:border-transparent',
31+
// Pill variant
32+
'group-data-[variant=pill]/tabs-list:relative group-data-[variant=pill]/tabs-list:z-10',
33+
'group-data-[variant=pill]/tabs-list:py-1 group-data-[variant=pill]/tabs-list:px-3',
34+
'group-data-[variant=pill]/tabs-list:rounded-full',
2835
className,
2936
)}
3037
onClick={onClick}
@@ -42,6 +49,6 @@ export const Tab = ({ children, value, onClick, onClose, disabled, className }:
4249
<X className="w-3 h-3" />
4350
</button>
4451
)}
45-
</RadixTabs.Trigger>
52+
</BaseTabs.Tab>
4653
);
4754
};

packages/playground-ui/src/ds/components/Tabs/tabs.stories.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ export const ManyTabs: Story = {
8282
),
8383
};
8484

85+
export const PillVariant: Story = {
86+
render: () => (
87+
<Tabs defaultTab="overview" className="w-[500px]">
88+
<TabList variant="pill">
89+
<Tab value="overview">Overview</Tab>
90+
<Tab value="projects">Projects</Tab>
91+
<Tab value="account">Account</Tab>
92+
</TabList>
93+
<TabContent value="overview">
94+
<div className="p-4 text-neutral5">Overview content</div>
95+
</TabContent>
96+
<TabContent value="projects">
97+
<div className="p-4 text-neutral5">Projects content</div>
98+
</TabContent>
99+
<TabContent value="account">
100+
<div className="p-4 text-neutral5">Account content</div>
101+
</TabContent>
102+
</Tabs>
103+
),
104+
};
105+
85106
export const WithClosableTabs: Story = {
86107
render: () => (
87108
<Tabs defaultTab="file1" className="w-[400px]">

0 commit comments

Comments
 (0)