@mshafiqyajid/react-segmented-control
v0.3.1
Published
Headless segmented-control hook and styled component for React. Buttery sliding indicator, full keyboard nav, themable, SSR-safe, fully typed.
Maintainers
Readme
@mshafiqyajid/react-segmented-control
A polished iOS-style segmented control for React — with a buttery sliding indicator that follows the active segment. Comes in two flavors:
- Styled —
<SegmentedControlStyled>with variants (solid / pill / underline), sizes, tones, full a11y. - Headless —
useSegmentedControl()hook + unstyled<SegmentedControl>primitive. Bring your own UI.
Generic over option type (works with strings, numbers, or full objects). Full keyboard nav. Zero dependencies. SSR-safe. Fully typed. ESM + CJS.
Install
npm install @mshafiqyajid/react-segmented-controlPeer dependency: react >= 17.
Quick start
Styled (recommended)
import { SegmentedControlStyled } from "@mshafiqyajid/react-segmented-control/styled";
import "@mshafiqyajid/react-segmented-control/styles.css";
export function ViewSwitcher() {
const [view, setView] = useState("week");
return (
<SegmentedControlStyled
options={["day", "week", "month"]}
value={view}
onChange={setView}
tone="primary"
/>
);
}Headless
import { SegmentedControl } from "@mshafiqyajid/react-segmented-control";
<SegmentedControl
options={["Code", "Preview", "Tests"]}
defaultValue="Preview"
onChange={(v) => console.log(v)}
/>Hook (full custom UI)
import { useSegmentedControl } from "@mshafiqyajid/react-segmented-control";
function CustomTabs() {
const { options, rootProps, indicatorStyle } = useSegmentedControl({
options: ["Code", "Preview", "Tests"],
});
return (
<div {...rootProps} style={indicatorStyle} className="my-tabs">
<span className="my-indicator" /> {/* uses --rsc-indicator-x / --rsc-indicator-width */}
{options.map((o) => (
<button key={o.index} {...o.buttonProps}>
{o.label}
</button>
))}
</div>
);
}Recipes
Variants
<SegmentedControlStyled options={["A", "B", "C"]} variant="solid" /> {/* default */}
<SegmentedControlStyled options={["A", "B", "C"]} variant="pill" />
<SegmentedControlStyled options={["A", "B", "C"]} variant="underline" />Sizes and tones
<SegmentedControlStyled options={["A","B"]} size="sm" tone="success" />
<SegmentedControlStyled options={["A","B"]} size="md" tone="primary" />
<SegmentedControlStyled options={["A","B"]} size="lg" tone="danger" />Full width
Stretches to its parent and distributes segments evenly.
<SegmentedControlStyled options={["Newest", "Top", "Following"]} fullWidth />Disabled options
<SegmentedControlStyled
options={[
"Free",
"Pro",
{ value: "Enterprise", disabled: true },
]}
/>Disabled options are skipped by arrow keys and ignore clicks.
Object options with custom labels
<SegmentedControlStyled
options={[
{ value: "asc", label: "↑ Ascending" },
{ value: "desc", label: "↓ Descending" },
]}
/>Generic over any value type
type Sort = { field: "name" | "date"; dir: "asc" | "desc" };
<SegmentedControlStyled<Sort>
options={[
{ value: { field: "name", dir: "asc" }, label: "Name ↑" },
{ value: { field: "date", dir: "desc" }, label: "Date ↓" },
]}
defaultValue={{ field: "name", dir: "asc" }}
equals={(a, b) => a.field === b.field && a.dir === b.dir}
/>Theme via CSS variables
.brand-segmented {
--rsc-bg-track: #fef3c7;
--rsc-bg-indicator: #f59e0b;
--rsc-fg-active: #ffffff;
--rsc-radius: 14px;
}<SegmentedControlStyled options={["A", "B", "C"]} className="brand-segmented" />Force a theme without prefers-color-scheme:
<div data-rsc-theme="dark">
<SegmentedControlStyled options={["A", "B", "C"]} />
</div>API
<SegmentedControlStyled>
| Prop | Type | Default | Description |
| -------------- | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------ |
| options | Array<T \| { value: T; label?; disabled? }> | — | Required. Strings/numbers auto-wrapped; pass objects for labels/disabled. |
| value | T | — | Controlled value. |
| defaultValue | T | first opt | Uncontrolled initial value. |
| onChange | (value: T) => void | — | Fires when the active value changes. |
| disabled | boolean | false | Disable the entire control. |
| equals | (a: T, b: T) => boolean | Object.is | Custom equality for object/complex values. |
| variant | "solid" \| "pill" \| "underline" | "solid" | Visual style. |
| size | "sm" \| "md" \| "lg" | "md" | Size. |
| tone | "neutral" \| "primary" \| "success" \| "danger" | "primary" | Color theme. |
| fullWidth | boolean | false | Stretch to container width. |
| label | ReactNode | — | Label rendered above. |
| hint | ReactNode | — | Helper text below. |
useSegmentedControl(options)
Returns { value, options, rootProps, indicatorStyle, setValue }:
options[i].buttonProps— spread onto your<button>to wire it up.rootProps— spread onto the wrapper forrole="radiogroup".indicatorStyle— apply to the wrapper. Sets CSS vars--rsc-indicator-x,--rsc-indicator-width, and--rsc-indicator-ready(0/1).
<SegmentedControl> (headless primitive)
Same options as the hook, plus:
renderOption?: (state) => ReactNode— replace the default button per option.showIndicator?: boolean— toggle the.rsc-indicatorspan (defaulttrue).optionProps?: ...— extra props applied to every default-rendered button.children?: ({ options, value, setValue, indicatorStyle }) => ReactNode— full custom render.
CSS variables
Override on .rsc-root, on the track via className, or on :root:
| Variable | Default | Description |
| ------------------------- | ------------- | --------------------------------- |
| --rsc-bg-track | #f4f4f5 | Track background |
| --rsc-bg-indicator | #ffffff | Indicator (sliding pill) color |
| --rsc-fg-active | #18181b | Active segment label color |
| --rsc-fg-inactive | #52525b | Inactive segment label color |
| --rsc-shadow-indicator | subtle | Indicator drop shadow |
| --rsc-ring | indigo glow | Focus ring |
| --rsc-radius | 9px | Track corner radius |
| --rsc-radius-inner | 7px | Indicator/segment corner radius |
| --rsc-padding | 3px | Track inner padding |
| --rsc-segment-padding-y | 0.4rem | Vertical segment padding |
| --rsc-segment-padding-x | 0.85rem | Horizontal segment padding |
| --rsc-font-size | 0.85rem | Segment font size |
| --rsc-duration | 320ms | Indicator slide duration |
| --rsc-ease-spring | spring curve | Indicator slide easing |
The styled component automatically:
- Switches palette under
prefers-color-scheme: dark - Disables animations under
prefers-reduced-motion: reduce - Uses
ResizeObserverto keep the indicator aligned when fonts/layout change - Forwards
refto the track<div>
Browser support
Modern evergreen browsers. Uses ResizeObserver (universally supported) for indicator measurement. SSR-safe — falls back to useEffect on the server.
License
MIT © Shafiq Yajid
Form integration
<form>
<SegmentedControlStyled
name="plan"
label="Plan"
options={["free", "pro", "team"]}
defaultValue="pro"
required
error={errors.plan}
/>
</form>| Prop | Type | Description |
|---|---|---|
| name | string | Renders a hidden input carrying String(value) for native form submission |
| id | string | Wrapper id used for label association |
| required | boolean | Surfaces aria-required and required on the hidden input |
| error | ReactNode | Inline error text. Flips tone to danger and lands data-invalid="true" |
| invalid | boolean | Force the invalid state without inline error text |
| label / hint | ReactNode | Already supported |
The wrapper lands data-invalid="true" whenever error or invalid is truthy.
What's new in 0.3.0
- Per-segment
badge— option config accepts{ value, label, badge }; badge renders as a pill on the right of the label, with active-state styling. scrollable: true— track scrolls horizontally instead of wrapping when content overflows.equalize: true— every segment is sized to the widest via CSS grid (grid-template-columns: repeat(N, 1fr)).hrefper option — when set, segment renders as<a href>for routing while still wired to the control's keyboard nav.- The hook return shape exposes the new
badgeandhrefso headless consumers see them too.
