From 2691ec8d9691fa809696506ecb5a0e37224f4d91 Mon Sep 17 00:00:00 2001 From: Matthew Litherland Date: Tue, 20 Oct 2026 12:16:47 +0000 Subject: [PATCH 1/3] feat(segmented-control): animated selection indicator like Tabs Add SegmentedControlIndicator (@coss/segmented-control-indicator), which tracks the checked, current, or pressed item and slides a shared pill to it. Item state recipes now only set the active text color, and Tabs share the pill surface class. Radio group and navigation particles render the indicator, and navigation particles track the clicked link. Co-Authored-By: Claude Opus 5.5 --- .../docs/components/segmented-control.mdx | 45 ++++++- apps/ui/public/r/p-navigation-1.json | 5 +- apps/ui/public/r/p-navigation-2.json | 5 +- apps/ui/public/r/p-navigation-3.json | 5 +- apps/ui/public/r/p-radio-group-7.json | 5 +- apps/ui/public/r/p-radio-group-8.json | 5 +- apps/ui/public/r/p-radio-group-9.json | 5 +- apps/ui/public/r/registry.json | 32 ++++- .../public/r/segmented-control-indicator.json | 16 +++ apps/ui/public/r/segmented-control.json | 2 +- apps/ui/public/r/tabs.json | 2 +- apps/ui/registry.json | 32 ++++- apps/ui/registry/__index__.tsx | 30 ++++- .../lib/segmented-control-indicator.tsx | 113 ++++++++++++++++++ .../registry/default/lib/segmented-control.ts | 16 +-- .../default/particles/p-navigation-1.tsx | 36 ++++-- .../default/particles/p-navigation-2.tsx | 36 ++++-- .../default/particles/p-navigation-3.tsx | 36 ++++-- .../default/particles/p-radio-group-7.tsx | 2 + .../default/particles/p-radio-group-8.tsx | 2 + .../default/particles/p-radio-group-9.tsx | 2 + apps/ui/registry/default/ui/tabs.tsx | 3 +- apps/ui/registry/registry-lib.ts | 11 ++ apps/ui/registry/registry-particles.ts | 33 ++++- .../coss/references/segmented-control.md | 22 +++- packages/ui/src/components/tabs.tsx | 3 +- .../src/lib/segmented-control-indicator.tsx | 113 ++++++++++++++++++ packages/ui/src/lib/segmented-control.ts | 16 +-- 28 files changed, 543 insertions(+), 90 deletions(-) create mode 100644 apps/ui/public/r/segmented-control-indicator.json create mode 100644 apps/ui/registry/default/lib/segmented-control-indicator.tsx create mode 100644 packages/ui/src/lib/segmented-control-indicator.tsx diff --git a/apps/ui/content/docs/components/segmented-control.mdx b/apps/ui/content/docs/components/segmented-control.mdx index 0d3905d51..2dcd4d9a6 100644 --- a/apps/ui/content/docs/components/segmented-control.mdx +++ b/apps/ui/content/docs/components/segmented-control.mdx @@ -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 { @@ -65,17 +65,52 @@ const itemClassName = segmentedControlItemVariants({ | 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. | +| `state` | `"checked" \| "current" \| "pressed"` | Selects the state attribute that sets the active item’s text color. | -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. +### Indicator + +The item recipe does not paint the active background. The selection pill is a separate element that slides to the active item, so a custom composition must render it: + +```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 + + + Monthly + + + Yearly + + + +``` + +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. 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. + + + ## Radio options Use Radio Group when the selected segment represents a mutually exclusive value, especially in forms. @@ -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. diff --git a/apps/ui/public/r/p-navigation-1.json b/apps/ui/public/r/p-navigation-1.json index 256c65ca6..40aa5a05a 100644 --- a/apps/ui/public/r/p-navigation-1.json +++ b/apps/ui/public/r/p-navigation-1.json @@ -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 \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(links[0].href);\n\n return (\n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-navigation-2.json b/apps/ui/public/r/p-navigation-2.json index 155ad9c63..204d4c86b 100644 --- a/apps/ui/public/r/p-navigation-2.json +++ b/apps/ui/public/r/p-navigation-2.json @@ -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 \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(links[0].href);\n\n return (\n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-navigation-3.json b/apps/ui/public/r/p-navigation-3.json index bc2a61eb7..c0eb68f18 100644 --- a/apps/ui/public/r/p-navigation-3.json +++ b/apps/ui/public/r/p-navigation-3.json @@ -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 \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(links[0].href);\n\n return (\n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-radio-group-7.json b/apps/ui/public/r/p-radio-group-7.json index be08b123d..9e4d4da61 100644 --- a/apps/ui/public/r/p-radio-group-7.json +++ b/apps/ui/public/r/p-radio-group-7.json @@ -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 \n \n Monthly\n \n \n Yearly\n \n \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 \n \n Monthly\n \n \n Yearly\n \n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-radio-group-8.json b/apps/ui/public/r/p-radio-group-8.json index 5294b70e9..fd590ee6d 100644 --- a/apps/ui/public/r/p-radio-group-8.json +++ b/apps/ui/public/r/p-radio-group-8.json @@ -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 \n \n Monthly\n \n \n Yearly\n \n \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 \n \n Monthly\n \n \n Yearly\n \n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-radio-group-9.json b/apps/ui/public/r/p-radio-group-9.json index f81f3f5e5..b254ed7be 100644 --- a/apps/ui/public/r/p-radio-group-9.json +++ b/apps/ui/public/r/p-radio-group-9.json @@ -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 \n \n Monthly\n \n \n Yearly\n \n \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 \n \n Monthly\n \n \n Yearly\n \n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 42fcf51e7..dc89d9872 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -9011,7 +9011,8 @@ ], "name": "p-navigation-1", "registryDependencies": [ - "@coss/segmented-control" + "@coss/segmented-control", + "@coss/segmented-control-indicator" ], "type": "registry:block" }, @@ -9029,7 +9030,8 @@ ], "name": "p-navigation-2", "registryDependencies": [ - "@coss/segmented-control" + "@coss/segmented-control", + "@coss/segmented-control-indicator" ], "type": "registry:block" }, @@ -9047,7 +9049,8 @@ ], "name": "p-navigation-3", "registryDependencies": [ - "@coss/segmented-control" + "@coss/segmented-control", + "@coss/segmented-control-indicator" ], "type": "registry:block" }, @@ -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" }, @@ -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" }, @@ -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" }, @@ -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", diff --git a/apps/ui/public/r/segmented-control-indicator.json b/apps/ui/public/r/segmented-control-indicator.json new file mode 100644 index 000000000..84fd6d1f5 --- /dev/null +++ b/apps/ui/public/r/segmented-control-indicator.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "segmented-control-indicator", + "registryDependencies": [ + "@coss/segmented-control", + "@coss/utils" + ], + "files": [ + { + "path": "registry/default/lib/segmented-control-indicator.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { segmentedControlIndicatorClassName } from \"@/registry/default/lib/segmented-control\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nconst activeItemSelector =\n ':scope > [data-checked], :scope > [aria-current=\"page\"], :scope > [data-pressed]';\n\nconst observedAttributes = [\"data-checked\", \"aria-current\", \"data-pressed\"];\n\ninterface ActiveItemRect {\n height: number;\n left: number;\n top: number;\n width: number;\n}\n\nfunction isSameRect(\n a: ActiveItemRect | null,\n b: ActiveItemRect | null,\n): boolean {\n return (\n a?.left === b?.left &&\n a?.top === b?.top &&\n a?.width === b?.width &&\n a?.height === b?.height\n );\n}\n\n/**\n * Animated selection pill for a segmented control. Render it as the last child\n * of the element that has `segmentedControlRootClassName`; it follows whichever\n * direct child is checked, current, or pressed.\n */\nexport function SegmentedControlIndicator({\n className,\n style,\n ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n const ref = React.useRef(null);\n const [rect, setRect] = React.useState(null);\n\n React.useLayoutEffect(() => {\n const root = ref.current?.parentElement;\n if (!root) {\n return;\n }\n\n const update = () => {\n const item = root.querySelector(activeItemSelector);\n const next: ActiveItemRect | null = item\n ? {\n height: item.offsetHeight,\n left: item.offsetLeft,\n top: item.offsetTop,\n width: item.offsetWidth,\n }\n : null;\n setRect((previous) => (isSameRect(previous, next) ? previous : next));\n };\n\n update();\n\n const resizeObserver = new ResizeObserver(update);\n const observeItems = () => {\n resizeObserver.disconnect();\n resizeObserver.observe(root);\n for (const child of root.children) {\n resizeObserver.observe(child);\n }\n };\n observeItems();\n\n const mutationObserver = new MutationObserver((records) => {\n if (records.some((record) => record.type === \"childList\")) {\n observeItems();\n }\n update();\n });\n mutationObserver.observe(root, {\n attributeFilter: observedAttributes,\n attributes: true,\n childList: true,\n subtree: true,\n });\n\n return () => {\n resizeObserver.disconnect();\n mutationObserver.disconnect();\n };\n }, []);\n\n return (\n