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
43 changes: 39 additions & 4 deletions apps/ui/content/docs/components/segmented-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ For a custom composition, install the styling library directly:
npx shadcn@latest add @coss/segmented-control
```

The library exports a root class, an item recipe, and a shared item layout class for icons:
The library exports a root class, an item recipe, a shared item layout class for icons, and the indicator surface class:

```tsx
import {
Expand All @@ -67,15 +67,50 @@ const itemClassName = segmentedControlItemVariants({
| `size` | `"sm" \| "default" \| "lg"` | Controls item height and horizontal padding. |
| `state` | `"checked" \| "current" \| "pressed"` | Selects the state attribute used by the underlying element. |

Use `checked` with Radio Group, `current` with navigation links, and `pressed` with Toggle Group. Tabs retain their own animated indicator and do not use the shared state recipe. They reuse `segmentedControlItemLayoutClassName` so icons match the other segmented implementations.
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.

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.
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.

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

### Indicator

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:

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

```tsx
import { SegmentedControlIndicator } from "@/lib/segmented-control-indicator"
```

Render `SegmentedControlIndicator` as the last child of the element with `segmentedControlRootClassName`:

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

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.

Tabs use Base UI’s `Tabs.Indicator` instead of this component, but share its surface through `segmentedControlIndicatorSurfaceClassName` so both pills look the same.

<ComponentSource
name="segmented-control-indicator"
title="lib/segmented-control-indicator.tsx"
/>

## Radio options

Use Radio Group when the selected segment represents a mutually exclusive value, especially in forms.
Expand Down Expand Up @@ -110,6 +145,6 @@ 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 but keep their animated indicator, orientation support, and panel semantics.
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.

<ComponentPreview name="p-tabs-1" />
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-navigation-1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"name": "p-navigation-1",
"description": "Segmented navigation built with links",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-navigation-1.tsx",
"content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({ state: \"current\" });\n\nexport default function Particle() {\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n <a aria-current=\"page\" className={itemClassName} href=\"#overview\">\n Overview\n </a>\n <a className={itemClassName} href=\"#activity\">\n Activity\n </a>\n <a className={itemClassName} href=\"#settings\">\n Settings\n </a>\n </div>\n </nav>\n );\n}\n",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\n\nconst itemClassName = segmentedControlItemVariants({ state: \"current\" });\n\nconst links = [\n { href: \"#overview\", label: \"Overview\" },\n { href: \"#activity\", label: \"Activity\" },\n { href: \"#settings\", label: \"Settings\" },\n];\n\nexport default function Particle() {\n const [current, setCurrent] = useState(\"#overview\");\n\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n {links.map((link) => (\n <a\n aria-current={link.href === current ? \"page\" : undefined}\n className={itemClassName}\n href={link.href}\n key={link.href}\n onClick={() => setCurrent(link.href)}\n >\n {link.label}\n </a>\n ))}\n <SegmentedControlIndicator />\n </div>\n </nav>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-navigation-2.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"name": "p-navigation-2",
"description": "Small segmented navigation built with links",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-navigation-2.tsx",
"content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"sm\",\n state: \"current\",\n});\n\nexport default function Particle() {\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n <a aria-current=\"page\" className={itemClassName} href=\"#overview\">\n Overview\n </a>\n <a className={itemClassName} href=\"#activity\">\n Activity\n </a>\n <a className={itemClassName} href=\"#settings\">\n Settings\n </a>\n </div>\n </nav>\n );\n}\n",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"sm\",\n state: \"current\",\n});\n\nconst links = [\n { href: \"#overview\", label: \"Overview\" },\n { href: \"#activity\", label: \"Activity\" },\n { href: \"#settings\", label: \"Settings\" },\n];\n\nexport default function Particle() {\n const [current, setCurrent] = useState(\"#overview\");\n\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n {links.map((link) => (\n <a\n aria-current={link.href === current ? \"page\" : undefined}\n className={itemClassName}\n href={link.href}\n key={link.href}\n onClick={() => setCurrent(link.href)}\n >\n {link.label}\n </a>\n ))}\n <SegmentedControlIndicator />\n </div>\n </nav>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-navigation-3.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"name": "p-navigation-3",
"description": "Large segmented navigation built with links",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-navigation-3.tsx",
"content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"lg\",\n state: \"current\",\n});\n\nexport default function Particle() {\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n <a aria-current=\"page\" className={itemClassName} href=\"#overview\">\n Overview\n </a>\n <a className={itemClassName} href=\"#activity\">\n Activity\n </a>\n <a className={itemClassName} href=\"#settings\">\n Settings\n </a>\n </div>\n </nav>\n );\n}\n",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"lg\",\n state: \"current\",\n});\n\nconst links = [\n { href: \"#overview\", label: \"Overview\" },\n { href: \"#activity\", label: \"Activity\" },\n { href: \"#settings\", label: \"Settings\" },\n];\n\nexport default function Particle() {\n const [current, setCurrent] = useState(\"#overview\");\n\n return (\n <nav aria-label=\"Project sections\">\n <div className={segmentedControlRootClassName}>\n {links.map((link) => (\n <a\n aria-current={link.href === current ? \"page\" : undefined}\n className={itemClassName}\n href={link.href}\n key={link.href}\n onClick={() => setCurrent(link.href)}\n >\n {link.label}\n </a>\n ))}\n <SegmentedControlIndicator />\n </div>\n </nav>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-radio-group-7.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"description": "Small segmented control built with a radio group",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-radio-group-7.tsx",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"sm\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n </RadioGroupPrimitive>\n );\n}\n",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"sm\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n <SegmentedControlIndicator />\n </RadioGroupPrimitive>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-radio-group-8.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"description": "Segmented control built with a radio group",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-radio-group-8.tsx",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n </RadioGroupPrimitive>\n );\n}\n",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n <SegmentedControlIndicator />\n </RadioGroupPrimitive>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/public/r/p-radio-group-9.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"description": "Large segmented control built with a radio group",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"files": [
{
"path": "registry/default/particles/p-radio-group-9.tsx",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"lg\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n </RadioGroupPrimitive>\n );\n}\n",
"content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport { SegmentedControlIndicator } from \"@/registry/default/lib/segmented-control-indicator\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"lg\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n <RadioGroupPrimitive\n aria-label=\"Billing period\"\n className={segmentedControlRootClassName}\n defaultValue=\"monthly\"\n >\n <RadioPrimitive.Root className={itemClassName} value=\"monthly\">\n Monthly\n </RadioPrimitive.Root>\n <RadioPrimitive.Root className={itemClassName} value=\"yearly\">\n Yearly\n </RadioPrimitive.Root>\n <SegmentedControlIndicator />\n </RadioGroupPrimitive>\n );\n}\n",
"type": "registry:block"
}
],
Expand Down
32 changes: 26 additions & 6 deletions apps/ui/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -9011,7 +9011,8 @@
],
"name": "p-navigation-1",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand All @@ -9029,7 +9030,8 @@
],
"name": "p-navigation-2",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand All @@ -9047,7 +9049,8 @@
],
"name": "p-navigation-3",
"registryDependencies": [
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand Down Expand Up @@ -9428,7 +9431,8 @@
"name": "p-radio-group-7",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand All @@ -9447,7 +9451,8 @@
"name": "p-radio-group-8",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand All @@ -9466,7 +9471,8 @@
"name": "p-radio-group-9",
"registryDependencies": [
"@coss/radio-group",
"@coss/segmented-control"
"@coss/segmented-control",
"@coss/segmented-control-indicator"
],
"type": "registry:block"
},
Expand Down Expand Up @@ -12714,6 +12720,20 @@
"name": "segmented-control",
"type": "registry:lib"
},
{
"files": [
{
"path": "registry/default/lib/segmented-control-indicator.tsx",
"type": "registry:lib"
}
],
"name": "segmented-control-indicator",
"registryDependencies": [
"@coss/segmented-control",
"@coss/utils"
],
"type": "registry:lib"
},
{
"dependencies": [
"clsx",
Expand Down
Loading
Loading