coss.com Svelte

Segmented Control

A visual pattern for presenting related choices, navigation destinations, filters, or content views.

Loading p-radio-group-8 preview…

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

IntentUseWhy
Choose one value in a formRadio GroupRepresents a mutually exclusive value and participates in form state.
Navigate to another URL or routeNavigation linksPreserves link behavior, browser history, and aria-current.
Apply an exclusive filter or modeToggle GroupRepresents the pressed state of an action that may be cleared.
Switch between related panelsTabsConnects 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:

ImplementationSmallDefaultLarge
Radio Groupp-radio-group-7p-radio-group-8p-radio-group-9
Navigationp-navigation-2p-navigation-1p-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.json

The 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.json

The 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>
OptionValuesDescription
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.

lib/segmented-control.ts
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

Loading p-radio-group-7 preview…

Default Radio Group

Loading p-radio-group-8 preview…

Large Radio Group

Loading p-radio-group-9 preview…

Use links when each segment points to a different destination. Apply aria-current="page" to the active link.

Small Navigation

Loading p-navigation-2 preview…

Default Navigation

Loading p-navigation-1 preview…

Large Navigation

Loading p-navigation-3 preview…

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.

Loading p-tabs-1 preview…