# SegmentedControl

A list of buttons for switching one view mode, with the selected option marked by aria-current.

Version: 0.2.0-preview.1 (preview)
Content digest: 70ac710f4d22dce040e7ac593379ba8aca9dcb59a0b9c0f839819cae0e2bb721
Contract kind: source-derived
Source: packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx

## Guidance

{
  "summary": "A list of buttons for switching one view mode, with the selected option marked by aria-current.",
  "guidance": [
    {
      "heading": "Overview",
      "body": "An iOS-style single-select switch for up to 5 mutually exclusive modes that re-filter one view in\nplace, such as a time range or a chart granularity."
    },
    {
      "heading": "It is a list of buttons",
      "body": "The markup is a `<ul>` of buttons, and the selected one carries `aria-current`. It is deliberately\nnone of the roles it resembles:\n\n-   Not a **radiogroup**. That role needs radio semantics on every option — `role=\"radio\"` with\n    `aria-checked` — plus radio-group arrow-key behavior. These are ordinary buttons.\n-   Not a **tablist**. That role promises content panels and arrow-key traversal.\n-   Not a **toolbar**. That role is a collection of function buttons.\n\nEach of those roles carries its own keyboard and announcement rules, so borrowing the wrong one\nmisleads anyone using assistive technology."
    },
    {
      "heading": "When to use",
      "body": "Reach for `SegmentedControl` when a small, fixed set of options changes how the same content is\nsliced and all options fit inline. Choosing between the neighbours:\n\n-   The options switch between **distinct content panels** rather than re-filtering one view? Use\n    `Tabs`.\n-   The set **grows from data**, wraps, or scrolls? Use a `ChipGroup`. The boundary is the shape of\n    the set: a segmented control is fixed, connected, and equal-width.\n-   A **binary input**, such as on and off? Use `Toggle`.\n-   You want a **labelled form field** with descriptions, or more than 5 options? Use `RadioGroup`.\n-   Too many options to sit inline? Use `Dropdown` + `Menu`."
    },
    {
      "heading": "Counts and dots",
      "body": "An option may carry a trailing `count`, a decorative `dot`, or its own `disabled` state. A count is\ntext, so it is announced with the label. A dot is not, so put the reason it is there into the label\nwhen it carries meaning."
    }
  ],
  "usage": [],
  "examples": [],
  "source": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.mdx",
  "status": "local-preview",
  "note": "Guidance is read from local documentation, originally imported from the frozen source. Platform assertions in guidance are not parity verification."
}

## Contract and public API

```json
{
  "props": [
    {
      "name": "items",
      "type": "SegmentedControlItem[]",
      "declared_type": "SegmentedControlItem[]",
      "required": true,
      "description": "Options rendered in order. Keep the set to 5 or fewer, per the iOS guideline.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 38
      }
    },
    {
      "name": "activeId",
      "type": "string",
      "declared_type": "string",
      "required": true,
      "description": "Controlled `id` of the active option. An id matching no item leaves none active.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 40
      }
    },
    {
      "name": "onSelect",
      "type": "((id: string) => void) | undefined",
      "declared_type": "(id: string) => void",
      "required": false,
      "description": "Called with the selected option id. Skipped when the pressed option is already active.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 42
      }
    },
    {
      "name": "ariaLabel",
      "type": "string | undefined",
      "declared_type": "string",
      "required": false,
      "description": "Accessible name for the strip, announced before the options — such as `Time range`.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 44
      }
    },
    {
      "name": "className",
      "type": "string | undefined",
      "declared_type": "string",
      "required": false,
      "description": "",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 45
      }
    },
    {
      "name": "haptic",
      "type": "HapticFeedback | undefined",
      "declared_type": "HapticFeedback",
      "required": false,
      "description": "Haptic feedback for selection changes. Defaults to `true` (`selection`).",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 47
      },
      "values": [
        "error",
        "heavy",
        "light",
        "medium",
        "selection",
        "success",
        "warning",
        false,
        true
      ],
      "values_complete": true,
      "default": "true",
      "default_kind": "source-expression"
    },
    {
      "name": "sizing",
      "type": "SegmentedControlSizing | undefined",
      "declared_type": "SegmentedControlSizing",
      "required": false,
      "description": "How options claim width. `fill` (default) shares the strip equally and\nellipsises labels that do not fit; `hug` sizes each option to its own\nlabel, for a strip that should be no wider than its content.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 53
      },
      "values": [
        "fill",
        "hug"
      ],
      "values_complete": true,
      "default": "'fill'",
      "default_kind": "source-expression"
    },
    {
      "name": "testId",
      "type": "string | undefined",
      "declared_type": "string",
      "required": false,
      "description": "Optional test selector. Each option derives `${testId}-${id}`.",
      "source": {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 55
      }
    }
  ],
  "prop_shapes": {},
  "api": {
    "method": "typescript-type-checker",
    "typescript_version": "5.9.3",
    "public_export": "SegmentedControl",
    "public_path": "SegmentedControl",
    "import_path": "@cloakedinc/design-system",
    "root_kind": "component",
    "entrypoints": [
      {
        "name": "SegmentedControl",
        "signature": 1,
        "visibility": "public",
        "source": {
          "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
          "line": 63
        },
        "props_type": "SegmentedControlProps",
        "props": [
          {
            "name": "items",
            "type": "SegmentedControlItem[]",
            "declared_type": "SegmentedControlItem[]",
            "required": true,
            "description": "Options rendered in order. Keep the set to 5 or fewer, per the iOS guideline.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 38
            }
          },
          {
            "name": "activeId",
            "type": "string",
            "declared_type": "string",
            "required": true,
            "description": "Controlled `id` of the active option. An id matching no item leaves none active.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 40
            }
          },
          {
            "name": "onSelect",
            "type": "((id: string) => void) | undefined",
            "declared_type": "(id: string) => void",
            "required": false,
            "description": "Called with the selected option id. Skipped when the pressed option is already active.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 42
            }
          },
          {
            "name": "ariaLabel",
            "type": "string | undefined",
            "declared_type": "string",
            "required": false,
            "description": "Accessible name for the strip, announced before the options — such as `Time range`.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 44
            }
          },
          {
            "name": "className",
            "type": "string | undefined",
            "declared_type": "string",
            "required": false,
            "description": "",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 45
            }
          },
          {
            "name": "haptic",
            "type": "HapticFeedback | undefined",
            "declared_type": "HapticFeedback",
            "required": false,
            "description": "Haptic feedback for selection changes. Defaults to `true` (`selection`).",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 47
            },
            "values": [
              "error",
              "heavy",
              "light",
              "medium",
              "selection",
              "success",
              "warning",
              false,
              true
            ],
            "values_complete": true,
            "default": "true",
            "default_kind": "source-expression"
          },
          {
            "name": "sizing",
            "type": "SegmentedControlSizing | undefined",
            "declared_type": "SegmentedControlSizing",
            "required": false,
            "description": "How options claim width. `fill` (default) shares the strip equally and\nellipsises labels that do not fit; `hug` sizes each option to its own\nlabel, for a strip that should be no wider than its content.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 53
            },
            "values": [
              "fill",
              "hug"
            ],
            "values_complete": true,
            "default": "'fill'",
            "default_kind": "source-expression"
          },
          {
            "name": "testId",
            "type": "string | undefined",
            "declared_type": "string",
            "required": false,
            "description": "Optional test selector. Each option derives `${testId}-${id}`.",
            "source": {
              "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
              "line": 55
            }
          }
        ],
        "branches": [],
        "constraints": [],
        "extraction_status": "extracted"
      }
    ],
    "type_sources": [
      {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 11,
        "name": "SegmentedControlItem",
        "code": "export interface SegmentedControlItem {\n    /** Stable option id. Also suffixes the derived test selector. */\n    id: string;\n    /** Visible option label. Keep it short: segments share the width equally. */\n    label: string;\n    /** Trailing count, rendered as text so it is announced with the label. */\n    count?: number;\n    /** Attention indicator. Decorative, so say why it is there in the label when it carries meaning. */\n    dot?: boolean;\n    /** Blocks this one option through the native attribute, without disabling the set. */\n    disabled?: boolean;\n    /** Overrides the selector derived from `testId` and `id`. */\n    testId?: string;\n}"
      },
      {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 30,
        "name": "SegmentedControlSizing",
        "code": "export type SegmentedControlSizing = 'fill' | 'hug';"
      },
      {
        "path": "packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.tsx",
        "line": 36,
        "name": "SegmentedControlProps",
        "code": "export interface SegmentedControlProps {\n    /** Options rendered in order. Keep the set to 5 or fewer, per the iOS guideline. */\n    items: SegmentedControlItem[];\n    /** Controlled `id` of the active option. An id matching no item leaves none active. */\n    activeId: string;\n    /** Called with the selected option id. Skipped when the pressed option is already active. */\n    onSelect?: (id: string) => void;\n    /** Accessible name for the strip, announced before the options — such as `Time range`. */\n    ariaLabel?: string;\n    className?: string;\n    /** Haptic feedback for selection changes. Defaults to `true` (`selection`). */\n    haptic?: HapticFeedback;\n    /**\n     * How options claim width. `fill` (default) shares the strip equally and\n     * ellipsises labels that do not fit; `hug` sizes each option to its own\n     * label, for a strip that should be no wider than its content.\n     */\n    sizing?: SegmentedControlSizing;\n    /** Optional test selector. Each option derives `${testId}-${id}`. */\n    testId?: string;\n}"
      },
      {
        "path": "packages/design-system/src/tokens/CdsHaptic.ts",
        "line": 6,
        "name": "HapticFeedback",
        "code": "export type HapticFeedback = boolean | HapticType;"
      },
      {
        "path": "packages/design-system/src/tokens/HapticType.ts",
        "line": 2,
        "name": "HapticType",
        "code": "export type HapticType = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' | 'selection';"
      }
    ],
    "defaults_policy": "Only explicit destructured parameter initializers are extracted. Other runtime defaults remain in implementation source."
  },
  "spec": {
    "$schema": "../../../specs/component-spec.schema.json",
    "identity": {
      "name": "SegmentedControl",
      "status": "preview",
      "version": "2.1.0",
      "summary": "A list of buttons for switching one view mode, with the selected option marked by aria-current."
    },
    "parts": [
      {
        "id": "strip",
        "label": "Strip",
        "description": "The list that owns the accessible name and shares width equally between options.",
        "required": true
      },
      {
        "id": "option",
        "label": "Option",
        "description": "One button per option. Carries aria-current and its own disabled state.",
        "required": true
      },
      {
        "id": "count",
        "label": "Count",
        "description": "Optional trailing number, rendered as text so it is announced with the label."
      },
      {
        "id": "dot",
        "label": "Dot",
        "description": "Optional decorative attention indicator."
      }
    ],
    "allowedDesignChoices": {
      "variants": {
        "pill": {
          "label": "Pill",
          "description": "Equal-width pill segments with filled active and outlined inactive treatments."
        }
      },
      "sizes": {}
    },
    "variants": [
      {
        "id": "pill",
        "label": "Pill",
        "description": "Compact mode switch with one active option.",
        "platforms": [
          "ios",
          "android",
          "web"
        ]
      }
    ],
    "sizes": [],
    "measurements": [
      {
        "id": "option-height",
        "label": "Option height",
        "kind": "exact",
        "token": "--cds-size-32",
        "value": 32,
        "unit": "px",
        "appliesTo": [
          "option"
        ]
      },
      {
        "id": "option-horizontal-padding",
        "label": "Option horizontal padding",
        "kind": "exact",
        "token": "--cds-space-16",
        "value": 16,
        "unit": "px",
        "appliesTo": [
          "option"
        ]
      },
      {
        "id": "option-content-gap",
        "label": "Option content gap",
        "kind": "exact",
        "token": "--cds-space-4",
        "value": 4,
        "unit": "px",
        "appliesTo": [
          "option",
          "count",
          "dot"
        ]
      },
      {
        "id": "option-gap",
        "label": "Gap between options",
        "kind": "exact",
        "token": "--cds-space-8",
        "value": 8,
        "unit": "px",
        "appliesTo": [
          "strip"
        ]
      },
      {
        "id": "dot-size",
        "label": "Dot size",
        "kind": "exact",
        "token": "--cds-size-8",
        "value": 8,
        "unit": "px",
        "appliesTo": [
          "dot"
        ]
      }
    ],
    "layout": {
      "alignment": "Options share equal width and center their content in both axes.",
      "flow": "Two to four options form one horizontal row.",
      "notes": [
        "Use Tabs when options switch distinct content panels rather than one view mode.",
        "Use a ChipGroup when the set grows from data, wraps, or scrolls.",
        "Use Toggle for a binary input rather than a two-option strip."
      ]
    },
    "typographyAndColor": {
      "typography": [
        {
          "label": "Segment label",
          "description": "Use the compact link ramp for concise mode labels.",
          "token": "--cds-type-link-size"
        }
      ],
      "colors": [
        {
          "label": "Active segment",
          "description": "Use the bold semantic surface.",
          "token": "--cds-bg-bold"
        },
        {
          "label": "Active label",
          "description": "Use text intended for bold surfaces.",
          "token": "--cds-text-on-bold"
        },
        {
          "label": "Inactive label",
          "description": "Use primary text emphasis.",
          "token": "--cds-text-primary"
        },
        {
          "label": "Inactive border",
          "description": "Use the subtle surface stroke.",
          "token": "--cds-surface-subtle"
        }
      ]
    },
    "states": [
      {
        "id": "active",
        "label": "Active",
        "description": "Bold fill with on-bold text, marked by aria-current."
      },
      {
        "id": "inactive",
        "label": "Inactive",
        "description": "Transparent fill with a subtle border."
      },
      {
        "id": "disabled",
        "label": "Disabled",
        "description": "One option dimmed and blocked through the native disabled attribute."
      },
      {
        "id": "with-count",
        "label": "With count",
        "description": "Shows a trailing number after the label."
      },
      {
        "id": "with-dot",
        "label": "With dot",
        "description": "Shows a trailing attention indicator."
      }
    ],
    "behavior": [
      {
        "label": "Select on press",
        "description": "Invoke onSelect with the pressed option id. Skip it when that option is already active."
      },
      {
        "label": "Per-option disable",
        "description": "An option may be disabled on its own through the native attribute, without disabling the set."
      },
      {
        "label": "Bounded set",
        "description": "Keep the set to 5 or fewer options, per the iOS guideline. Past that, reach for a Chip group or a Select."
      }
    ],
    "accessibility": [
      {
        "label": "Not a radiogroup",
        "description": "A radiogroup needs radio semantics on every option, role=radio with aria-checked, plus radio-group arrow-key behavior. These are ordinary buttons, so the selected one carries aria-current."
      },
      {
        "label": "Not a tablist",
        "description": "A tablist promises content panels and arrow-key traversal. Reach for Tabs when the options switch panels."
      },
      {
        "label": "Explicit list role",
        "description": "The list keeps role=list because removing the bullets strips list semantics in some engines."
      },
      {
        "label": "Group name",
        "description": "Pass ariaLabel so the set is announced before its options."
      },
      {
        "label": "Counts over dots",
        "description": "A count is text and announces. A dot is decorative, so say why it is there in the label when it carries meaning."
      }
    ],
    "contentAdaptation": [
      {
        "label": "Option count",
        "description": "Keep the set to two through four mutually exclusive modes."
      },
      {
        "label": "Concise labels",
        "description": "Use parallel, short labels that remain legible at equal widths."
      },
      {
        "label": "Width sizing",
        "description": "sizing='fill' (default) shares the strip equally and ellipsises a label that does not fit. sizing='hug' sizes each option to its own label, so an auto-width strip is never narrower than its content; use it when the strip sits beside other content rather than filling a row."
      }
    ],
    "motionHaptics": {
      "motion": [
        {
          "label": "Selection treatment",
          "description": "Swap active and inactive color treatments without moving or resizing segments."
        }
      ],
      "haptics": [
        {
          "label": "Selection feedback",
          "description": "Request the selection haptic after the active segment changes."
        }
      ]
    },
    "platformDifferences": [
      {
        "platforms": [
          "web"
        ],
        "description": "Web renders no focus ring and no arrow-key traversal between options.",
        "rationale": "The consumers are a touch iOS WKWebView and a harness previewing it, and per Primer arrow keys must not move selection in a segmented control anyway."
      }
    ]
  },
  "platforms": {
    "implemented": [
      "web"
    ],
    "native": "Imported design guidance only; no native implementation or parity verification in this preview.",
    "react": {
      "implementation": "local",
      "status": "preview",
      "parity": "web-reference"
    },
    "vue": {
      "implementation": "absent",
      "parity": "not-verified",
      "status": "reference-only"
    },
    "swift": {
      "implementation": "absent",
      "parity": "not-verified",
      "status": "reference-only"
    },
    "kotlin": {
      "implementation": "absent",
      "parity": "not-verified",
      "status": "reference-only"
    }
  },
  "tokens": [
    {
      "name": "--cds-size-32",
      "light": "32px",
      "dark": "32px"
    },
    {
      "name": "--cds-space-16",
      "light": "16px",
      "dark": "16px"
    },
    {
      "name": "--cds-space-4",
      "light": "4px",
      "dark": "4px"
    },
    {
      "name": "--cds-space-8",
      "light": "8px",
      "dark": "8px"
    },
    {
      "name": "--cds-size-8",
      "light": "8px",
      "dark": "8px"
    },
    {
      "name": "--cds-type-link-size",
      "light": "0.75rem",
      "dark": "0.75rem"
    },
    {
      "name": "--cds-bg-bold",
      "light": "#0b0b0a",
      "dark": "#ffffff"
    },
    {
      "name": "--cds-text-on-bold",
      "light": "#ffffff",
      "dark": "#141410"
    },
    {
      "name": "--cds-text-primary",
      "light": "#141410",
      "dark": "#f3f1ed"
    },
    {
      "name": "--cds-surface-subtle",
      "light": "#e0ddd6",
      "dark": "#ffffff26"
    }
  ],
  "review": {
    "local_status": "not-reviewed",
    "source_approval": null,
    "imported_spec_status": "approved",
    "spec_origin": "imported-adapted",
    "local_spec_source_status": "approved",
    "imported_source_revision": "f96f416e1c3bfbfaffcd155a88b87b18b8bb25f1",
    "note": "Inherited specification status is provenance, not approval of the current local implementation or extracted contract."
  },
  "missing_facts": [
    {
      "code": "human-review-pending",
      "detail": "This local preview has no recorded human design or engineering sign-off."
    },
    {
      "code": "platform-parity-unverified",
      "detail": "Vue, Swift and Kotlin implementations are absent from this package; platform parity has not been verified."
    },
    {
      "code": "figma-mapping-unverified",
      "detail": "No current component-level Figma node and immutable revision have been verified for this contract."
    }
  ]
}
```

## Visual states

- [Playground](/storybook/iframe.html?id=cloaked-design-system-controls-segmentedcontrol--playground&viewMode=story) | export: Playground | source: packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.stories.tsx

- [Middle Active](/storybook/iframe.html?id=cloaked-design-system-controls-segmentedcontrol--middle-active&viewMode=story) | export: MiddleActive | source: packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.stories.tsx

- [With Counts And Dots](/storybook/iframe.html?id=cloaked-design-system-controls-segmentedcontrol--with-counts-and-dots&viewMode=story) | export: WithCountsAndDots | source: packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.stories.tsx

- [Dark Theme](/storybook/iframe.html?id=cloaked-design-system-controls-segmentedcontrol--dark-theme&viewMode=story) | export: DarkTheme | source: packages/design-system/src/components/controls/SegmentedControl/SegmentedControl.stories.tsx

Preview pending human sign-off. Source-derived APIs and successful checks do not certify accessibility, native parity or approval.
