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.
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;
};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()andvar(--token). Unsupported values are ignored and warn in development.
value?: T: controlled value. If you pass it, the parent must pass the committed value back afteronValueChange.defaultValue?: T: initial value for uncontrolled mode.onValueChange?: (value: T) => void: called when selection commits.
name?: string: radio group name. If omitted, the component generates a stable internal name.required?: boolean: passed to the underlying radio inputs.
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.
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:
equalmeasures the widest option content and uses that width for every option box.contentlets each option box follow its own content width.geometry.optionSizeresolves sizing to fixed square option boxes and overridesoptionSizing.
optionDistribution only becomes visible when the surface has extra room:
space-betweenplaces spare space between option boxes.space-aroundalso 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.
ariaLabel?: stringariaLabelledby?: stringariaDescribedby?: string
Use at least one group labelling strategy: ariaLabel or ariaLabelledby.
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.
Rendered slots do not receive style={...} from the component.
What this means:
- public theming stays overrideable from app CSS
slotProps.styleis 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
SegmentedChoice validates its option model before rendering.
- at least 2 options are required
- every
option.valuemust be a unique string - invalid structures return
nulland emit a dev warning - radiogroup labelling requires
ariaLabelorariaLabelledby
That keeps runtime behavior predictable for consumers and avoids half-valid controls.
Dynamic geometry is written into a shared document stylesheet instead of inline slot styles.
- pass
styleNonceto attach a nonce to that stylesheet - instances in one document reuse the same stylesheet when they share a nonce
- distinct
styleNoncevalues create separate runtime style hosts in the same document
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
The component is easier to reason about in four layers:
options, selection props and interaction props define state and semantics.geometrydefines 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.- Public CSS variables, stable classes and stable data attributes define appearance.
slotPropslets 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.
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.
"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.
falseorundefined: no extra drag scaling.true: scales to1.1while dragging.number: exact custom scale factor while dragging (for example1.25).
dragScale affects the indicator while an active pointer drag is in progress. It does not change option hitboxes or the committed value.
- 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.
size: shorthand for square anchor.widthandheight: non-square anchor geometry.- If
width/heightare provided, they overridesizeper 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.
"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.
"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.
size: shorthand for square indicator.widthandheight: 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 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.
"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',
},
}}
/>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.
styleis not public API and is ignored. - owning root radiogroup semantics such as
role,ariaLabel,ariaLabelledby,ariaDescribedbyoraria-orientation.
Handler ordering:
- internal list pointer/focus handlers run before
slotProps.listhandlers - 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' },
}}
/>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-descriptionUse .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;
}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"whenoption.descriptionis present.data-previewed:"true"on the option currently targeted during an active drag. It can differ fromdata-selecteduntil 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.
Keep these selectors scoped:
- Scope option-state selectors by component hooks such as
.rsc-rootand.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;
}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.
| 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. |
| 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. |
| 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. |
| 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. |
| Variable | What it controls |
|---|---|
--rsc-track-size |
Rail thickness for center-span tracks. |
| 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. |
| 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:
- 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-*.
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.
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.
<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.
<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.
<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.
<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.
<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' },
}}
/><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.
<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;
}<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.
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.
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.
The supported baseline is current and previous major versions of Chrome, Edge and Safari.
Visual regression runs in CI on Chromium and WebKit.