` instead of rendering it as the root.
+- Adding a separate indicator element; `SegmentedControl` already renders one.
+- Importing from the removed `lib/segmented-control.ts` library; see the Oct 20, 2026 changelog entry for the migration.
+
+## Useful particle references
+
+- radio group, small/default/large: `p-radio-group-7`, `p-radio-group-8`, `p-radio-group-9`
+- navigation, small/default/large: `p-navigation-2`, `p-navigation-1`, `p-navigation-3`
diff --git a/apps/ui/skills/coss/references/primitives/tabs.md b/apps/ui/skills/coss/references/primitives/tabs.md
index 350a852c7..0ddee55b9 100644
--- a/apps/ui/skills/coss/references/primitives/tabs.md
+++ b/apps/ui/skills/coss/references/primitives/tabs.md
@@ -14,10 +14,10 @@ npx shadcn@latest add @coss/tabs
Manual deps from docs:
```bash
-npm install @base-ui/react class-variance-authority
+npm install @base-ui/react
```
-The CLI installs the shared `@coss/segmented-control` registry dependency automatically. For a manual installation, also copy `lib/segmented-control.ts` before copying the Tabs component.
+The CLI installs the `@coss/segmented-control` component automatically. For a manual installation, also copy `components/ui/segmented-control.tsx` before copying the Tabs component.
## Canonical imports
@@ -90,7 +90,7 @@ Underline variant:
- Mismatching `TabsTab value` and `TabsPanel value` pairs.
- Using tabs for workflows that require route-level navigation instead.
-- Using tabs for a form value or reversible filter just because it looks like a segmented control. Read `../segmented-control.md` and choose semantics first.
+- Using tabs for a form value or reversible filter just because it looks like a segmented control. Read `./segmented-control.md` and choose semantics first.
- Mounting expensive panel content without considering visibility/performance.
## Useful particle references
diff --git a/apps/ui/skills/coss/references/primitives/toggle-group.md b/apps/ui/skills/coss/references/primitives/toggle-group.md
index a9a5cf9dc..585c23b81 100644
--- a/apps/ui/skills/coss/references/primitives/toggle-group.md
+++ b/apps/ui/skills/coss/references/primitives/toggle-group.md
@@ -105,7 +105,7 @@ function ControlledToggleGroup() {
### More examples
-See `p-toggle-group-1` through `p-toggle-group-9` for sizes, outline, vertical, disabled, multiple, and tooltip patterns. Read `../segmented-control.md` before applying segmented styling to a custom reversible mode.
+See `p-toggle-group-1` through `p-toggle-group-9` for sizes, outline, vertical, disabled, multiple, and tooltip patterns. Read `./segmented-control.md` before rendering a reversible mode as a segmented control.
## Common pitfalls
diff --git a/apps/ui/skills/coss/references/segmented-control.md b/apps/ui/skills/coss/references/segmented-control.md
deleted file mode 100644
index 048458ab3..000000000
--- a/apps/ui/skills/coss/references/segmented-control.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# coss Segmented Control Pattern
-
-A segmented control is a shared visual treatment, not a standalone behavior. Choose the underlying primitive from the interaction before applying the styling.
-
-## Choose the behavior
-
-| Intent | Use | State model |
-| --- | --- | --- |
-| Choose one value, especially in a form | Radio Group | Exactly one mutually exclusive value |
-| Navigate to another URL or route | Links | Browser navigation with `aria-current="page"` on the current link |
-| Apply an exclusive filter or reversible mode | Toggle Group | At most one pressed item by default; the current item may be cleared |
-| Switch between associated content panels | Tabs | Each tab controls a matching panel |
-
-Do not use Tabs for navigation links, form values, or filters merely because the result should look segmented. Do not use Toggle Group when one value must always remain selected; use Radio Group instead.
-
-## Install a particle
-
-| Implementation | Small | Default | Large |
-| --- | --- | --- | --- |
-| 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, 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.
-
-## Custom composition
-
-Install the styling library directly when none of the particles matches:
-
-```bash
-npx shadcn@latest add @coss/segmented-control @coss/segmented-control-indicator
-```
-
-```tsx
-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:
-
-```tsx
-const itemClassName = segmentedControlItemVariants({
- size: "default",
- state: "checked",
-})
-```
-
-- `state: "checked"` for Radio Group
-- `state: "current"` for navigation links
-- `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 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, 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/segmented-control.tsx b/packages/ui/src/components/segmented-control.tsx
new file mode 100644
index 000000000..245ffe718
--- /dev/null
+++ b/packages/ui/src/components/segmented-control.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cn } from "@coss/ui/lib/utils";
+import * as React from "react";
+
+export type SegmentedControlSize = "default" | "lg" | "sm";
+
+export const segmentedControlItemSizeClassNames: Record<
+ SegmentedControlSize,
+ string
+> = {
+ default: "h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5",
+ lg: "h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5",
+ sm: "h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5",
+};
+
+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 segmentedControlIndicatorSurfaceClassName =
+ "rounded-md bg-background shadow-sm/5 dark:bg-input";
+
+const SegmentedControlContext: React.Context
=
+ React.createContext("default");
+
+export function SegmentedControl({
+ className,
+ children,
+ render,
+ size = "default",
+ ...props
+}: useRender.ComponentProps<"div"> & {
+ size?: SegmentedControlSize;
+}): React.ReactElement {
+ const defaultProps = {
+ children: (
+ <>
+ {children}
+
+ >
+ ),
+ className: cn(
+ "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5",
+ className,
+ ),
+ "data-size": size,
+ "data-slot": "segmented-control",
+ };
+
+ const element = useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+
+ return (
+
+ {element}
+
+ );
+}
+
+export function SegmentedControlItem({
+ className,
+ render,
+ size,
+ ...props
+}: useRender.ComponentProps<"button"> & {
+ size?: SegmentedControlSize;
+}): React.ReactElement {
+ const contextSize = React.useContext(SegmentedControlContext);
+ const resolvedSize = size ?? contextSize;
+
+ const defaultProps = {
+ className: cn(
+ "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",
+ "data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input",
+ "aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input",
+ "data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input",
+ "[:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:bg-transparent! [:has(>[data-slot=segmented-control-indicator]:not([hidden]))>&]:shadow-none!",
+ segmentedControlItemLayoutClassName,
+ segmentedControlItemSizeClassNames[resolvedSize],
+ className,
+ ),
+ "data-size": resolvedSize,
+ "data-slot": "segmented-control-item",
+ ...(render ? {} : { type: "button" as const }),
+ };
+
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(defaultProps, props),
+ render,
+ });
+}
+
+const activeItemSelector =
+ ':scope > [data-checked], :scope > [aria-current="page"], :scope > [data-pressed]';
+
+const observedAttributes: string[] = [
+ "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,
+ };
+}
+
+function SegmentedControlIndicator(): React.ReactElement {
+ const ref = React.useRef(null);
+ const [rect, setRect] = React.useState(null);
+
+ React.useLayoutEffect(() => {
+ const root = ref.current?.parentElement;
+ if (!root) {
+ return;
+ }
+
+ const update = (): void => {
+ 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 = (): void => {
+ 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/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index 3683bc6a0..c30e221a4 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -6,7 +6,7 @@ import {
segmentedControlIndicatorSurfaceClassName,
segmentedControlItemLayoutClassName,
segmentedControlItemSizeClassNames,
-} from "@coss/ui/lib/segmented-control";
+} from "@coss/ui/components/segmented-control";
import { cn } from "@coss/ui/lib/utils";
import * as React from "react";
diff --git a/packages/ui/src/lib/segmented-control-indicator.tsx b/packages/ui/src/lib/segmented-control-indicator.tsx
deleted file mode 100644
index ba12337e5..000000000
--- a/packages/ui/src/lib/segmented-control-indicator.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-"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
deleted file mode 100644
index a6b20d276..000000000
--- a/packages/ui/src/lib/segmented-control.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { cva } from "class-variance-authority";
-
-export type SegmentedControlSize = "default" | "lg" | "sm";
-
-export const segmentedControlItemSizeClassNames: Record<
- SegmentedControlSize,
- string
-> = {
- default: "h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5",
- lg: "h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5",
- sm: "h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5",
-};
-
-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-[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,
- ],
- {
- defaultVariants: {
- size: "default",
- },
- variants: {
- size: segmentedControlItemSizeClassNames,
- state: {
- checked:
- "data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input",
- current:
- "aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input",
- pressed:
- "data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input",
- },
- },
- },
-);