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 Overview\n \n \n Activity\n \n \n Settings\n \n \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 {links.map((link) => (\n setCurrent(link.href)}\n >\n {link.label}\n \n ))}\n \n \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 Overview\n \n \n Activity\n \n \n Settings\n \n \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 {links.map((link) => (\n setCurrent(link.href)}\n >\n {link.label}\n \n ))}\n \n \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 Overview\n \n \n Activity\n \n \n Settings\n \n \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 {links.map((link) => (\n setCurrent(link.href)}\n >\n {link.label}\n \n ))}\n \n \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 \n );\n}\n", + "type": "registry:lib" + } + ], + "type": "registry:lib" +} \ No newline at end of file diff --git a/apps/ui/public/r/segmented-control.json b/apps/ui/public/r/segmented-control.json index a1f38b4b5..e80891328 100644 --- a/apps/ui/public/r/segmented-control.json +++ b/apps/ui/public/r/segmented-control.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/default/lib/segmented-control.ts", - "content": "import { cva } from \"class-variance-authority\";\n\nexport type SegmentedControlSize = \"default\" | \"lg\" | \"sm\";\n\nexport const segmentedControlItemSizeClassNames: Record<\n SegmentedControlSize,\n string\n> = {\n default: \"h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5\",\n lg: \"h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5\",\n sm: \"h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5\",\n};\n\nexport const segmentedControlRootClassName =\n \"relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5\";\n\nexport const segmentedControlItemLayoutClassName =\n \"gap-1.5 [&_svg:not([class*='opacity-'])]:opacity-80 [&_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\nexport const segmentedControlItemVariants = cva(\n [\n \"relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm\",\n segmentedControlItemLayoutClassName,\n ],\n {\n defaultVariants: {\n size: \"default\",\n },\n variants: {\n size: segmentedControlItemSizeClassNames,\n state: {\n checked:\n \"data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input\",\n current:\n \"aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input\",\n pressed:\n \"data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input\",\n },\n },\n },\n);\n", + "content": "import { cva } from \"class-variance-authority\";\n\nexport type SegmentedControlSize = \"default\" | \"lg\" | \"sm\";\n\nexport const segmentedControlItemSizeClassNames: Record<\n SegmentedControlSize,\n string\n> = {\n default: \"h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5\",\n lg: \"h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5\",\n sm: \"h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5\",\n};\n\nexport const segmentedControlRootClassName =\n \"relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5\";\n\nexport const segmentedControlIndicatorSurfaceClassName =\n \"rounded-md bg-background shadow-sm/5 dark:bg-input\";\n\nexport const segmentedControlIndicatorClassName = `pointer-events-none absolute top-0 left-0 -z-1 h-(--active-item-height) w-(--active-item-width) translate-x-(--active-item-left) translate-y-(--active-item-top) transition-[width,height,translate] duration-200 ease-in-out motion-reduce:transition-none ${segmentedControlIndicatorSurfaceClassName}`;\n\nexport const segmentedControlItemLayoutClassName =\n \"gap-1.5 [&_svg:not([class*='opacity-'])]:opacity-80 [&_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\nexport const segmentedControlItemVariants = cva(\n [\n \"relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[color,outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm\",\n \"[:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:bg-transparent! [:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:shadow-none!\",\n segmentedControlItemLayoutClassName,\n ],\n {\n defaultVariants: {\n size: \"default\",\n },\n variants: {\n size: segmentedControlItemSizeClassNames,\n state: {\n checked:\n \"data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input\",\n current:\n \"aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input\",\n pressed:\n \"data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input\",\n },\n },\n },\n);\n", "type": "registry:lib" } ], diff --git a/apps/ui/public/r/tabs.json b/apps/ui/public/r/tabs.json index 68df32115..7e2a868d1 100644 --- a/apps/ui/public/r/tabs.json +++ b/apps/ui/public/r/tabs.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/default/ui/tabs.tsx", - "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport * as React from \"react\";\nimport {\n type SegmentedControlSize,\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames,\n} from \"@/registry/default/lib/segmented-control\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\ntype TabsVariant = \"default\" | \"underline\";\ntype TabsSize = SegmentedControlSize;\n\nconst TabsListContext: React.Context =\n React.createContext(\"default\");\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport function TabsList({\n variant = \"default\",\n size = \"default\",\n className,\n children,\n ...props\n}: TabsPrimitive.List.Props & {\n size?: TabsSize;\n variant?: TabsVariant;\n}): React.ReactElement {\n return (\n \n \n {children}\n \n \n \n );\n}\n\nexport function TabsTab({\n className,\n size,\n ...props\n}: TabsPrimitive.Tab.Props & {\n size?: TabsSize;\n}): React.ReactElement {\n const contextSize: TabsSize = React.useContext(TabsListContext);\n const resolvedSize: TabsSize = size ?? contextSize;\n\n return (\n \n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport {\n TabsPrimitive,\n TabsTab as TabsTrigger,\n TabsPanel as TabsContent,\n type TabsSize,\n type TabsVariant,\n};\n", + "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport * as React from \"react\";\nimport {\n type SegmentedControlSize,\n segmentedControlIndicatorSurfaceClassName,\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames,\n} from \"@/registry/default/lib/segmented-control\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\ntype TabsVariant = \"default\" | \"underline\";\ntype TabsSize = SegmentedControlSize;\n\nconst TabsListContext: React.Context =\n React.createContext(\"default\");\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport function TabsList({\n variant = \"default\",\n size = \"default\",\n className,\n children,\n ...props\n}: TabsPrimitive.List.Props & {\n size?: TabsSize;\n variant?: TabsVariant;\n}): React.ReactElement {\n return (\n \n \n {children}\n \n \n \n );\n}\n\nexport function TabsTab({\n className,\n size,\n ...props\n}: TabsPrimitive.Tab.Props & {\n size?: TabsSize;\n}): React.ReactElement {\n const contextSize: TabsSize = React.useContext(TabsListContext);\n const resolvedSize: TabsSize = size ?? contextSize;\n\n return (\n \n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport {\n TabsPrimitive,\n TabsTab as TabsTrigger,\n TabsPanel as TabsContent,\n type TabsSize,\n type TabsVariant,\n};\n", "type": "registry:ui" } ], diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 42fcf51e7..dc89d9872 100644 --- a/apps/ui/registry.json +++ b/apps/ui/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/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index e69406f43..36a2cb51a 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -7705,7 +7705,7 @@ export const Index: Record = { name: "p-navigation-1", description: "Segmented navigation built with links", type: "registry:block", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: ["@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-navigation-1.tsx", type: "registry:block", @@ -7723,7 +7723,7 @@ export const Index: Record = { name: "p-navigation-2", description: "Small segmented navigation built with links", type: "registry:block", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: ["@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-navigation-2.tsx", type: "registry:block", @@ -7741,7 +7741,7 @@ export const Index: Record = { name: "p-navigation-3", description: "Large segmented navigation built with links", type: "registry:block", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: ["@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-navigation-3.tsx", type: "registry:block", @@ -8065,7 +8065,7 @@ export const Index: Record = { name: "p-radio-group-7", description: "Small segmented control built with a radio group", type: "registry:block", - registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + registryDependencies: ["@coss/radio-group","@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-radio-group-7.tsx", type: "registry:block", @@ -8083,7 +8083,7 @@ export const Index: Record = { name: "p-radio-group-8", description: "Segmented control built with a radio group", type: "registry:block", - registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + registryDependencies: ["@coss/radio-group","@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-radio-group-8.tsx", type: "registry:block", @@ -8101,7 +8101,7 @@ export const Index: Record = { name: "p-radio-group-9", description: "Large segmented control built with a radio group", type: "registry:block", - registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + registryDependencies: ["@coss/radio-group","@coss/segmented-control","@coss/segmented-control-indicator"], files: [{ path: "registry/default/particles/p-radio-group-9.tsx", type: "registry:block", @@ -10771,6 +10771,24 @@ export const Index: Record = { categories: undefined, meta: undefined, }, + "segmented-control-indicator": { + name: "segmented-control-indicator", + description: "", + type: "registry:lib", + registryDependencies: ["@coss/segmented-control","@coss/utils"], + files: [{ + path: "registry/default/lib/segmented-control-indicator.tsx", + type: "registry:lib", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/lib/segmented-control-indicator.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: undefined, + meta: undefined, + }, "utils": { name: "utils", description: "", diff --git a/apps/ui/registry/default/lib/segmented-control-indicator.tsx b/apps/ui/registry/default/lib/segmented-control-indicator.tsx new file mode 100644 index 000000000..45b427c1b --- /dev/null +++ b/apps/ui/registry/default/lib/segmented-control-indicator.tsx @@ -0,0 +1,155 @@ +"use client"; + +import * as React from "react"; +import { segmentedControlIndicatorClassName } from "@/registry/default/lib/segmented-control"; +import { cn } from "@/registry/default/lib/utils"; + +const activeItemSelector = + ':scope > [data-checked], :scope > [aria-current="page"], :scope > [data-pressed]'; + +const observedAttributes = ["data-checked", "aria-current", "data-pressed"]; + +const MAX_LAYOUT_ROUNDING_ERROR = 2; + +interface ActiveItemRect { + height: number; + left: number; + top: number; + width: number; +} + +function isSameRect( + a: ActiveItemRect | null, + b: ActiveItemRect | null, +): boolean { + return ( + a?.left === b?.left && + a?.top === b?.top && + a?.width === b?.width && + a?.height === b?.height + ); +} + +function getCssSize(element: HTMLElement): { height: number; width: number } { + const style = getComputedStyle(element); + const width = Number.parseFloat(style.width) || 0; + const height = Number.parseFloat(style.height) || 0; + if ( + Math.round(width) !== element.offsetWidth || + Math.round(height) !== element.offsetHeight + ) { + return { height: element.offsetHeight, width: element.offsetWidth }; + } + return { height, width }; +} + +function measureItem(item: HTMLElement, root: HTMLElement): ActiveItemRect { + const { width, height } = getCssSize(item); + const rootSize = getCssSize(root); + const itemRect = item.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + const scaleX = rootSize.width > 0 ? rootRect.width / rootSize.width : 1; + const scaleY = rootSize.height > 0 ? rootRect.height / rootSize.height : 1; + + const rectLeft = + (itemRect.left - rootRect.left) / scaleX - + root.clientLeft + + root.scrollLeft; + const rectTop = + (itemRect.top - rootRect.top) / scaleY - root.clientTop + root.scrollTop; + + const isUndistorted = + Math.abs(rectLeft - item.offsetLeft) <= MAX_LAYOUT_ROUNDING_ERROR && + Math.abs(rectTop - item.offsetTop) <= MAX_LAYOUT_ROUNDING_ERROR; + + return { + height, + left: isUndistorted ? rectLeft : item.offsetLeft, + top: isUndistorted ? rectTop : item.offsetTop, + width, + }; +} + +export function SegmentedControlIndicator({ + className, + ref: refProp, + style, + ...props +}: React.ComponentProps<"span">): React.ReactElement { + const ref = React.useRef(null); + const [rect, setRect] = React.useState(null); + + const mergedRef = React.useCallback( + (node: HTMLSpanElement | null) => { + ref.current = node; + if (typeof refProp === "function") { + return refProp(node); + } + if (refProp) { + refProp.current = node; + } + }, + [refProp], + ); + + React.useLayoutEffect(() => { + const root = ref.current?.parentElement; + if (!root) { + return; + } + + const update = () => { + const item = root.querySelector(activeItemSelector); + const next = item ? measureItem(item, root) : null; + setRect((previous) => (isSameRect(previous, next) ? previous : next)); + }; + + update(); + + const resizeObserver = new ResizeObserver(update); + const mutationObserver = new MutationObserver((records) => { + if (records.some((record) => record.type === "childList")) { + observeItems(); + } + update(); + }); + const observeItems = () => { + resizeObserver.disconnect(); + resizeObserver.observe(root); + for (const child of root.children) { + resizeObserver.observe(child); + mutationObserver.observe(child, { + attributeFilter: observedAttributes, + attributes: true, + }); + } + }; + mutationObserver.observe(root, { childList: true }); + observeItems(); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + return ( + + ); +} diff --git a/apps/ui/registry/default/lib/segmented-control.ts b/apps/ui/registry/default/lib/segmented-control.ts index e70789c82..a6b20d276 100644 --- a/apps/ui/registry/default/lib/segmented-control.ts +++ b/apps/ui/registry/default/lib/segmented-control.ts @@ -14,12 +14,18 @@ export const segmentedControlItemSizeClassNames: Record< export const segmentedControlRootClassName = "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5"; +export const segmentedControlIndicatorSurfaceClassName = + "rounded-md bg-background shadow-sm/5 dark:bg-input"; + +export const segmentedControlIndicatorClassName = `pointer-events-none absolute top-0 left-0 -z-1 h-(--active-item-height) w-(--active-item-width) translate-x-(--active-item-left) translate-y-(--active-item-top) transition-[width,height,translate] duration-200 ease-in-out motion-reduce:transition-none ${segmentedControlIndicatorSurfaceClassName}`; + export const segmentedControlItemLayoutClassName = "gap-1.5 [&_svg:not([class*='opacity-'])]:opacity-80 [&_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"; export const segmentedControlItemVariants = cva( [ - "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[color,outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + "[:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:bg-transparent! [:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:shadow-none!", segmentedControlItemLayoutClassName, ], { diff --git a/apps/ui/registry/default/particles/p-navigation-1.tsx b/apps/ui/registry/default/particles/p-navigation-1.tsx index c5c23baa8..b1c0bf274 100644 --- a/apps/ui/registry/default/particles/p-navigation-1.tsx +++ b/apps/ui/registry/default/particles/p-navigation-1.tsx @@ -1,23 +1,38 @@ +"use client"; + +import { useState } from "react"; import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; const itemClassName = segmentedControlItemVariants({ state: "current" }); +const links = [ + { href: "#overview", label: "Overview" }, + { href: "#activity", label: "Activity" }, + { href: "#settings", label: "Settings" }, +]; + export default function Particle() { + const [current, setCurrent] = useState("#overview"); + return ( - - Overview - - - Activity - - - Settings - + {links.map((link) => ( + setCurrent(link.href)} + > + {link.label} + + ))} + ); diff --git a/apps/ui/registry/default/particles/p-navigation-2.tsx b/apps/ui/registry/default/particles/p-navigation-2.tsx index ccce03d86..a57dd6ac7 100644 --- a/apps/ui/registry/default/particles/p-navigation-2.tsx +++ b/apps/ui/registry/default/particles/p-navigation-2.tsx @@ -1,26 +1,41 @@ +"use client"; + +import { useState } from "react"; import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; const itemClassName = segmentedControlItemVariants({ size: "sm", state: "current", }); +const links = [ + { href: "#overview", label: "Overview" }, + { href: "#activity", label: "Activity" }, + { href: "#settings", label: "Settings" }, +]; + export default function Particle() { + const [current, setCurrent] = useState("#overview"); + return ( - - Overview - - - Activity - - - Settings - + {links.map((link) => ( + setCurrent(link.href)} + > + {link.label} + + ))} + ); diff --git a/apps/ui/registry/default/particles/p-navigation-3.tsx b/apps/ui/registry/default/particles/p-navigation-3.tsx index 53dd2ce37..cd23e3561 100644 --- a/apps/ui/registry/default/particles/p-navigation-3.tsx +++ b/apps/ui/registry/default/particles/p-navigation-3.tsx @@ -1,26 +1,41 @@ +"use client"; + +import { useState } from "react"; import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; const itemClassName = segmentedControlItemVariants({ size: "lg", state: "current", }); +const links = [ + { href: "#overview", label: "Overview" }, + { href: "#activity", label: "Activity" }, + { href: "#settings", label: "Settings" }, +]; + export default function Particle() { + const [current, setCurrent] = useState("#overview"); + return ( - - Overview - - - Activity - - - Settings - + {links.map((link) => ( + setCurrent(link.href)} + > + {link.label} + + ))} + ); diff --git a/apps/ui/registry/default/particles/p-radio-group-7.tsx b/apps/ui/registry/default/particles/p-radio-group-7.tsx index 1e31a259c..0a24b83a8 100644 --- a/apps/ui/registry/default/particles/p-radio-group-7.tsx +++ b/apps/ui/registry/default/particles/p-radio-group-7.tsx @@ -4,6 +4,7 @@ import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; import { RadioGroupPrimitive, RadioPrimitive, @@ -28,6 +29,7 @@ export default function Particle() { Yearly + ); } diff --git a/apps/ui/registry/default/particles/p-radio-group-8.tsx b/apps/ui/registry/default/particles/p-radio-group-8.tsx index 40683b050..aa090c597 100644 --- a/apps/ui/registry/default/particles/p-radio-group-8.tsx +++ b/apps/ui/registry/default/particles/p-radio-group-8.tsx @@ -4,6 +4,7 @@ import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; import { RadioGroupPrimitive, RadioPrimitive, @@ -27,6 +28,7 @@ export default function Particle() { Yearly + ); } diff --git a/apps/ui/registry/default/particles/p-radio-group-9.tsx b/apps/ui/registry/default/particles/p-radio-group-9.tsx index 8c8ddab39..267631bd3 100644 --- a/apps/ui/registry/default/particles/p-radio-group-9.tsx +++ b/apps/ui/registry/default/particles/p-radio-group-9.tsx @@ -4,6 +4,7 @@ import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/registry/default/lib/segmented-control"; +import { SegmentedControlIndicator } from "@/registry/default/lib/segmented-control-indicator"; import { RadioGroupPrimitive, RadioPrimitive, @@ -28,6 +29,7 @@ export default function Particle() { Yearly + ); } diff --git a/apps/ui/registry/default/ui/tabs.tsx b/apps/ui/registry/default/ui/tabs.tsx index e53f93c27..eab656dba 100644 --- a/apps/ui/registry/default/ui/tabs.tsx +++ b/apps/ui/registry/default/ui/tabs.tsx @@ -4,6 +4,7 @@ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import * as React from "react"; import { type SegmentedControlSize, + segmentedControlIndicatorSurfaceClassName, segmentedControlItemLayoutClassName, segmentedControlItemSizeClassNames, } from "@/registry/default/lib/segmented-control"; @@ -60,10 +61,10 @@ export function TabsList({ diff --git a/apps/ui/registry/registry-lib.ts b/apps/ui/registry/registry-lib.ts index 1db027ad6..df6ef4e9b 100644 --- a/apps/ui/registry/registry-lib.ts +++ b/apps/ui/registry/registry-lib.ts @@ -36,6 +36,17 @@ export const lib: Registry["items"] = [ name: "segmented-control", type: "registry:lib", }, + { + files: [ + { + path: "lib/segmented-control-indicator.tsx", + type: "registry:lib", + }, + ], + name: "segmented-control-indicator", + registryDependencies: ["@coss/segmented-control", "@coss/utils"], + type: "registry:lib", + }, { dependencies: ["clsx", "tailwind-merge"], files: [ diff --git a/apps/ui/registry/registry-particles.ts b/apps/ui/registry/registry-particles.ts index 7f7cbcbfe..cc36c29d0 100644 --- a/apps/ui/registry/registry-particles.ts +++ b/apps/ui/registry/registry-particles.ts @@ -4165,7 +4165,10 @@ export const particles: ParticleItem[] = [ description: "Segmented navigation built with links", files: [{ path: "particles/p-navigation-1.tsx", type: "registry:block" }], name: "p-navigation-1", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: [ + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { @@ -4173,7 +4176,10 @@ export const particles: ParticleItem[] = [ description: "Small segmented navigation built with links", files: [{ path: "particles/p-navigation-2.tsx", type: "registry:block" }], name: "p-navigation-2", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: [ + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { @@ -4181,7 +4187,10 @@ export const particles: ParticleItem[] = [ description: "Large segmented navigation built with links", files: [{ path: "particles/p-navigation-3.tsx", type: "registry:block" }], name: "p-navigation-3", - registryDependencies: ["@coss/segmented-control"], + registryDependencies: [ + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { @@ -4381,7 +4390,11 @@ export const particles: ParticleItem[] = [ description: "Small segmented control built with a radio group", files: [{ path: "particles/p-radio-group-7.tsx", type: "registry:block" }], name: "p-radio-group-7", - registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + registryDependencies: [ + "@coss/radio-group", + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { @@ -4389,7 +4402,11 @@ export const particles: ParticleItem[] = [ description: "Segmented control built with a radio group", files: [{ path: "particles/p-radio-group-8.tsx", type: "registry:block" }], name: "p-radio-group-8", - registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + registryDependencies: [ + "@coss/radio-group", + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { @@ -4397,7 +4414,11 @@ export const particles: ParticleItem[] = [ description: "Large segmented control built with a radio group", files: [{ path: "particles/p-radio-group-9.tsx", type: "registry:block" }], name: "p-radio-group-9", - registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + registryDependencies: [ + "@coss/radio-group", + "@coss/segmented-control", + "@coss/segmented-control-indicator", + ], type: "registry:block", }, { diff --git a/apps/ui/skills/coss/references/segmented-control.md b/apps/ui/skills/coss/references/segmented-control.md index e2ce8fc57..048458ab3 100644 --- a/apps/ui/skills/coss/references/segmented-control.md +++ b/apps/ui/skills/coss/references/segmented-control.md @@ -20,7 +20,7 @@ Do not use Tabs for navigation links, form values, or filters merely because the | Radio Group | `@coss/p-radio-group-7` | `@coss/p-radio-group-8` | `@coss/p-radio-group-9` | | Navigation links | `@coss/p-navigation-2` | `@coss/p-navigation-1` | `@coss/p-navigation-3` | -The CLI installs the relevant primitive and the shared `@coss/segmented-control` styling library automatically. +The CLI installs the relevant primitive, the shared `@coss/segmented-control` styling library, and the `@coss/segmented-control-indicator` sliding pill automatically. COSS does not publish a Toggle Group segmented particle. Use the shared styling directly only when a reversible mode genuinely allows no active option. @@ -29,7 +29,7 @@ COSS does not publish a Toggle Group segmented particle. Use the shared styling Install the styling library directly when none of the particles matches: ```bash -npx shadcn@latest add @coss/segmented-control +npx shadcn@latest add @coss/segmented-control @coss/segmented-control-indicator ``` ```tsx @@ -37,6 +37,7 @@ import { segmentedControlItemVariants, segmentedControlRootClassName, } from "@/lib/segmented-control" +import { SegmentedControlIndicator } from "@/lib/segmented-control-indicator" ``` Apply `segmentedControlRootClassName` to the group surface. Generate one item class with the matching state selector: @@ -53,8 +54,21 @@ const itemClassName = segmentedControlItemVariants({ - `state: "pressed"` for Toggle Group - `size: "sm" | "default" | "lg"` +The `state` variants paint a static pill on the active item. Render `` as the last child of the root to animate it; while the indicator is visible, items drop their static pill: + +```tsx + + + Monthly + + + +``` + +The indicator tracks the direct child with `data-checked`, `aria-current="page"`, or `data-pressed`, slides to it (200ms ease-in-out, none under reduced motion), and hides when no item is active. For navigation links, it animates only when the root persists across route changes, such as in a shared layout. + Use the exported Radio Group primitives for a custom segmented radio presentation so the native radio indicator is not rendered. Keep a default or controlled value when the interface requires one option to remain selected. -Tabs do not use the shared state recipe because they retain their animated indicator. They import the shared size map and `segmentedControlItemLayoutClassName`: set `size="sm" | "default" | "lg"` on `TabsList`, and use a `TabsTab` size only as an item-level override. +Tabs do not use the shared state recipe or `SegmentedControlIndicator`; they use Base UI’s `Tabs.Indicator`, styled with the shared `segmentedControlIndicatorSurfaceClassName`. They also import the shared size map and `segmentedControlItemLayoutClassName`: set `size="sm" | "default" | "lg"` on `TabsList`, and use a `TabsTab` size only as an item-level override. -Reuse the shared root, item, and layout recipes instead of copying their classes into a new segmented particle. This keeps Tabs and the radio, navigation, and toggle implementations optically aligned. +Reuse the shared root, item, layout, and indicator recipes instead of copying their classes into a new segmented particle. This keeps Tabs and the radio, navigation, and toggle implementations optically aligned. diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 1e0baefdb..3683bc6a0 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -3,6 +3,7 @@ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import { type SegmentedControlSize, + segmentedControlIndicatorSurfaceClassName, segmentedControlItemLayoutClassName, segmentedControlItemSizeClassNames, } from "@coss/ui/lib/segmented-control"; @@ -60,10 +61,10 @@ export function TabsList({ diff --git a/packages/ui/src/lib/segmented-control-indicator.tsx b/packages/ui/src/lib/segmented-control-indicator.tsx new file mode 100644 index 000000000..ba12337e5 --- /dev/null +++ b/packages/ui/src/lib/segmented-control-indicator.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { segmentedControlIndicatorClassName } from "@coss/ui/lib/segmented-control"; +import { cn } from "@coss/ui/lib/utils"; +import * as React from "react"; + +const activeItemSelector = + ':scope > [data-checked], :scope > [aria-current="page"], :scope > [data-pressed]'; + +const observedAttributes = ["data-checked", "aria-current", "data-pressed"]; + +const MAX_LAYOUT_ROUNDING_ERROR = 2; + +interface ActiveItemRect { + height: number; + left: number; + top: number; + width: number; +} + +function isSameRect( + a: ActiveItemRect | null, + b: ActiveItemRect | null, +): boolean { + return ( + a?.left === b?.left && + a?.top === b?.top && + a?.width === b?.width && + a?.height === b?.height + ); +} + +function getCssSize(element: HTMLElement): { height: number; width: number } { + const style = getComputedStyle(element); + const width = Number.parseFloat(style.width) || 0; + const height = Number.parseFloat(style.height) || 0; + if ( + Math.round(width) !== element.offsetWidth || + Math.round(height) !== element.offsetHeight + ) { + return { height: element.offsetHeight, width: element.offsetWidth }; + } + return { height, width }; +} + +function measureItem(item: HTMLElement, root: HTMLElement): ActiveItemRect { + const { width, height } = getCssSize(item); + const rootSize = getCssSize(root); + const itemRect = item.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + const scaleX = rootSize.width > 0 ? rootRect.width / rootSize.width : 1; + const scaleY = rootSize.height > 0 ? rootRect.height / rootSize.height : 1; + + const rectLeft = + (itemRect.left - rootRect.left) / scaleX - + root.clientLeft + + root.scrollLeft; + const rectTop = + (itemRect.top - rootRect.top) / scaleY - root.clientTop + root.scrollTop; + + const isUndistorted = + Math.abs(rectLeft - item.offsetLeft) <= MAX_LAYOUT_ROUNDING_ERROR && + Math.abs(rectTop - item.offsetTop) <= MAX_LAYOUT_ROUNDING_ERROR; + + return { + height, + left: isUndistorted ? rectLeft : item.offsetLeft, + top: isUndistorted ? rectTop : item.offsetTop, + width, + }; +} + +export function SegmentedControlIndicator({ + className, + ref: refProp, + style, + ...props +}: React.ComponentProps<"span">): React.ReactElement { + const ref = React.useRef(null); + const [rect, setRect] = React.useState(null); + + const mergedRef = React.useCallback( + (node: HTMLSpanElement | null) => { + ref.current = node; + if (typeof refProp === "function") { + return refProp(node); + } + if (refProp) { + refProp.current = node; + } + }, + [refProp], + ); + + React.useLayoutEffect(() => { + const root = ref.current?.parentElement; + if (!root) { + return; + } + + const update = () => { + const item = root.querySelector(activeItemSelector); + const next = item ? measureItem(item, root) : null; + setRect((previous) => (isSameRect(previous, next) ? previous : next)); + }; + + update(); + + const resizeObserver = new ResizeObserver(update); + const mutationObserver = new MutationObserver((records) => { + if (records.some((record) => record.type === "childList")) { + observeItems(); + } + update(); + }); + const observeItems = () => { + resizeObserver.disconnect(); + resizeObserver.observe(root); + for (const child of root.children) { + resizeObserver.observe(child); + mutationObserver.observe(child, { + attributeFilter: observedAttributes, + attributes: true, + }); + } + }; + mutationObserver.observe(root, { childList: true }); + observeItems(); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + return ( + + ); +} diff --git a/packages/ui/src/lib/segmented-control.ts b/packages/ui/src/lib/segmented-control.ts index e70789c82..a6b20d276 100644 --- a/packages/ui/src/lib/segmented-control.ts +++ b/packages/ui/src/lib/segmented-control.ts @@ -14,12 +14,18 @@ export const segmentedControlItemSizeClassNames: Record< export const segmentedControlRootClassName = "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5"; +export const segmentedControlIndicatorSurfaceClassName = + "rounded-md bg-background shadow-sm/5 dark:bg-input"; + +export const segmentedControlIndicatorClassName = `pointer-events-none absolute top-0 left-0 -z-1 h-(--active-item-height) w-(--active-item-width) translate-x-(--active-item-left) translate-y-(--active-item-top) transition-[width,height,translate] duration-200 ease-in-out motion-reduce:transition-none ${segmentedControlIndicatorSurfaceClassName}`; + export const segmentedControlItemLayoutClassName = "gap-1.5 [&_svg:not([class*='opacity-'])]:opacity-80 [&_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"; export const segmentedControlItemVariants = cva( [ - "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[color,outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + "[:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:bg-transparent! [:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:shadow-none!", segmentedControlItemLayoutClassName, ], {