Documentation

API reference and usage notes for building segmented controls, switches, option pickers and tab-like UI with react-segmented-choice.

react-segmented-choice

react-segmented-choice is a React segmented control for choices that should feel like real UI, not styled tabs.

It keeps the boring parts in place: native radio semantics, keyboard behavior, form-friendly state and drag-to-select. From there, you can shape the same component into a switch, toolbar, option picker, rail or compact mode control with CSS and measured geometry.

Typical use cases:

  • report ranges, billing periods, density switches and mode pickers
  • icon rails and compact toolbars where every option needs a stable target
  • custom switches or tabs alternatives that should still behave like form controls

The hosted Storybook shows the range: plain controls, rails, thumbnails, filters, toggles and the geometry stories behind them.

Install

pnpm add react-segmented-choice
# or
npm install react-segmented-choice
# or
yarn add react-segmented-choice

Import bundled styles once:

import 'react-segmented-choice/styles.css';

Import the bundled stylesheet before your app or component CSS. That keeps the default skin available while letting your own classes override public --rsc-* variables and stable .rsc-* hooks in normal CSS order.

Quick Start

import { SegmentedChoice } from 'react-segmented-choice';
import 'react-segmented-choice/styles.css';

export function ReportRange() {
  return (
    <SegmentedChoice
      ariaLabel="Report range"
      defaultValue="week"
      options={[
        { value: 'day', label: 'Day' },
        { value: 'week', label: 'Week' },
        { value: 'month', label: 'Month' },
      ]}
    />
  );
}

Value Type (string only)

SegmentedChoiceValue is string. Treat option values as stable public IDs. If the selected thing is a richer object, keep that object in your app state and pass the ID to the control.

For complex domain values, keep external mapping by ID:

const items = [
  { id: 'plan-free', plan: { seats: 1, tier: 'free' } },
  { id: 'plan-pro', plan: { seats: 10, tier: 'pro' } },
] as const;

const byId = Object.fromEntries(items.map(x => [x.id, x.plan]));

<SegmentedChoice
  ariaLabel="Plan"
  value={selectedId}
  onValueChange={id => {
    setSelectedId(id);
    setSelectedPlan(byId[id]);
  }}
  options={items.map(x => ({ value: x.id, label: x.plan.tier }))}
/>;

Public API

The package has one component entry point and a small type surface. This README keeps the map short; API.md is where the prop-by-prop reference and longer examples live.

Exports:

  • SegmentedChoice
  • types: SegmentedChoiceProps, SegmentedChoiceOption, SegmentedChoiceValue, SegmentedChoiceGeometry, SegmentedChoiceSlotProps
  • geometry/types: SegmentedChoiceTrackLayout, SegmentedChoiceTrackStyle, SegmentedChoiceIndicatorStyle, SegmentedChoiceIndicatorContentMode, SegmentedChoiceIndicatorTransition
  • typography helpers: SEGMENTED_CHOICE_FONT_FAMILY, SEGMENTED_CHOICE_TYPOGRAPHY_TOKENS, getSegmentedChoiceTypographyTokens, SegmentedChoiceTypographyTokens

Top-level props:

  • options, value, defaultValue, onValueChange, name
  • disabled, required, orientation, optionSizing, optionDistribution, size
  • draggable, loop
  • ariaLabel, ariaLabelledby, ariaDescribedby
  • className, styleNonce, unstyled, slotProps, geometry

Layout props worth knowing early:

  • optionSizing controls option box dimensions: equal uses the widest measured option content for every option, content lets each option follow its own content width and geometry.optionSize switches to fixed square option boxes.
  • optionDistribution controls option placement when the surface has extra space. The default is space-between; space-around adds outer breathing room too. Surface width itself still comes from normal CSS layout.

Structural runtime guarantees:

  • options must contain at least 2 entries
  • every options[i].value must be a unique string
  • invalid option structures render nothing and warn in development
  • the radiogroup must be labelled with ariaLabel or ariaLabelledby

For complete prop-by-prop reference with examples, see API.md.

Option fields:

  • value: unique string selection value
  • label: rendered option content
  • ariaLabel: accessible label for icon-only or non-text labels
  • description: optional secondary content
  • disabled: disables one option
  • accentColor: optional per-option indicator color, sanitized before runtime CSS injection

geometry

Use geometry when the layout mechanics need to change, not just the colors. It controls things like underlay vs overlay behavior, track span, fixed option or indicator sizes, drag scale, cloned indicator content and indicator motion.

geometry?: {
  mode?: "underlay" | "overlay";
  dragScale?: boolean | number;
  optionSize?: number;
  anchor?: {
    size?: number;
    width?: number;
    height?: number;
  };
  track?: {
    layout?: "container" | "center-span";
    style?: "surface" | "none";
  };
  indicator?: {
    size?: number;
    width?: number;
    height?: number;
    style?: "fill" | "ring" | "none";
    content?: "none" | "clone-active";
    transition?: "smooth" | "instant";
    inset?: number;
    borderWidth?: number;
  };
}

Defaults:

  • geometry.mode = "underlay"
  • geometry.track.layout = "container"
  • geometry.track.style = "surface"
  • geometry.indicator.style = "fill"
  • geometry.indicator.content = "none"
  • geometry.indicator.transition = "smooth"

geometry.indicator.transition is only about the moving selection indicator:

  • "smooth" animates selection indicator position and size changes.
  • "instant" updates indicator position and size without movement or resize animation.

It does not delay value changes or change drag preview behavior.

slotProps

slotProps is for integration hooks, not visual styling:

  • allowed: className, data-*, non-conflicting aria-*, handlers (onPointerEnter, etc.)
  • not supported: style

style is intentionally ignored at runtime even if forced through a cast.

For slotProps.list, internal pointer/focus handlers run before user-provided handlers. Use list-level handlers to observe or add to interactions. Do not rely on them to override the built-in drag and commit flow.

Root radiogroup semantics stay controlled by top-level props such as ariaLabel, ariaLabelledby and ariaDescribedby.

Example:

<SegmentedChoice
  ariaLabel="Density"
  defaultValue="comfortable"
  options={[
    { value: 'comfortable', label: 'Comfortable' },
    { value: 'compact', label: 'Compact' },
  ]}
  slotProps={{
    root: { 'data-qa': 'density-choice' },
    option: { 'data-track': 'density-option' },
  }}
/>

CSS-First Customization Contract

SegmentedChoice does not put style={...} on its rendered slots.

The styling contract is intentionally CSS-first:

  • stable .rsc-* classes for slot targeting
  • core state data-* attrs for state-specific styling
  • public --rsc-* variables for theme tokens
  • no rendered slot inline styles, so your CSS can still override variables such as --rsc-surface

Runtime geometry is separate from theme styling. Measured positions and sizes are written through an internal scoped stylesheet, not through slot inline styles.

CSP-safe runtime styles

Dynamic layout values are injected into a shared document stylesheet.

  • pass styleNonce when your app uses strict CSP without unsafe-inline
  • instances in the same document share one runtime style host per nonce value
  • controls with different styleNonce values create separate runtime style hosts

Stable class hooks

  • .rsc-root, .rsc-list, .rsc-track, .rsc-indicator, .rsc-indicator-content
  • .rsc-option, .rsc-option-input, .rsc-option-anchor, .rsc-option-content
  • .rsc-option-label, .rsc-option-description

Use class hooks for stable slot targeting. Use data attrs for semantic state and interaction state.

Stable root data attrs

  • data-orientation, data-size
  • data-disabled, data-unstyled, data-dragging, data-drag-released

data-dragging marks an active pointer drag. data-drag-released is briefly true after a pointer drag ends so CSS can add optional release feedback such as a bounce or glow. The default stylesheet does not animate release.

Stable option data attrs

  • data-selected, data-disabled, data-focus-visible
  • data-has-description, data-previewed

data-previewed marks the option currently targeted during an active drag. It can differ from data-selected until the drag commits.

Stable CSS variables

Always active:

  • --rsc-bg, --rsc-surface, --rsc-border-color, --rsc-border-radius
  • --rsc-font-family, --rsc-font-weight, --rsc-line-height, --rsc-letter-spacing
  • --rsc-font-size, --rsc-description-font-size
  • --rsc-container-offset, --rsc-padding, --rsc-gap, --rsc-label-gap
  • --rsc-disabled-opacity
  • --rsc-option-min-size, --rsc-option-padding-block, --rsc-option-padding-inline, --rsc-option-radius
  • --rsc-track-size
  • --rsc-text-color, --rsc-active-text-color
  • --rsc-indicator-bg, --rsc-indicator-color
  • --rsc-indicator-border-width, --rsc-indicator-shadow, --rsc-focus-ring-color

Optional public override:

  • --rsc-indicator-hover-bg overrides the overlay fill hover color. Without it, overlay fill hover preserves active accentColor; controls without an active accent use the default gray hover fill.

Public vs internal CSS variables

Public variables are the documented --rsc-* tokens above. They are safe to theme from external CSS.

Internal runtime selectors and variables use data-rsc-* and --_rsc-*. They belong to the bundled stylesheet and scoped runtime layout rules. Do not build app-level styling contracts on them.

SSR and Hydration

Server rendering does not emit the internal runtime stylesheet.

  • SSR markup is safe to render without touching window, document or navigator
  • the scoped runtime CSS is installed on the client after hydration during the first layout effect
  • expect indicator geometry to settle on the client after hydration

API Precedence

  1. Component state and geometry define mechanics/layout.
  2. Internal runtime stylesheet applies instance-scoped layout values.
  3. Public CSS variables define theme and visual output.
  4. Class/data selectors define contextual styling.
  5. slotProps adds attrs/events/class names to slots.

Use geometry when the component should measure or move differently. Use CSS when the same mechanics should look different.

unstyled means "remove the default visual skin". It is not a headless primitive. Structure, semantics, slots and layout logic still come from the component.

For deeper guidance, examples and sentence-level descriptions of each stable class/data hook, read API.md.

Storybook

Storybook is the easiest place to see the component pushed past the default pill control.

It covers:

  • baseline variants and state examples
  • geometry and indicator architecture
  • CSS-first customization patterns
  • rails, thumbnails, filters, custom switches and toggle-like controls
  • keyboard, pointer, drag and accessibility interaction behavior
  • styling examples built only on the documented public API

Run it locally with pnpm storybook, or browse the hosted examples at sb.segmentedchoice.visiofutura.com.

Browser Support

The supported baseline is current and previous major releases of:

  • Chrome / Edge
  • Safari

The library is tested with Chromium and WebKit in CI.

Visual Regression Workflow

pnpm test:visual
pnpm test:visual:update

test:visual builds Storybook and compares screenshots for the curated tags: ["visual"] stories.

Accessibility

  • Native radio inputs and radiogroup semantics
  • Arrow/Home/End keyboard support
  • Disabled options skipped in keyboard navigation
  • Immediate form compatibility via shared name
  • For non-text labels, pass ariaLabel per option

Security / SBOM

Tagged releases publish a CycloneDX SBOM generated from the pnpm lockfile.

Security and compliance tooling can use the SBOM to inspect dependency metadata.

Quality Gates

  • Format: pnpm format:check
  • Lint: pnpm lint
  • Typecheck: pnpm typecheck
  • Build/declaration output: pnpm build
  • Unit: pnpm test:unit
  • Storybook interactions: pnpm test:storybook
  • Source/build contract: pnpm test:contract
  • Package sanity: pnpm pack:check
  • Visual regression: pnpm test:visual
  • Coverage: pnpm test:coverage

CI runs format checks, lint, unit coverage, package build, public contract checks, package-content checks, Storybook browser tests and Chromium/WebKit visual regression.

prepack runs: build -> contract check -> pack check.

Supported Versions

  • React: ^18.2.0 || ^19.0.0
  • Node: >=22.13.0

SegmentedChoice API Reference

The README gets you to a working control. This file is for the parts that matter once you start fitting it into a real interface: value ownership, geometry, slot hooks, CSS customization and the few sharp edges worth knowing.

If you only need the default segmented control, the README is probably enough. If you are changing layout mechanics, wiring analytics attrs, building a custom skin or using the component in forms, this reference is meant to answer the "what owns this?" questions.

Component Signature

import { SegmentedChoice } from 'react-segmented-choice';

type SegmentedChoiceValue = string;

type SegmentedChoiceProps<T extends SegmentedChoiceValue = string> = {
  options: readonly SegmentedChoiceOption<T>[];
  value?: T;
  defaultValue?: T;
  onValueChange?: (value: T) => void;
  name?: string;
  disabled?: boolean;
  required?: boolean;
  orientation?: 'horizontal' | 'vertical';
  optionSizing?: 'equal' | 'content';
  optionDistribution?: 'space-between' | 'space-around';
  size?: 'sm' | 'md' | 'lg';
  draggable?: boolean;
  loop?: boolean;
  ariaLabel?: string;
  ariaLabelledby?: string;
  ariaDescribedby?: string;
  className?: string;
  styleNonce?: string;
  unstyled?: boolean;
  slotProps?: SegmentedChoiceSlotProps;
  geometry?: SegmentedChoiceGeometry;
};

options

type SegmentedChoiceOption<T extends string = string> = {
  value: T;
  label: React.ReactNode;
  ariaLabel?: string;
  description?: React.ReactNode;
  disabled?: boolean;
  accentColor?: string;
};

Field details:

  • value: unique string identifier used for selection state.
  • label: rendered content for the option.
  • ariaLabel: strongly recommended for icon-only labels.
  • description: secondary content beside or under the label, depending on styles.
  • disabled: disables only this option.
  • accentColor: optional per-option accent used by the indicator color logic. Supported formats are hex, alphabetic named colors, rgb()/rgba(), hsl()/hsla() and var(--token). Unsupported values are ignored and warn in development.

Top-Level Props

Selection and State

  • value?: T: controlled value. If you pass it, the parent must pass the committed value back after onValueChange.
  • defaultValue?: T: initial value for uncontrolled mode.
  • onValueChange?: (value: T) => void: called when selection commits.

Form

  • name?: string: radio group name. If omitted, the component generates a stable internal name.
  • required?: boolean: passed to the underlying radio inputs.

Interaction

  • disabled?: boolean (default: false): disables the whole control.
  • draggable?: boolean (default: true): enables drag-to-select behavior.
  • loop?: boolean (default: true): lets keyboard arrows wrap at the edges.

Layout

  • orientation?: "horizontal" | "vertical" (default: "horizontal")
  • optionSizing?: "equal" | "content" (default: "equal")
  • optionDistribution?: "space-between" | "space-around" (default: "space-between")
  • size?: "sm" | "md" | "lg" (default: "md")

optionSizing decides how wide each option box should be:

  • equal measures the widest option content and uses that width for every option box.
  • content lets each option box follow its own content width.
  • geometry.optionSize resolves sizing to fixed square option boxes and overrides optionSizing.

optionDistribution only becomes visible when the surface has extra room:

  • space-between places spare space between option boxes.
  • space-around also adds spare space before the first and after the last option.

The surface width itself still comes from normal CSS layout. Without an explicit width, the root stays compact around its options.

Accessibility

  • ariaLabel?: string
  • ariaLabelledby?: string
  • ariaDescribedby?: string

Use at least one group labelling strategy: ariaLabel or ariaLabelledby.

Styling Entry Points

  • className?: string: added on .rsc-root.
  • styleNonce?: string: CSP nonce for the runtime stylesheet bucket. Use it when your app disallows inline styles without a nonce.
  • unstyled?: boolean (default: false): removes the default visual skin while keeping DOM structure, semantics and layout logic.
  • geometry?: SegmentedChoiceGeometry: behavior and measured layout tuning.
  • slotProps?: SegmentedChoiceSlotProps: slot-level attrs, events and class hooks.

Zero-inline Styling Contract

Rendered slots do not receive style={...} from the component.

What this means:

  • public theming stays overrideable from app CSS
  • slotProps.style is not public API and is ignored at runtime
  • dynamic layout is written through an internal scoped stylesheet, not through element inline styles

The split is deliberate:

  • public --rsc-* variables are for consumers
  • internal --_rsc-* variables are runtime mechanics and are not public API

Structural Validation

SegmentedChoice validates its option model before rendering.

  • at least 2 options are required
  • every option.value must be a unique string
  • invalid structures return null and emit a dev warning
  • radiogroup labelling requires ariaLabel or ariaLabelledby

That keeps runtime behavior predictable for consumers and avoids half-valid controls.

CSP and Runtime Styles

Dynamic geometry is written into a shared document stylesheet instead of inline slot styles.

  • pass styleNonce to attach a nonce to that stylesheet
  • instances in one document reuse the same stylesheet when they share a nonce
  • distinct styleNonce values create separate runtime style hosts in the same document

SSR and Hydration

Server rendering does not emit the internal runtime stylesheet.

  • server rendering does not access browser globals during render
  • the runtime stylesheet is attached on the client during the first layout effect after hydration
  • indicator geometry and measured layout settle on the client after hydration

Mental Model

The component is easier to reason about in four layers:

  1. options, selection props and interaction props define state and semantics.
  2. geometry defines what the component measures: where the indicator moves, how the track is measured, whether anchors exist and whether explicit option or indicator sizing is active.
  3. Public CSS variables, stable classes and stable data attributes define appearance.
  4. slotProps lets an app attach integration metadata, event observers and extra class names to the rendered slots.

Use geometry when the component should measure or move differently. Use CSS when the same mechanics should look different. Use slotProps when another system needs attributes, class names or event handlers on a specific slot.

geometry

type SegmentedChoiceGeometrySize = {
  size?: number;
  width?: number;
  height?: number;
};

type SegmentedChoiceIndicatorTransition = 'smooth' | 'instant';

type SegmentedChoiceGeometry = {
  mode?: 'underlay' | 'overlay';
  dragScale?: boolean | number;
  optionSize?: number;
  anchor?: SegmentedChoiceGeometrySize;
  track?: {
    layout?: 'container' | 'center-span';
    style?: 'surface' | 'none';
  };
  indicator?: SegmentedChoiceGeometrySize & {
    style?: 'fill' | 'ring' | 'none';
    content?: 'none' | 'clone-active';
    transition?: SegmentedChoiceIndicatorTransition;
    inset?: number;
    borderWidth?: number;
  };
};

Defaults:

  • mode: "underlay"
  • track.layout: "container"
  • track.style: "surface"
  • indicator.style: "fill"
  • indicator.content: "none"
  • indicator.transition: "smooth"

During an active drag, changing options, orientation or geometry cancels the in-flight gesture. The control clears the preview state and the user can start a new drag after the update.

mode

  • "underlay": indicator participates as under-selection background.
  • "overlay": indicator behaves like moving handle above options.

Use "underlay" for classic segmented controls where selection feels like a highlighted background behind the active option. Use "overlay" when selection should behave like a handle or capsule moving above the option labels.

dragScale

  • false or undefined: no extra drag scaling.
  • true: scales to 1.1 while dragging.
  • number: exact custom scale factor while dragging (for example 1.25).

dragScale affects the indicator while an active pointer drag is in progress. It does not change option hitboxes or the committed value.

optionSize

  • Sets fixed square size for each option box.
  • Uses internal runtime layout values; no additional public styling hook is required.

Use this for icon grids, compact tool pickers or any control where every option needs the same square target, regardless of label length. optionSize defines option-box dimensions; optionDistribution still controls how those boxes spread when the surface is wider than the boxes.

anchor

  • size: shorthand for square anchor.
  • width and height: non-square anchor geometry.
  • If width/height are provided, they override size per axis.

Anchors are invisible measurement targets by default. They are useful when an overlay handle should move between compact targets inside wider option labels. You can also style them through .rsc-option-anchor.

track.layout

  • "container": the track fills the control container.
  • "center-span": the track starts at the center of the first option and ends at the center of the last option, using anchors when present.

Use "container" for regular pill backgrounds. Use "center-span" for rail-like controls where the track should run through option centers instead of filling the whole wrapper.

track.style

  • "surface": applies the default track paint.
  • "none": keeps track geometry but removes default paint so CSS can draw the rail.

track.style = "none" does not remove the track element. It keeps the .rsc-track slot available so your CSS can draw a line, gradient, timeline rail or no visible track at all.

indicator

  • size: shorthand for square indicator.
  • width and height: non-square indicator geometry.
  • style: "fill" | "ring" | "none".
  • content: "none" or "clone-active" (for overlay clone mode).
  • transition: "smooth" or "instant".
  • inset: internal inset used in layout math.
  • borderWidth: used by ring visuals and ring geometry calculations.

Axis precedence:

  • width resolution: indicator.width ?? indicator.size
  • height resolution: indicator.height ?? indicator.size

Explicit indicator sizing is useful when the indicator should be a fixed handle instead of matching selected option content. The measured dimensions are applied through internal runtime layout variables.

indicator.transition

indicator.transition controls selection indicator geometry motion.

  • "smooth" is the default and animates position and size changes.
  • "instant" updates position and size immediately.

This controls the selection indicator only. It does not delay value changes, change selection semantics or change drag preview timing.

indicator.content = "clone-active"

"clone-active" turns the indicator into a moving value capsule. In overlay mode, the cloned content follows the current preview target during drag: the option that would be selected on release.

It does not reorder options. The option model stays fixed; this is selection preview, not drag-and-drop list behavior.

Use it when the active value should travel with the handle: camera modes, compact icon + label pickers or controls that intentionally feel close to a small slider. Skip it for heavy option content, dense controls where cloned content hurts readability or classic segmented controls where a plain selected highlight is clearer.

For most mode pickers, start with indicator.content = "none". Reach for clone-active only when the moving value capsule is part of the interaction you want.

Recipe (overlay + clone-active):

<SegmentedChoice
  ariaLabel="Camera mode"
  defaultValue="portrait"
  options={[
    { value: 'photo', label: 'Photo' },
    { value: 'portrait', label: 'Portrait' },
    { value: 'video', label: 'Video' },
  ]}
  geometry={{
    mode: 'overlay',
    track: { layout: 'center-span', style: 'none' },
    indicator: {
      style: 'fill',
      content: 'clone-active',
    },
  }}
/>

slotProps

type SegmentedChoiceSlotProps = {
  root?: Omit<
    React.HTMLAttributes<HTMLDivElement>,
    'aria-describedby' | 'aria-label' | 'aria-labelledby' | 'aria-orientation' | 'role' | 'style'
  >;
  list?: Omit<React.HTMLAttributes<HTMLDivElement>, 'style'>;
  track?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  indicator?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  indicatorContent?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  option?: Omit<React.LabelHTMLAttributes<HTMLLabelElement>, 'style'>;
  optionAnchor?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  optionContent?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  optionLabel?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
  optionDescription?: Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'>;
};

Use slotProps to:

  • add className
  • add data-* attributes
  • add non-conflicting aria-* attributes
  • attach event handlers

Do not use it for:

  • inline styling. style is not public API and is ignored.
  • owning root radiogroup semantics such as role, ariaLabel, ariaLabelledby, ariaDescribedby or aria-orientation.

Handler ordering:

  • internal list pointer/focus handlers run before slotProps.list handlers
  • user handlers should observe or add to list interactions, not depend on canceling internal drag or commit logic with preventDefault()

Slot map:

slotProps key Rendered slot
root outer .rsc-root radiogroup
list .rsc-list interaction/layout container
track .rsc-track visual track
indicator .rsc-indicator moving selection element
indicatorContent .rsc-indicator-content clone-content wrapper
option each .rsc-option <label>
optionAnchor .rsc-option-anchor measurement/visual target
optionContent .rsc-option-content visible option wrapper
optionLabel .rsc-option-label primary label wrapper
optionDescription .rsc-option-description secondary text wrapper

Use slotProps when you need integration metadata, event observers or extra class names on one of these slots. Use CSS selectors against the stable class hooks below when the change is only visual.

Example:

<SegmentedChoice
  ariaLabel="Density"
  defaultValue="comfortable"
  options={[
    { value: 'comfortable', label: 'Comfortable' },
    { value: 'compact', label: 'Compact' },
  ]}
  slotProps={{
    root: { className: 'density-choice', 'data-qa': 'density-choice' },
    list: { onPointerDown: event => trackPointerDown(event.pointerType) },
    option: { 'data-track': 'density-option' },
    optionLabel: { className: 'density-choice__label' },
  }}
/>

Stable Class Hooks

These selectors match the slots listed in the slotProps map:

.rsc-root
.rsc-list
.rsc-track
.rsc-indicator
.rsc-indicator-content
.rsc-option
.rsc-option-input
.rsc-option-anchor
.rsc-option-content
.rsc-option-label
.rsc-option-description

Use .rsc-root or your own root className to scope a theme. Use .rsc-option, .rsc-option-content, .rsc-option-label and .rsc-option-description for option styling. Use .rsc-track, .rsc-indicator, .rsc-indicator-content and .rsc-option-anchor for geometry-driven visuals such as rails, handles, cloned content, anchors, rings or release feedback.

The .rsc-option-input hook exists because the native radio input is part of the public DOM structure. In normal styling, leave that input visually hidden and style the visible option slots instead.

Class hooks describe structure. Data attributes describe state. In most custom themes, use both together:

.billing-range .rsc-option[data-selected='true'] .rsc-option-content {
  color: #111827;
}

Stable Data Attributes

Root attrs:

  • data-orientation: "horizontal" or "vertical" for axis-specific CSS.
  • data-size: "sm", "md" or "lg" for density-specific overrides.
  • data-disabled: "true" when the whole control is disabled.
  • data-unstyled: "true" when the default visual skin is disabled.
  • data-dragging: "true" during an active pointer drag.
  • data-drag-released: briefly "true" after pointer drag release or cancel, useful for optional CSS-only release feedback.

Option attrs:

  • data-selected: "true" on the committed selected option.
  • data-disabled: "true" when the option or whole control is disabled.
  • data-focus-visible: "true" when keyboard-visible focus treatment should appear.
  • data-has-description: "true" when option.description is present.
  • data-previewed: "true" on the option currently targeted during an active drag. It can differ from data-selected until pointer release commits the value.

Internal runtime selectors use data-rsc-*. They are owned by the bundled stylesheet and scoped runtime layout system. Do not treat them as public customization API.

Data Attribute Scoping Best Practices

Keep these selectors scoped:

  • Scope option-state selectors by component hooks such as .rsc-root and .rsc-option.
  • Prefer a user-owned class on the root when styling one control instance.
  • Avoid global bare [data-*] selectors. These attrs use simple names and may exist elsewhere in your app.

Do:

.my-choice .rsc-option[data-selected='true'] .rsc-option-content {
  color: #111827;
}

.my-choice.rsc-root[data-dragging='true'] .rsc-indicator {
  box-shadow: 0 10px 24px rgb(15 23 42 / 0.18);
}

.my-choice.rsc-root[data-drag-released='true'] .rsc-indicator {
  animation: my-choice-release 180ms ease-out;
}

.my-choice .rsc-option[data-previewed='true'] .rsc-option-label {
  color: #2563eb;
}

Don't:

[data-selected='true'] {
  color: red;
}

Stable CSS Variables

These public --rsc-* tokens are the normal CSS override surface.

Import react-segmented-choice/styles.css before your app or component CSS so your overrides win in normal cascade order.

Surface and color

Variable What it controls
--rsc-bg Base background fallback for the control.
--rsc-surface Default track/surface fill for the bundled skin.
--rsc-border-color Inset border color for the default surface track.
--rsc-text-color Default option text color.
--rsc-active-text-color Selected, indicator and active option text color.

Typography

Variable What it controls
--rsc-font-family Font family applied to the root and option text.
--rsc-font-weight Font weight for labels and descriptions.
--rsc-line-height Line height for labels and descriptions.
--rsc-letter-spacing Letter spacing for labels and descriptions.
--rsc-font-size Primary option label font size.
--rsc-description-font-size Secondary description font size.

Layout and spacing

Variable What it controls
--rsc-border-radius Radius for the container track in the bundled skin.
--rsc-container-offset Default inset used by the bundled skin.
--rsc-padding Surface/list inner padding; defaults to --rsc-container-offset.
--rsc-gap Gap between option slots.
--rsc-label-gap Gap between label/description content and cloned indicator content.

Option sizing

Variable What it controls
--rsc-option-min-size Minimum block size for visible option content.
--rsc-option-padding-block Vertical padding inside visible option content.
--rsc-option-padding-inline Horizontal padding inside visible option content.
--rsc-option-radius Radius for option content and the indicator.

Track

Variable What it controls
--rsc-track-size Rail thickness for center-span tracks.

Indicator

Variable What it controls
--rsc-indicator-bg Default fill color used by the indicator token.
--rsc-indicator-color Indicator fill or ring color; defaults to --rsc-indicator-bg.
--rsc-indicator-border-width Public fallback border width for ring indicators.
--rsc-indicator-shadow Box shadow for fill indicators and focused overlay handles.
--rsc-indicator-hover-bg Optional overlay fill hover color override. Without it, overlay fill hover preserves the active accentColor; controls without an active accent use the default gray hover fill.

Focus and disabled state

Variable What it controls
--rsc-focus-ring-color Outline color for keyboard-visible focus states.
--rsc-disabled-opacity Root opacity when the whole control is disabled.

Public vs Internal Variables

Public:

  • documented --rsc-* variables in this section
  • safe to override from external CSS

Internal:

  • --_rsc-*
  • data-rsc-*
  • owned by component runtime layout logic
  • not covered by semver or public docs

Do not build app-level styling contracts on --_rsc-* or data-rsc-*.

Practical Examples

These examples stay close to the API reference. For more visual recipes, run Storybook with pnpm storybook or browse the hosted build at sb.segmentedchoice.visiofutura.com.

1) Controlled value

const [value, setValue] = useState('week');

<SegmentedChoice
  ariaLabel="Range"
  value={value}
  onValueChange={setValue}
  options={[
    { value: 'day', label: 'Day' },
    { value: 'week', label: 'Week' },
    { value: 'month', label: 'Month' },
  ]}
/>;

Use controlled mode when selection belongs to app state, URL state, a form library or another external store. The component calls onValueChange when a new value commits; the parent passes that value back through value.

2) Uncontrolled value

<SegmentedChoice
  ariaLabel="Default report range"
  defaultValue="week"
  options={[
    { value: 'day', label: 'Day' },
    { value: 'week', label: 'Week' },
    { value: 'month', label: 'Month' },
  ]}
  onValueChange={value => {
    rememberLastRange(value);
  }}
/>

Use uncontrolled mode when the control can own its selected value after the first render. defaultValue is the initial selection; if it is missing or invalid, the component falls back to the first enabled option.

3) Icon or non-text labels

<SegmentedChoice
  ariaLabel="Text alignment"
  defaultValue="center"
  options={[
    { value: 'left', label: <AlignLeftIcon />, ariaLabel: 'Align left' },
    { value: 'center', label: <AlignCenterIcon />, ariaLabel: 'Align center' },
    { value: 'right', label: <AlignRightIcon />, ariaLabel: 'Align right' },
  ]}
/>

When label is not readable text, provide ariaLabel on the option. The group itself still needs ariaLabel or ariaLabelledby.

4) Overlay with non-square indicator and anchor

<SegmentedChoice
  ariaLabel="Tip"
  defaultValue="10"
  className="tip-choice"
  options={[
    { value: '5', label: '5%' },
    { value: '10', label: '10%' },
    { value: '15', label: '15%' },
  ]}
  geometry={{
    mode: 'overlay',
    dragScale: true,
    track: { layout: 'center-span', style: 'none' },
    anchor: { width: 20, height: 13 },
    indicator: {
      width: 40,
      height: 25,
      style: 'fill',
      content: 'none',
    },
  }}
/>
.tip-choice.rsc-root .rsc-track {
  --rsc-track-size: 6px;
  background: #dbeafe;
}

.tip-choice.rsc-root .rsc-option-anchor {
  background: #93c5fd;
}

.tip-choice.rsc-root .rsc-option-label {
  margin-top: 50px;
}

This pattern uses center-span to run the track from the first option center to the last option center. The styled .rsc-track becomes the visible rail, explicit anchors provide compact targets and a fixed indicator moves along that rail.

With track.style = "none", the component does not draw the default surface. The visible rail and label placement are yours to style with CSS. This example moves labels below the rail only to keep the rail shape easy to read.

5) Overlay with clone-active

<SegmentedChoice
  ariaLabel="Editor mode"
  defaultValue="compose"
  options={[
    { value: 'draft', label: 'Draft' },
    { value: 'compose', label: 'Compose' },
    { value: 'review', label: 'Review' },
  ]}
  geometry={{
    mode: 'overlay',
    indicator: {
      style: 'fill',
      content: 'clone-active',
    },
    track: { style: 'none' },
  }}
/>

Use clone-active when the moving overlay should carry the active option content. It previews the target selection during drag. It does not reorder options.

6) Slot-level analytics attrs

<SegmentedChoice
  ariaLabel="Density"
  defaultValue="comfortable"
  options={[
    { value: 'comfortable', label: 'Comfortable' },
    { value: 'compact', label: 'Compact' },
  ]}
  slotProps={{
    root: { 'data-qa': 'density-choice' },
    option: { 'data-track': 'density-option' },
    indicator: { 'aria-hidden': 'true' },
  }}
/>

7) Scoped option-state styling

<SegmentedChoice
  ariaLabel="Theme"
  defaultValue="system"
  className="my-choice"
  options={[
    { value: 'day', label: 'Day' },
    { value: 'week', label: 'Week' },
    { value: 'month', label: 'Month' },
  ]}
/>
.my-choice .rsc-option:first-of-type .rsc-option-content,
.my-choice .rsc-option:last-of-type .rsc-option-content {
  font-weight: 600;
}

.my-choice .rsc-option[data-selected='true'] .rsc-option-content {
  color: #111827;
}

.my-choice .rsc-option[data-disabled='true'] .rsc-option-content {
  opacity: 0.45;
}

.my-choice .rsc-option[data-previewed='true'] .rsc-option-label {
  color: #2563eb;
}

data-selected is the committed value. data-previewed is the drag target and appears only while dragging.

8) CSS-first theming

<SegmentedChoice
  ariaLabel="Theme"
  defaultValue="system"
  className="my-theme-choice"
  options={[
    { value: 'light', label: 'Light' },
    { value: 'system', label: 'System' },
    { value: 'dark', label: 'Dark' },
  ]}
/>
.my-theme-choice {
  --rsc-surface: #0f172a;
  --rsc-text-color: #93c5fd;
  --rsc-active-text-color: #f8fafc;
  --rsc-indicator-color: #2563eb;
  --rsc-border-color: #1e293b;
}

9) unstyled with custom CSS

<SegmentedChoice
  ariaLabel="Billing period"
  defaultValue="monthly"
  className="billing-period"
  unstyled
  options={[
    { value: 'monthly', label: 'Monthly' },
    { value: 'yearly', label: 'Yearly' },
  ]}
/>
.billing-period {
  --rsc-text-color: #64748b;
  --rsc-active-text-color: #0f172a;
  --rsc-focus-ring-color: rgb(37 99 235 / 0.35);
}

.billing-period .rsc-list {
  gap: 4px;
}

.billing-period .rsc-option-content {
  border: 1px solid #cbd5e1;
  border-radius: 6px;
}

.billing-period .rsc-option[data-selected='true'] .rsc-option-content {
  background: #dbeafe;
  border-color: #60a5fa;
}

unstyled removes the bundled visual skin, not the behavior or DOM structure. Stable slots, radio semantics, keyboard behavior, drag behavior and geometry logic still apply.

10) Complex domain objects through string IDs

const plans = [
  { id: 'plan-free', value: { seats: 1, tier: 'free' } },
  { id: 'plan-pro', value: { seats: 10, tier: 'pro' } },
] as const;

const byId = new Map(plans.map(plan => [plan.id, plan.value]));

<SegmentedChoice
  ariaLabel="Plan"
  defaultValue="plan-free"
  options={plans.map(plan => ({
    value: plan.id,
    label: plan.value.tier,
  }))}
  onValueChange={id => {
    const selectedPlan = byId.get(id);
    if (selectedPlan) {
      savePlan(selectedPlan);
    }
  }}
/>;

Keep rich values in your app model and pass stable string IDs to the component. That keeps DOM values, form behavior and TypeScript generics aligned with the public SegmentedChoiceValue = string contract.

Guidance: geometry vs CSS vs slotProps

Use this split when you are deciding where a customization belongs:

  • geometry: mechanics and layout math.
  • internal runtime stylesheet: instance-scoped layout values.
  • CSS variables and selectors: look and theme.
  • slotProps: attrs/events/class hooks for integration.

If a customization can be expressed in CSS, prefer CSS over adding a new JS prop.

For drag feedback, data-dragging marks the active pointer drag phase. data-drag-released is briefly true after a pointer drag ends. It is there for optional CSS-only release feedback; the library does not ship a default release animation.

Browser Support

The supported baseline is current and previous major versions of Chrome, Edge and Safari.

Visual regression runs in CI on Chromium and WebKit.