Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/ui/content/docs/(root)/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ title: Changelog
description: Breaking changes, migration guides, and notable updates.
---

## August 4, 2026

### Tabs — scrollable list

**`TabsList`** now wraps its tabs in a **`ScrollArea`** so overflowing tabs scroll horizontally instead of overflowing their container — the usual reason dashboards could be pushed sideways on mobile. Opt out with **`scrollable={false}`**, and add **`scrollFade`** to fade the edges while there is more to scroll to.

Because the list can no longer grow past its container, a **`TabsList`** stretched with **`className="w-full"`** now sizes to its tabs instead. Pass **`scrollable={false}`** in that case:

```diff
- <TabsList className="w-full">
+ <TabsList className="w-full" scrollable={false}>
```

**Agent migration prompt:**

```text
After upgrading @coss/tabs, TabsList wraps its tabs in a ScrollArea and no longer grows past its container. Add scrollable={false} to any TabsList that relies on stretching itself (for example className="w-full"). If the project vendors tabs.tsx, also add the scroll-area component since tabs.tsx now imports it.
```

## July 31, 2026

### Scroll Area — overscroll contain
Expand Down
16 changes: 12 additions & 4 deletions apps/ui/content/docs/components/tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,18 @@ import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"

Root component. Styled wrapper for `Tabs.Root` from Base UI.

| Prop | Type | Default | Description |
| --------- | ---------------------------- | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "underline"` | `"default"` | Controls the tabs styling |

### TabsList

Container for tab triggers. Styled wrapper for `Tabs.List` from Base UI.

| Prop | Type | Default | Description |
| ------------ | -------------------------- | ----------- | ---------------------------------------------------------------- |
| `variant` | `"default" \| "underline"` | `"default"` | Controls the tabs styling |
| `scrollable` | `boolean` | `true` | Wraps the list in a scroll area so overflowing tabs can scroll |
| `scrollFade` | `boolean` | `false` | Fades the scroll area edges while there is content to scroll to |

When `scrollable` is enabled the list never grows past the width of its container, which keeps overflowing tabs reachable on small screens instead of overflowing the page. Pass `scrollable={false}` if the list should size itself freely, for example when stretching it to the full container width with `className="w-full"`.

### TabsTab

Individual tab trigger. Styled wrapper for `Tabs.Tab` from Base UI.
Expand All @@ -98,3 +102,7 @@ Visual indicator for the active tab. Styled wrapper for `Tabs.Indicator` from Ba
### Underline with Vertical Orientation

<ComponentPreview name="p-tabs-4" />

### Scrollable

<ComponentPreview name="p-tabs-14" />
23 changes: 23 additions & 0 deletions apps/ui/public/r/p-tabs-14.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "p-tabs-14",
"description": "Tabs that scroll horizontally when they overflow",
"registryDependencies": [
"@coss/tabs"
],
"files": [
{
"path": "registry/default/particles/p-tabs-14.tsx",
"content": "import { Tabs, TabsList, TabsPanel, TabsTab } from \"@/registry/default/ui/tabs\";\n\nconst tabs = [\n { label: \"Overview\", value: \"tab-1\" },\n { label: \"Insights\", value: \"tab-2\" },\n { label: \"Performance\", value: \"tab-3\" },\n { label: \"Members\", value: \"tab-4\" },\n { label: \"Integrations\", value: \"tab-5\" },\n { label: \"Billing\", value: \"tab-6\" },\n { label: \"Notifications\", value: \"tab-7\" },\n { label: \"Audit log\", value: \"tab-8\" },\n];\n\nexport default function Particle() {\n return (\n <Tabs defaultValue=\"tab-1\">\n <TabsList scrollFade>\n {tabs.map((tab) => (\n <TabsTab key={tab.value} value={tab.value}>\n {tab.label}\n </TabsTab>\n ))}\n </TabsList>\n {tabs.map((tab) => (\n <TabsPanel key={tab.value} value={tab.value}>\n <p className=\"p-4 text-center text-muted-foreground text-xs\">\n {tab.label} content\n </p>\n </TabsPanel>\n ))}\n </Tabs>\n );\n}\n",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full"
},
"categories": [
"tabs",
"scroll area"
],
"type": "registry:block"
}
24 changes: 24 additions & 0 deletions apps/ui/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,9 @@
}
],
"name": "tabs",
"registryDependencies": [
"@coss/scroll-area"
],
"type": "registry:ui"
},
{
Expand Down Expand Up @@ -10594,6 +10597,27 @@
],
"type": "registry:block"
},
{
"categories": [
"tabs",
"scroll area"
],
"description": "Tabs that scroll horizontally when they overflow",
"files": [
{
"path": "registry/default/particles/p-tabs-14.tsx",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full"
},
"name": "p-tabs-14",
"registryDependencies": [
"@coss/tabs"
],
"type": "registry:block"
},
{
"categories": [
"textarea"
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/public/r/tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
"dependencies": [
"@base-ui/react"
],
"registryDependencies": [
"@coss/scroll-area"
],
"files": [
{
"path": "registry/default/ui/tabs.tsx",
"content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport type React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nexport type TabsVariant = \"default\" | \"underline\";\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n <TabsPrimitive.Root\n className={cn(\n \"flex flex-col gap-2 data-[orientation=vertical]:flex-row\",\n className,\n )}\n data-slot=\"tabs\"\n {...props}\n />\n );\n}\n\nexport function TabsList({\n variant = \"default\",\n className,\n children,\n ...props\n}: TabsPrimitive.List.Props & {\n variant?: TabsVariant;\n}): React.ReactElement {\n return (\n <TabsPrimitive.List\n className={cn(\n \"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground\",\n \"data-[orientation=vertical]:flex-col\",\n variant === \"default\"\n ? \"rounded-lg bg-muted p-0.5 text-muted-foreground/72\"\n : \"data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent\",\n className,\n )}\n data-slot=\"tabs-list\"\n {...props}\n >\n {children}\n <TabsPrimitive.Indicator\n className={cn(\n \"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out\",\n variant === \"underline\"\n ? \"z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px\"\n : \"-z-1 rounded-md bg-background shadow-sm/5 dark:bg-input\",\n )}\n data-slot=\"tab-indicator\"\n />\n </TabsPrimitive.List>\n );\n}\n\nexport function TabsTab({\n className,\n ...props\n}: TabsPrimitive.Tab.Props): React.ReactElement {\n return (\n <TabsPrimitive.Tab\n className={cn(\n \"relative flex h-9 shrink-0 grow cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-[calc(--spacing(2.5)-1px)] font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:h-8 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0\",\n className,\n )}\n data-slot=\"tabs-tab\"\n {...props}\n />\n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n <TabsPrimitive.Panel\n className={cn(\"flex-1 outline-none\", className)}\n data-slot=\"tabs-content\"\n {...props}\n />\n );\n}\n\nexport { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent };\n",
"content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport type React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\n\nexport type TabsVariant = \"default\" | \"underline\";\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n <TabsPrimitive.Root\n className={cn(\n \"flex flex-col gap-2 data-[orientation=vertical]:flex-row\",\n className,\n )}\n data-slot=\"tabs\"\n {...props}\n />\n );\n}\n\nexport function TabsList({\n variant = \"default\",\n className,\n children,\n scrollable = true,\n scrollFade = false,\n ...props\n}: TabsPrimitive.List.Props & {\n variant?: TabsVariant;\n scrollable?: boolean;\n scrollFade?: boolean;\n}): React.ReactElement {\n const list = (\n <TabsPrimitive.List\n className={cn(\n \"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground\",\n \"data-[orientation=vertical]:flex-col\",\n variant === \"default\"\n ? \"rounded-lg bg-muted p-0.5 text-muted-foreground/72\"\n : \"data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent\",\n className,\n )}\n data-slot=\"tabs-list\"\n {...props}\n >\n {children}\n <TabsPrimitive.Indicator\n className={cn(\n \"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out\",\n variant === \"underline\"\n ? \"z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px\"\n : \"-z-1 rounded-md bg-background shadow-sm/5 dark:bg-input\",\n )}\n data-slot=\"tab-indicator\"\n />\n </TabsPrimitive.List>\n );\n\n if (!scrollable) {\n return list;\n }\n\n return (\n <ScrollArea\n className=\"-m-0.5 size-fit max-h-[calc(100%+--spacing(1))] max-w-[calc(100%+--spacing(1))] **:data-[slot=scroll-area-viewport]:p-0.5\"\n overscrollContain\n scrollFade={scrollFade}\n >\n {list}\n </ScrollArea>\n );\n}\n\nexport function TabsTab({\n className,\n ...props\n}: TabsPrimitive.Tab.Props): React.ReactElement {\n return (\n <TabsPrimitive.Tab\n className={cn(\n \"relative flex h-9 shrink-0 grow cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-[calc(--spacing(2.5)-1px)] font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:h-8 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0\",\n className,\n )}\n data-slot=\"tabs-tab\"\n {...props}\n />\n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n <TabsPrimitive.Panel\n className={cn(\"flex-1 outline-none\", className)}\n data-slot=\"tabs-content\"\n {...props}\n />\n );\n}\n\nexport { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent };\n",
"type": "registry:ui"
}
],
Expand Down
24 changes: 24 additions & 0 deletions apps/ui/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,9 @@
}
],
"name": "tabs",
"registryDependencies": [
"@coss/scroll-area"
],
"type": "registry:ui"
},
{
Expand Down Expand Up @@ -10594,6 +10597,27 @@
],
"type": "registry:block"
},
{
"categories": [
"tabs",
"scroll area"
],
"description": "Tabs that scroll horizontally when they overflow",
"files": [
{
"path": "registry/default/particles/p-tabs-14.tsx",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full"
},
"name": "p-tabs-14",
"registryDependencies": [
"@coss/tabs"
],
"type": "registry:block"
},
{
"categories": [
"textarea"
Expand Down
20 changes: 19 additions & 1 deletion apps/ui/registry/__index__.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ export const Index: Record<string, any> = {
name: "tabs",
description: "",
type: "registry:ui",
registryDependencies: undefined,
registryDependencies: ["@coss/scroll-area"],
files: [{
path: "registry/default/ui/tabs.tsx",
type: "registry:ui",
Expand Down Expand Up @@ -9079,6 +9079,24 @@ export const Index: Record<string, any> = {
categories: ["tabs","tooltip"],
meta: undefined,
},
"p-tabs-14": {
name: "p-tabs-14",
description: "Tabs that scroll horizontally when they overflow",
type: "registry:block",
registryDependencies: ["@coss/tabs"],
files: [{
path: "registry/default/particles/p-tabs-14.tsx",
type: "registry:block",
target: ""
}],
component: React.lazy(async () => {
const mod = await import("@/registry/default/particles/p-tabs-14.tsx")
const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name
return { default: mod.default || mod[exportName] }
}),
categories: ["tabs","scroll area"],
meta: {"className":"**:data-[slot=preview]:w-full"},
},
"p-textarea-1": {
name: "p-textarea-1",
description: "Basic textarea",
Expand Down
33 changes: 33 additions & 0 deletions apps/ui/registry/default/particles/p-tabs-14.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/registry/default/ui/tabs";

const tabs = [
{ label: "Overview", value: "tab-1" },
{ label: "Insights", value: "tab-2" },
{ label: "Performance", value: "tab-3" },
{ label: "Members", value: "tab-4" },
{ label: "Integrations", value: "tab-5" },
{ label: "Billing", value: "tab-6" },
{ label: "Notifications", value: "tab-7" },
{ label: "Audit log", value: "tab-8" },
];

export default function Particle() {
return (
<Tabs defaultValue="tab-1">
<TabsList scrollFade>
{tabs.map((tab) => (
<TabsTab key={tab.value} value={tab.value}>
{tab.label}
</TabsTab>
))}
</TabsList>
{tabs.map((tab) => (
<TabsPanel key={tab.value} value={tab.value}>
<p className="p-4 text-center text-muted-foreground text-xs">
{tab.label} content
</p>
</TabsPanel>
))}
</Tabs>
);
}
21 changes: 20 additions & 1 deletion apps/ui/registry/default/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import type React from "react";
import { cn } from "@/registry/default/lib/utils";
import { ScrollArea } from "@/registry/default/ui/scroll-area";

export type TabsVariant = "default" | "underline";

Expand All @@ -26,11 +27,15 @@ export function TabsList({
variant = "default",
className,
children,
scrollable = true,
scrollFade = false,
...props
}: TabsPrimitive.List.Props & {
variant?: TabsVariant;
scrollable?: boolean;
scrollFade?: boolean;
}): React.ReactElement {
return (
const list = (
<TabsPrimitive.List
className={cn(
"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground",
Expand All @@ -55,6 +60,20 @@ export function TabsList({
/>
</TabsPrimitive.List>
);

if (!scrollable) {
return list;
}

return (
<ScrollArea
className="-m-0.5 size-fit max-h-[calc(100%+--spacing(1))] max-w-[calc(100%+--spacing(1))] **:data-[slot=scroll-area-viewport]:p-0.5"
overscrollContain
scrollFade={scrollFade}
>
{list}
</ScrollArea>
);
}

export function TabsTab({
Expand Down
11 changes: 11 additions & 0 deletions apps/ui/registry/registry-particles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5231,6 +5231,17 @@ export const particles: ParticleItem[] = [
registryDependencies: ["@coss/tabs", "@coss/tooltip"],
type: "registry:block",
},
{
categories: categories("tabs", "scroll area"),
description: "Tabs that scroll horizontally when they overflow",
files: [{ path: "particles/p-tabs-14.tsx", type: "registry:block" }],
meta: {
className: "**:data-[slot=preview]:w-full",
},
name: "p-tabs-14",
registryDependencies: ["@coss/tabs"],
type: "registry:block",
},
{
categories: categories("textarea"),
description: "Basic textarea",
Expand Down
1 change: 1 addition & 0 deletions apps/ui/registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ export const ui: Registry["items"] = [
},
],
name: "tabs",
registryDependencies: ["@coss/scroll-area"],
type: "registry:ui",
},
{
Expand Down
21 changes: 20 additions & 1 deletion packages/ui/src/components/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { ScrollArea } from "@coss/ui/components/scroll-area";
import { cn } from "@coss/ui/lib/utils";
import type React from "react";

Expand All @@ -26,11 +27,15 @@ export function TabsList({
variant = "default",
className,
children,
scrollable = true,
scrollFade = false,
...props
}: TabsPrimitive.List.Props & {
variant?: TabsVariant;
scrollable?: boolean;
scrollFade?: boolean;
}): React.ReactElement {
return (
const list = (
<TabsPrimitive.List
className={cn(
"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground",
Expand All @@ -55,6 +60,20 @@ export function TabsList({
/>
</TabsPrimitive.List>
);

if (!scrollable) {
return list;
}

return (
<ScrollArea
className="-m-0.5 size-fit max-h-[calc(100%+--spacing(1))] max-w-[calc(100%+--spacing(1))] **:data-[slot=scroll-area-viewport]:p-0.5"
overscrollContain
scrollFade={scrollFade}
>
{list}
</ScrollArea>
);
}

export function TabsTab({
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading