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,namedisabled,required,orientation,optionSizing,optionDistribution,sizedraggable,loopariaLabel,ariaLabelledby,ariaDescribedbyclassName,styleNonce,unstyled,slotProps,geometry
Layout props worth knowing early:
optionSizingcontrols option box dimensions:equaluses the widest measured option content for every option,contentlets each option follow its own content width andgeometry.optionSizeswitches to fixed square option boxes.optionDistributioncontrols option placement when the surface has extra space. The default isspace-between;space-aroundadds outer breathing room too. Surface width itself still comes from normal CSS layout.
Structural runtime guarantees:
optionsmust contain at least 2 entries- every
options[i].valuemust be a unique string - invalid option structures render nothing and warn in development
- the radiogroup must be labelled with
ariaLabelorariaLabelledby
For complete prop-by-prop reference with examples, see API.md.
Option fields:
value: unique string selection valuelabel: rendered option contentariaLabel: accessible label for icon-only or non-text labelsdescription: optional secondary contentdisabled: disables one optionaccentColor: 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-conflictingaria-*, 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
styleNoncewhen your app uses strict CSP withoutunsafe-inline - instances in the same document share one runtime style host per nonce value
- controls with different
styleNoncevalues 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-sizedata-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-visibledata-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-bgoverrides the overlay fill hover color. Without it, overlay fill hover preserves activeaccentColor; 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,documentornavigator - 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
- Component state and
geometrydefine mechanics/layout. - Internal runtime stylesheet applies instance-scoped layout values.
- Public CSS variables define theme and visual output.
- Class/data selectors define contextual styling.
slotPropsadds 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
ariaLabelper 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()andvar(--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 afteronValueChange.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:
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.
Accessibility
ariaLabel?: stringariaLabelledby?: stringariaDescribedby?: 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.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
Structural Validation
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.
CSP and Runtime Styles
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
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:
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.
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
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.
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.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.
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.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
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.
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' },
}}
/>
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"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.
Data Attribute Scoping Best Practices
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;
}
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.