Skip to content
Merged
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
86 changes: 86 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,92 @@ title: Changelog
description: Breaking changes, migration guides, and notable updates.
---

## October 20, 2026

### Segmented Control component

**`@coss/segmented-control`** is now a component at **`components/ui/segmented-control.tsx`** instead of a styling library at **`lib/segmented-control.ts`**. It exports **`SegmentedControl`** and **`SegmentedControlItem`**. The element you pass to **`render`** still decides the behavior — a Radio Group, links, or a Toggle Group — so the semantics stay those of the primitive you choose.

```tsx
<SegmentedControl
aria-label="Billing period"
render={<RadioGroupPrimitive defaultValue="monthly" />}
>
<SegmentedControlItem render={<RadioPrimitive.Root value="monthly" />}>
Monthly
</SegmentedControlItem>
<SegmentedControlItem render={<RadioPrimitive.Root value="yearly" />}>
Yearly
</SegmentedControlItem>
</SegmentedControl>
```

The sliding indicator is built in. **`SegmentedControl`** renders it after its children, so there is nothing to add or order by hand, and before hydration the active item paints its own static pill. **`size`** is set once on **`SegmentedControl`** and inherited by its items; an item can still override it. One item recipe now covers **`data-checked`**, **`aria-current="page"`**, and **`data-pressed`**, so there is no **`state`** option.

These exports were removed: **`segmentedControlRootClassName`**, **`segmentedControlItemVariants`**, **`segmentedControlIndicatorClassName`**, and **`SegmentedControlIndicator`**. The **`@coss/segmented-control-indicator`** registry item no longer exists. **`SegmentedControlSize`**, **`segmentedControlItemSizeClassNames`**, **`segmentedControlItemLayoutClassName`**, and **`segmentedControlIndicatorSurfaceClassName`** are still exported, from the component file. Tabs imports them from there, and **`@coss/tabs`** declares the component as a registry dependency.

**Migration:**

Install the component, then update Tabs so it no longer imports from the library:

```bash
npx shadcn@latest add @coss/segmented-control @coss/tabs
```

If you maintain a local Tabs, change its import instead of overwriting it:

```diff
- } from "@/lib/segmented-control"
+ } from "@/components/ui/segmented-control"
```

Replace class-based compositions. Move the root class and the indicator into **`SegmentedControl`**, and each item recipe into **`SegmentedControlItem`**, keeping behavior props on the rendered element:

```diff
- <RadioGroupPrimitive
- aria-label="Billing period"
- className={segmentedControlRootClassName}
- defaultValue="monthly"
- >
- <RadioPrimitive.Root className={itemClassName} value="monthly">
+ <SegmentedControl
+ aria-label="Billing period"
+ render={<RadioGroupPrimitive defaultValue="monthly" />}
+ >
+ <SegmentedControlItem render={<RadioPrimitive.Root value="monthly" />}>
Monthly
- </RadioPrimitive.Root>
- <SegmentedControlIndicator />
- </RadioGroupPrimitive>
+ </SegmentedControlItem>
+ </SegmentedControl>
```

Navigation follows the same shape, with the **`<nav>`** as the rendered root and the link on each item:

```diff
- <nav aria-label="Project sections">
- <div className={segmentedControlRootClassName}>
- <a aria-current="page" className={itemClassName} href="/overview">
- Overview
- </a>
- </div>
- </nav>
+ <SegmentedControl aria-label="Project sections" render={<nav />}>
+ <SegmentedControlItem render={<a aria-current="page" href="/overview" />}>
+ Overview
+ </SegmentedControlItem>
+ </SegmentedControl>
```

Extra classes from **`segmentedControlItemVariants({ className })`** move to **`className`** on **`SegmentedControlItem`**. Once nothing imports **`@/lib/segmented-control`** or **`@/lib/segmented-control-indicator`**, delete both files.

**Agent migration prompt:**

```text
Migrate my codebase from the coss segmented-control styling library to the Segmented Control component. Install the component with `npx shadcn@latest add @coss/segmented-control`; it is written to components/ui/segmented-control.tsx and exports SegmentedControl, SegmentedControlItem, SegmentedControlSize, segmentedControlItemSizeClassNames, segmentedControlItemLayoutClassName, and segmentedControlIndicatorSurfaceClassName. Update the local Tabs component first: either reinstall it with `npx shadcn@latest add @coss/tabs`, or, if it has project-specific changes, only change its segmented-control import from @/lib/segmented-control to @/components/ui/segmented-control. Then find every file that imports segmentedControlRootClassName, segmentedControlItemVariants, segmentedControlIndicatorClassName, or SegmentedControlIndicator. For each composition, replace the element that has segmentedControlRootClassName with <SegmentedControl render={<ThatElement {...itsBehaviorProps} />}>: Radio Group roots become render={<RadioGroupPrimitive defaultValue=… />}, Toggle Group roots become render={<ToggleGroupPrimitive … />}, and a plain wrapper div inside a <nav> collapses so the nav itself is the rendered root via render={<nav />}. Keep accessibility props such as aria-label on SegmentedControl. Replace each item that used segmentedControlItemVariants with <SegmentedControlItem render={<ThatItem {...itsBehaviorProps} />}>, for example render={<RadioPrimitive.Root value="monthly" />} or render={<a href="/overview" aria-current={…} />}; put value, href, onClick, aria-current, and other behavior props on the rendered element, and move any className passed to segmentedControlItemVariants onto SegmentedControlItem. Drop the state option entirely. Move a shared size option to the size prop on SegmentedControl, and keep a per-item size prop only where items genuinely differ. Delete every <SegmentedControlIndicator /> element, because SegmentedControl renders the indicator itself. Do not convert Tabs call sites; Tabs keeps its own component. When nothing imports @/lib/segmented-control or @/lib/segmented-control-indicator any more, delete those files. Run formatting and typechecking, then visually check each segmented control in light and dark mode: the pill should sit exactly on the active item and slide when the selection changes.
```

## October 18, 2026

### Avatar group clipping and inherited sizing
Expand Down
159 changes: 93 additions & 66 deletions apps/ui/content/docs/components/segmented-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: A visual pattern for presenting related choices, navigation destina

## About

A segmented control is a visual pattern, not a standalone behavior. COSS uses the same presentation across several components while preserving the semantics, keyboard interactions, and state model of each underlying primitive.
A segmented control is a visual pattern, not a standalone behavior. `SegmentedControl` and `SegmentedControlItem` provide the presentation and the sliding indicator; the element you pass to `render` provides the semantics, keyboard interactions, and state model.

## Choose the right primitive

Expand All @@ -18,102 +18,129 @@ A segmented control is a visual pattern, not a standalone behavior. COSS uses th
| Apply an exclusive filter or mode | [Toggle Group](/ui/docs/components/toggle-group) | Represents the pressed state of an action that may be cleared. |
| Switch between related panels | [Tabs](/ui/docs/components/tabs) | Connects each tab to an associated content panel. |

Choose the primitive from the interaction first, then apply the segmented-control styling. Visual similarity alone is not a reason to use Tabs or Toggle Group.
Choose the primitive from the interaction first, then render it through the segmented control. Visual similarity alone is not a reason to use Tabs or Toggle Group.

## Installation

Segmented controls are provided as particles. Install the implementation and size that match your interaction:
<CodeTabs>

| Implementation | Small | Default | Large |
| -------------- | ----- | ------- | ----- |
| Radio Group | `@coss/p-radio-group-7` | `@coss/p-radio-group-8` | `@coss/p-radio-group-9` |
| Navigation | `@coss/p-navigation-2` | `@coss/p-navigation-1` | `@coss/p-navigation-3` |

For example, install the default Radio Group version with:
<TabsList>
<TabsTab value="cli">CLI</TabsTab>
<TabsTab value="manual">Manual</TabsTab>
</TabsList>
<TabsPanel value="cli">

```bash
npx shadcn@latest add @coss/p-radio-group-8
npx shadcn@latest add @coss/segmented-control
```

The CLI installs the shared `segmented-control` styling library and the required primitive automatically.
</TabsPanel>

<TabsPanel value="manual">

## Shared styling
<Steps>

For a custom composition, install the styling library directly:
<Step>Install the following dependencies:</Step>

```bash
npx shadcn@latest add @coss/segmented-control
npm install @base-ui/react
```

The library exports a root class, an item recipe, a shared item layout class for icons, and the indicator surface class:
<Step>Copy and paste the following code into your project.</Step>

<ComponentSource
name="segmented-control"
title="components/ui/segmented-control.tsx"
/>

<Step>Update the import paths to match your project setup.</Step>

</Steps>

</TabsPanel>

</CodeTabs>

## Usage

```tsx
import {
segmentedControlItemLayoutClassName,
segmentedControlItemVariants,
segmentedControlRootClassName,
} from "@/lib/segmented-control"
RadioGroupPrimitive,
RadioPrimitive,
} from "@/components/ui/radio-group"
import {
SegmentedControl,
SegmentedControlItem,
} from "@/components/ui/segmented-control"
```

```tsx
const itemClassName = segmentedControlItemVariants({
size: "default",
state: "checked",
})
<SegmentedControl
aria-label="Billing period"
render={<RadioGroupPrimitive defaultValue="monthly" />}
>
<SegmentedControlItem render={<RadioPrimitive.Root value="monthly" />}>
Monthly
</SegmentedControlItem>
<SegmentedControlItem render={<RadioPrimitive.Root value="yearly" />}>
Yearly
</SegmentedControlItem>
</SegmentedControl>
```

| Option | Values | Description |
| ------ | ------ | ----------- |
| `size` | `"sm" \| "default" \| "lg"` | Controls item height and horizontal padding. |
| `state` | `"checked" \| "current" \| "pressed"` | Selects the state attribute used by the underlying element. |
Put behavior props such as `defaultValue`, `value`, `onValueChange`, or `href` on the element passed to `render`. Use the Radio Group primitives rather than `RadioGroup` and `Radio`, so the native radio indicator is not rendered.

Use `checked` with Radio Group, `current` with navigation links, and `pressed` with Toggle Group. Tabs do not use the shared state recipe; they reuse `segmentedControlItemLayoutClassName` so icons match the other segmented implementations.
The same composition works for navigation links:

At the outside edges, the item padding and the surface’s `p-0.5` inset combine to match the horizontal padding of the corresponding Button size. The outer segmented surface is slightly taller than that Button to optically balance its inset selected item when the controls appear next to each other.
```tsx
<SegmentedControl aria-label="Project sections" render={<nav />}>
<SegmentedControlItem render={<a aria-current="page" href="/overview" />}>
Overview
</SegmentedControlItem>
<SegmentedControlItem render={<a href="/activity" />}>
Activity
</SegmentedControlItem>
</SegmentedControl>
```

<ComponentSource
name="segmented-control"
title="lib/segmented-control.ts"
/>
And for an exclusive filter that may be cleared:

### Indicator
```tsx
<SegmentedControl render={<ToggleGroupPrimitive aria-label="Status" />}>
<SegmentedControlItem render={<TogglePrimitive value="open" />}>
Open
</SegmentedControlItem>
<SegmentedControlItem render={<TogglePrimitive value="closed" />}>
Closed
</SegmentedControlItem>
</SegmentedControl>
```

The item recipe paints a static pill on the active item. To animate the selection, add the indicator, a separate element that slides to the active item:
## API Reference

```bash
npx shadcn@latest add @coss/segmented-control-indicator
```
### SegmentedControl

```tsx
import { SegmentedControlIndicator } from "@/lib/segmented-control-indicator"
```
The segmented surface. Renders a `<div>` by default and appends the sliding indicator after its children. Provides the size to its items via context.

Render `SegmentedControlIndicator` as the last child of the element with `segmentedControlRootClassName`:
| Prop | Type | Default | Description |
| -------- | --------------------------- | ----------- | --------------------------------------------------------------------------- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Controls the height and horizontal padding of every item |
| `render` | `ReactElement \| function` | — | Replaces the rendered element, for example with a Radio Group primitive or `<nav />` |

```tsx
<RadioGroupPrimitive className={segmentedControlRootClassName}>
<RadioPrimitive.Root className={itemClassName} value="monthly">
Monthly
</RadioPrimitive.Root>
<RadioPrimitive.Root className={itemClassName} value="yearly">
Yearly
</RadioPrimitive.Root>
<SegmentedControlIndicator />
</RadioGroupPrimitive>
```
### SegmentedControlItem

The indicator follows whichever direct child has `data-checked`, `aria-current="page"`, or `data-pressed`. It moves with a 200ms ease-in-out transition, stays still when the user prefers reduced motion, and is hidden when no item is active. While it is visible, the items drop their static pill; before hydration or without JavaScript, the static pill shows the selection. For navigation, the pill animates only when the root persists across route changes, such as in a shared layout; otherwise it appears in place on each page.
A single segment. Renders a `<button type="button">` by default.

Tabs use Base UI’s `Tabs.Indicator` instead of this component, but share its surface through `segmentedControlIndicatorSurfaceClassName` so both pills look the same.
| Prop | Type | Default | Description |
| -------- | --------------------------- | ------------------- | ----------------------------------------------------------------- |
| `size` | `"sm" \| "default" \| "lg"` | Inherited from root | Overrides the size inherited from `SegmentedControl` |
| `render` | `ReactElement \| function` | — | Replaces the rendered element, for example with a radio or a link |

<ComponentSource
name="segmented-control-indicator"
title="lib/segmented-control-indicator.tsx"
/>
An item is active when it has `data-checked` (Radio Group), `aria-current="page"` (links), or `data-pressed` (Toggle Group). The indicator slides to the active item over 200ms with an ease-in-out curve, stays still when the user prefers reduced motion, and is hidden when no item is active. Before hydration or without JavaScript, the active item paints its own static pill instead. For navigation, the pill animates only when the control persists across route changes, such as in a shared layout; otherwise it appears in place on each page.

## Radio options
At the outside edges, the item padding and the surface’s inset combine to match the horizontal padding of the corresponding Button size. The surface is slightly taller than that Button to optically balance its inset selected item when the controls appear next to each other.

Use Radio Group when the selected segment represents a mutually exclusive value, especially in forms.
## Examples

### Small Radio Group

Expand All @@ -127,10 +154,6 @@ Use Radio Group when the selected segment represents a mutually exclusive value,

<ComponentPreview name="p-radio-group-9" />

## Navigation

Use links when each segment points to a different destination. Apply `aria-current="page"` to the active link.

### Small Navigation

<ComponentPreview name="p-navigation-2" />
Expand All @@ -145,6 +168,10 @@ Use links when each segment points to a different destination. Apply `aria-curre

## Related content

Use Tabs when each segment controls an associated content panel. Tabs share the visual language of segmented controls, including the sliding indicator, and add orientation support and panel semantics.
Use Tabs when each segment controls an associated content panel. Tabs keep Base UI’s own indicator and add orientation support and panel semantics, but share the segmented control’s sizes, icon layout, and indicator surface so both look the same.

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

## Changelog

- [Oct 20, 2026](/ui/docs/changelog#segmented-control-component) — Segmented control is now a component with a built-in sliding indicator
18 changes: 13 additions & 5 deletions apps/ui/content/docs/components/tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ links:
npx shadcn@latest add @coss/tabs
```

The CLI installs the shared `@coss/segmented-control` registry dependency automatically.
The CLI installs the `@coss/segmented-control` component automatically, because Tabs shares its sizes, icon layout, and indicator surface.

</TabsPanel>

Expand All @@ -33,12 +33,15 @@ The CLI installs the shared `@coss/segmented-control` registry dependency automa
<Step>Install the following dependencies:</Step>

```bash
npm install @base-ui/react class-variance-authority
npm install @base-ui/react
```

<Step>Copy the shared segmented control styling into your project.</Step>
<Step>Copy the Segmented Control component into your project.</Step>

<ComponentSource name="segmented-control" title="lib/segmented-control.ts" />
<ComponentSource
name="segmented-control"
title="components/ui/segmented-control.tsx"
/>

<Step>Copy and paste the Tabs component into your project.</Step>

Expand Down Expand Up @@ -100,7 +103,7 @@ Visual indicator for the active tab. Styled wrapper for `Tabs.Indicator` from Ba

## Examples

For guidance on choosing between Tabs, Radio Group, Toggle Group, and navigation links, see the [Segmented Control](/ui/docs/components/segmented-control) pattern.
For guidance on choosing between Tabs, Radio Group, Toggle Group, and navigation links, see [Segmented Control](/ui/docs/components/segmented-control).

Tabs use the same optical sizing as segmented controls. The default surface is slightly taller than a Button of the same size so its inset active indicator remains visually balanced alongside adjacent controls.

Expand All @@ -123,3 +126,8 @@ Tabs use the same optical sizing as segmented controls. The default surface is s
### Underline with Vertical Orientation

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

## Changelog

- [Oct 20, 2026](/ui/docs/changelog#segmented-control-component) — Shared sizing and indicator surface now come from `components/ui/segmented-control.tsx`
- [Aug 19, 2026](/ui/docs/changelog#tabs-sizing-and-segmented-control-styles) — `TabsList` `size` prop and segmented control sizing
Loading
Loading