diff --git a/apps/ui/content/docs/components/segmented-control.mdx b/apps/ui/content/docs/components/segmented-control.mdx index 0d3905d51..128b415fe 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 { @@ -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. +### 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 + + + 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. 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. + + + ## 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..f1c2d60c6 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(\"#overview\");\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..7c9f1f953 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(\"#overview\");\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..41eaefa3f 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(\"#overview\");\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..7390ae648 --- /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\nconst MAX_LAYOUT_ROUNDING_ERROR = 2;\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\nfunction getCssSize(element: HTMLElement): { height: number; width: number } {\n const style = getComputedStyle(element);\n const width = Number.parseFloat(style.width) || 0;\n const height = Number.parseFloat(style.height) || 0;\n if (\n Math.round(width) !== element.offsetWidth ||\n Math.round(height) !== element.offsetHeight\n ) {\n return { height: element.offsetHeight, width: element.offsetWidth };\n }\n return { height, width };\n}\n\nfunction measureItem(item: HTMLElement, root: HTMLElement): ActiveItemRect {\n const { width, height } = getCssSize(item);\n const rootSize = getCssSize(root);\n const itemRect = item.getBoundingClientRect();\n const rootRect = root.getBoundingClientRect();\n const scaleX = rootSize.width > 0 ? rootRect.width / rootSize.width : 1;\n const scaleY = rootSize.height > 0 ? rootRect.height / rootSize.height : 1;\n\n const rectLeft =\n (itemRect.left - rootRect.left) / scaleX -\n root.clientLeft +\n root.scrollLeft;\n const rectTop =\n (itemRect.top - rootRect.top) / scaleY - root.clientTop + root.scrollTop;\n\n const isUndistorted =\n Math.abs(rectLeft - item.offsetLeft) <= MAX_LAYOUT_ROUNDING_ERROR &&\n Math.abs(rectTop - item.offsetTop) <= MAX_LAYOUT_ROUNDING_ERROR;\n\n return {\n height,\n left: isUndistorted ? rectLeft : item.offsetLeft,\n top: isUndistorted ? rectTop : item.offsetTop,\n width,\n };\n}\n\nexport function SegmentedControlIndicator({\n className,\n ref: refProp,\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 const mergedRef = React.useCallback(\n (node: HTMLSpanElement | null) => {\n ref.current = node;\n if (typeof refProp === \"function\") {\n return refProp(node);\n }\n if (refProp) {\n refProp.current = node;\n }\n },\n [refProp],\n );\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 = item ? measureItem(item, root) : null;\n setRect((previous) => (isSameRect(previous, next) ? previous : next));\n };\n\n update();\n\n const resizeObserver = new ResizeObserver(update);\n const mutationObserver = new MutationObserver((records) => {\n if (records.some((record) => record.type === \"childList\")) {\n observeItems();\n }\n update();\n });\n const observeItems = () => {\n resizeObserver.disconnect();\n resizeObserver.observe(root);\n for (const child of root.children) {\n resizeObserver.observe(child);\n mutationObserver.observe(child, {\n attributeFilter: observedAttributes,\n attributes: true,\n });\n }\n };\n mutationObserver.observe(root, { childList: true });\n observeItems();\n\n return () => {\n resizeObserver.disconnect();\n mutationObserver.disconnect();\n };\n }, []);\n\n return (\n