About
A segmented control is a visual pattern, not a standalone behavior. COSS uses the same presentation across several components while preserving the semantics, keyboard interactions, and state model of each underlying primitive.
Choose the right primitive
| Intent | Use | Why |
|---|---|---|
| Choose one value in a form | Radio Group | Represents a mutually exclusive value and participates in form state. |
| Navigate to another URL or route | Navigation links | Preserves link behavior, browser history, and aria-current. |
| Apply an exclusive filter or mode | Toggle Group | Represents the pressed state of an action that may be cleared. |
| Switch between related panels | Tabs | Connects each tab to an associated content panel. |
Choose the primitive from the interaction first, then apply the segmented-control styling. Visual similarity alone is not a reason to use Tabs or Toggle Group.
Installation
Segmented controls are provided as particles. Install the implementation and size that match your interaction:
| Implementation | Small | Default | Large |
|---|---|---|---|
| Radio Group | p-radio-group-7 | p-radio-group-8 | p-radio-group-9 |
| Navigation | p-navigation-2 | p-navigation-1 | p-navigation-3 |
For example, install the default Radio Group version with:
pnpm dlx shadcn-svelte@latest add https://coss-sv.vercel.app/r/p-radio-group-8.jsonThe CLI installs the shared segmented-control styling library and the required primitive automatically.
Shared styling
For a custom composition, install the styling library directly:
pnpm dlx shadcn-svelte@latest add https://coss-sv.vercel.app/r/segmented-control.jsonThe library exports a root class, an item recipe, and a shared item layout class for icons:
<script lang="ts">
import {
segmentedControlItemLayoutClassName,
segmentedControlItemVariants,
segmentedControlRootClassName,
} from "$lib/segmented-control.js";
</script><script lang="ts">
const itemClassName = segmentedControlItemVariants({
size: "default",
state: "checked",
});
</script>| Option | Values | Description |
|---|---|---|
size | "sm" \| "default" \| "lg" | Controls item height and horizontal padding. |
state | "checked" \| "current" \| "pressed" | Selects the state attribute used by the underlying element. |
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.
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.
import { type ClassValue, clsx } from "clsx";
export type SegmentedControlSize = "default" | "lg" | "sm";
export type SegmentedControlState = "checked" | "current" | "pressed";
export type SegmentedControlItemVariantOptions = {
className?: ClassValue;
size?: SegmentedControlSize;
state?: SegmentedControlState;
};
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 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";
const segmentedControlItemBaseClassName =
"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";
const segmentedControlItemStateClassNames: Record<SegmentedControlState, string> = {
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",
};
export function segmentedControlItemVariants({
className,
size = "default",
state,
}: SegmentedControlItemVariantOptions = {}): string {
return clsx(
segmentedControlItemBaseClassName,
segmentedControlItemLayoutClassName,
segmentedControlItemSizeClassNames[size],
state ? segmentedControlItemStateClassNames[state] : undefined,
className,
);
}
Radio options
Use Radio Group when the selected segment represents a mutually exclusive value, especially in forms.
Small Radio Group
Default Radio Group
Large Radio Group
Navigation
Use links when each segment points to a different destination. Apply aria-current="page" to the active link.
Small Navigation
Default Navigation
Large Navigation
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.