WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Segmented Control: iOS Thumb, Drag and When to Use It
  1. Blog
  2. Mobile UI in React
  3. React Segmented Control: iOS Thumb, Drag and When to Use It
Mobile UI in React
Mobile UI in React

React Segmented Control: iOS Thumb, Drag and When to Use It

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 8 min read

On this page

0%
  1. What is a segmented control, in web terms?
  2. How do you add a segmented control in React?
  3. What makes it feel like the iOS control?
  4. Is a segmented control accessible?
  5. How should a segmented control behave on a phone?
  6. Segmented control vs tabs, a toggle group or radio cards: which one?
  7. What goes wrong with segmented controls?
  8. What does a complete example look like?
  9. Where should you start?
  10. FAQ
    1. Does shadcn/ui have a segmented control?
    2. What is the difference between a segmented control and tabs?
    3. How many options should a segmented control have?
    4. Is a segmented control accessible to screen readers?
    5. Can a segmented control submit with a form?
    6. Is the Wingo UI Segmented Control free?

TL;DR

A React segmented control is a radio group drawn as a row of 2 to 5 segments with a raised thumb on the selected one: one tab stop, arrow keys that move and select, and a 44px height on touch screens. Use it when the choice changes the content already on screen (a time range, a view, units); use Tabs when it swaps in a different panel, a Toggle Group when several options can be on, and radio cards when each option needs a description. The free Wingo UI Segmented Control adds the iOS details: a spring thumb you can drag sideways with a finger, while a vertical swipe still scrolls the page.

Published Oct 9, 2026

A React segmented control is the row of two to five options with a raised thumb under the selected one, the control iOS uses to pick the time range of the charts in the Health app. Search for an iOS segmented control web component and most results are React Native packages, and shadcn/ui ships none as of October 2026. This guide covers what the web version has to get right (the thumb, touch, keyboard and radio semantics), then the decision that matters more: when Tabs, a Toggle Group or radio cards are the better control. The examples use the free Segmented Control from Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.

What is a segmented control, in web terms?

It is a radio group with a different look. Each segment is one option, exactly one is selected, and selecting one applies at once without a submit button. Apple's Human Interface Guidelines (opens in a new tab) describe it as a way to offer "closely related choices that affect an object, state, or view." Material Design 3 has a close relative, the segmented button (opens in a new tab), so you also see the control searched as a React segmented button.

That framing settles the implementation: the markup should be a radiogroup with radio items, the WAI-ARIA radio group pattern (opens in a new tab) defines the keyboard, and a name should make it submit like native radios. The thumb, the spring and the drag sit on top of that.

How do you add a segmented control in React?

Install it, pass options, and either let it hold its own value or control it:

bash
npx wingo-ui@latest add segmented-control
tsx
"use client";
import { useState } from "react";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
const periods: SegmentedControlOption[] = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
];
export function SalesPeriod() {
const [period, setPeriod] = useState("week");
return (
<SegmentedControl
aria-label="Period"
options={periods}
value={period}
onValueChange={setPeriod}
fullWidth
/>
);
}

Each option takes value, label, and optionally icon, badge (a number renders a count that hides at 0), tooltip and disabled. The props that change the look are size (sm, md or lg, a 32, 40 or 48px track), radius, tone (default neutral, a white thumb like iOS that turns a lifted gray in dark mode) or any CSS color, and dividers, the hairlines between unselected segments. Leave defaultValue out and it starts on the first enabled option.

Tap or click Month, then press the thumb and slide it sideways: the value follows your finger and the sales total updates. With the keyboard, Tab in and use the arrow keys
$ npx wingo-ui@latest add segmented-control
FreeSegmented Control docs

If you came for a React segmented control Tailwind recipe, the whole look is Tailwind v4 utilities on design tokens, and every inner part takes classes through classNames (item, thumb, icon, label, badge, divider). App-wide defaults go through the free UIProvider: <UIProvider defaults={{ "segmented-control": { radius: "full" } }}> rounds every segmented control in the app.

What makes it feel like the iOS control?

Three details: the thumb moves on a spring instead of a linear tween, it follows the finger, and it never steals a scroll.

The thumb animates transform only. It is rendered inside the selected segment, so CSS sizes it at rest. When the value changes, it measures the box it is leaving, jumps there as a transform and springs back to zero offset using the snappy spring from lib/motion (stiffness 500, damping 32). Animating left and width would recalculate layout on every frame; a transform stays on the compositor. Its corner radius is divided by the current scale, so a thumb stretching between a short and a long label keeps round corners. A change mid-flight starts from where the thumb is on screen and keeps its speed.

It answers the finger. Pressing the thumb dips it to 96%. Keep holding and slide sideways and the value follows the segment under your finger, then settles where you let go. The hairlines next to the thumb fade out, like on iOS.

A vertical swipe still scrolls the page. The track sets touch-action: pan-y, so the browser keeps vertical scrolling, and the code waits for 8px of movement before deciding what the gesture is. This is the part to copy if you build your own, trimmed from the component:

tsx
// track: className="touch-pan-y"; drag.current is recorded on pointerdown over the thumb, SLOP is 8
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
const state = drag.current;
if (!state || state.id !== event.pointerId) return;
if (!state.active) {
const dx = Math.abs(event.clientX - state.x);
const dy = Math.abs(event.clientY - state.y);
// vertical first: a page scroll, let the browser have it
if (dy > SLOP && dy > dx) return endPress();
if (dx <= SLOP) return;
state.active = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
// pick the segment under clientX from rects measured on pointerdown
}

touch-action: none on the track would make the drag simpler and trap anyone who starts a scroll on the control. After a drag, the click that follows lands on the segment where the drag began, so the component swallows it; otherwise the value snaps back.

With reduced motion turned on, the thumb jumps to its new place and the press dip is off.

Is a segmented control accessible?

It is when the semantics are a radio group, and that comes almost free from Radix. The Segmented Control is built on @radix-ui/react-radio-group, so the track is a radiogroup, each segment a radio with aria-checked, and the whole control is a single tab stop. Left and Right (Up and Down too) move focus and select, which matches the APG rule for a radio group outside a toolbar. We added Home and End, disabled segments are skipped, and loop={false} stops the arrows at the ends. Mantine takes the same route: its SegmentedControl docs (opens in a new tab) (v9.7.1, October 2026) say it "uses radio inputs under the hood."

Four things are on you:

  • A name. Pass aria-label, or aria-labelledby pointing at a visible heading; both land on the radiogroup.
  • Text for icons. Give icon options a label anyway. With iconOnly, it becomes the segment's aria-label and its tooltip, shown on hover, on keyboard focus and on a long press on touch.
  • A form name. Pass name and, inside a <form>, the selected value submits like native radios.
  • A non-drag path. WCAG 2.5.7 Dragging Movements (opens in a new tab) (level AA) asks that anything done by dragging also work with a single pointer without dragging. Tapping a segment is that path, so keep it. Never make the drag the only way.

One trap if you build on Radix yourself: Radix checks the next radio only if the arrow key is still down when its deferred focus lands, so a very quick press (a screen reader, a remote, automation) can move focus without checking anything. The component selects on any focus that follows an arrow key, so a fast press still changes the value.

How should a segmented control behave on a phone?

It should be 44px tall on touch, share the width, and never flash gray on tap. The md size is a 40px track with a mouse and grows to 44px on coarse pointers (pointer-coarse:h-11). The sm size keeps its 32px look and gets an invisible hit area that reaches 44px; lg is 48px everywhere. The reasoning behind 44px, and the Tailwind variants that apply it only on touch, are in minimum touch target size for React and Tailwind.

fullWidth gives each segment an equal share of the container, the usual layout under a phone header. A segment with a long label keeps its natural width instead of truncating, and the others share what is left. Every segment carries touch-manipulation and a transparent -webkit-tap-highlight-color, so a quick second tap never zooms the page and iOS shows no gray flash. Set dir="rtl" and the layout, the arrow keys and the drag mirror.

The rest of the rules (sheets instead of popovers, safe areas, hover alternatives) are in the pillar on mobile-first React components, and the Mobile UI in React category collects every guide on the topic.

Segmented control vs tabs, a toggle group or radio cards: which one?

Pick by what the choice changes. The look tells you little, since Wingo UI's Tabs have a segmented variant with the same raised thumb. What differs is the semantics, and with them what a screen reader user expects to happen next.

The choiceUseWhat assistive tech getsWingo UI
Changes the content already on screen: a time range, a view, units; 2 to 5 short optionsSegmented Controlradiogroup of radio; arrows move and selectFree
Swaps in a different panel of contentTabs, variant="segmented" for this looktablist, tab, tabpanel linked by aria-controlsFree
Changes the URLNav Tabslinks in a nav, the current one with aria-current="page"Pro
Several options can be on at once (bold and italic, amenities)Toggle Group type="multiple"buttons with aria-pressedPro
Each option needs a description or a priceRadio Group variant="card"radiogroup of radioFree
More than five options, or long labelsSelecta combobox that opens a listbox (a bottom sheet on phones)Free

Apple draws a similar line on iOS: "For switching between completely separate sections of an app, use a tab bar instead." Here are the same segments as Tabs, each with its own panel:

tsx
import { Tabs } from "@/components/ui/tabs";
// ProfileForm and BillingForm are your own components
export function AccountTabs() {
return (
<Tabs
variant="segmented"
fullWidth
aria-label="Account"
options={[
{ value: "profile", label: "Profile", content: <ProfileForm /> },
{ value: "billing", label: "Billing", content: <BillingForm /> },
]}
/>
);
}
The same thumb as the segmented control, but each segment opens its own panel: switch from Active to Sold and the list slides in from the side you moved toward
$ npx wingo-ui@latest add tabs
FreeTabs docs

The Toggle Group needs one more note. In single mode Radix also renders it as a radiogroup, but its arrow keys only move focus and Space or Enter presses the item. That is the APG rule for a radio group inside a toolbar, where "the button that is checked does not change" while you arrow through. So an alignment switch in an editor toolbar is a single Toggle Group, and a stand-alone time range is a segmented control. When several can be on, the Toggle Group in multiple mode is the only one of the four that fits:

tsx
import { ToggleGroup } from "@/components/ui/toggle-group";
export function AmenityFilter() {
return (
<ToggleGroup
type="multiple"
variant="outline"
aria-label="Amenities"
defaultValue={["parking"]}
options={[
{ value: "parking", label: "Parking" },
{ value: "pets", label: "Pets allowed" },
{ value: "laundry", label: "In-unit laundry" },
]}
/>
);
}
Bold starts on. Press Italic and both stay on, because the formatting group is type multiple; the alignment group next to it is single, so pressing one releases the other
$ npx wingo-ui@latest add toggle-group
ProToggle Group docs

Radio cards win when the options are not self-explanatory. "Courier, Locker, Pickup" fits in a segmented control; "Home delivery, FedEx, 1-2 days, $9.99" does not, and squeezing it into a segment hides the price that decides the choice. The React form components guide covers the wider choice between a radio group, a select and a combobox in a form.

What goes wrong with segmented controls?

Four things: too many segments, icons mixed with text, a value outside the options, and a missing name.

  • Too many segments. Apple says "no more than about five segments on iPhone", and Flutter's SegmentedButton docs (opens in a new tab) say they are "typically used in cases where there are only 2-5 options." Six or more labels on a 360px screen get cramped or cut off; switch to a select or scrolling tabs.
  • Mixing icons and text. Apple's guidelines prefer one or the other in a single control. Pick text, or iconOnly with labels as names.
  • A controlled value that is not in options. The control shows no thumb, which looks broken. This usually happens when options load after the value.
  • No name. A radiogroup without aria-label reads as an unnamed group of radios.

What does a complete example look like?

An orders screen that filters one list by status. The choice changes the content on screen and there are four short options, so this is the segmented control's job. Counts ride on badges, and a status line tells screen readers how many orders are visible:

tsx
"use client";
import { useState } from "react";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
type Order = { id: string; customer: string; total: number; status: "open" | "shipped" | "returned" };
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
export function OrdersPanel({ orders }: { orders: Order[] }) {
const [status, setStatus] = useState("all");
const count = (value: Order["status"]) => orders.filter((order) => order.status === value).length;
const options: SegmentedControlOption[] = [
{ value: "all", label: "All" },
{ value: "open", label: "Open", badge: count("open") },
{ value: "shipped", label: "Shipped" },
{ value: "returned", label: "Returned", badge: count("returned") },
];
const visible = status === "all" ? orders : orders.filter((order) => order.status === status);
return (
<section className="flex flex-col gap-4">
<SegmentedControl
aria-label="Order status"
options={options}
value={status}
onValueChange={setStatus}
fullWidth
className="sm:max-w-md"
/>
<p role="status" className="text-sm text-muted-foreground">
{visible.length} {visible.length === 1 ? "order" : "orders"}
</p>
<ul className="flex flex-col divide-y divide-border">
{visible.map((order) => (
<li key={order.id} className="flex justify-between gap-4 py-3 text-sm">
<span className="truncate">{order.customer}</span>
<span className="tabular-nums">{usd.format(order.total)}</span>
</li>
))}
</ul>
</section>
);
}

In a form, drop the state and give it a name. A server action then reads it like any radio:

tsx
<form action={saveDelivery}>
<SegmentedControl
name="delivery"
aria-label="Delivery"
defaultValue="locker"
fullWidth
options={[
{ value: "courier", label: "Courier" },
{ value: "locker", label: "Locker" },
{ value: "pickup", label: "Pickup" },
]}
/>
{/* in saveDelivery: formData.get("delivery") */}
</form>

Where should you start?

Install the free control and drop it where a time range, a view switch or a unit toggle lives today:

bash
npx wingo-ui@latest add segmented-control

Tabs and Radio Group are free too. The Toggle Group and Nav Tabs from the table above are part of Wingo UI Pro.

Components in this post

  • Segmented Control

    The iOS segmented control: pick one of a few options on a raised thumb that slides, and on touch drags along with your finger.

    Free
  • Tabs

    Switch between views of the same page with a sliding indicator: underline for sections, pill for filters, segmented for a compact switch.

    Free
  • Toggle Group

    A row of toggles where one or several stay pressed, for text formatting, alignment, view switches and quick filters.

    Pro
  • Radio Group

    Pick exactly one of a few visible options: rows with a dot that springs in, or cards with icons, prices and a selection ring that slides.

    Free

FAQ

Does shadcn/ui have a segmented control?

Not as of October 2026. Its component list has Tabs, Toggle Group, Radio Group and Button Group, so building one on shadcn/ui means restyling one of those. Pick the base by what the choice does: Tabs for panels, a radio group for a value.

What is the difference between a segmented control and tabs?

Tabs swap in a different panel and expose tablist, tab and tabpanel roles linked by aria-controls. A segmented control picks a value that changes the content already on screen, such as a time range, and is a radiogroup. Apple's guidelines send switching between separate sections of an app to a tab bar.

How many options should a segmented control have?

Two to five. Apple's Human Interface Guidelines say no more than about five segments on iPhone, and Flutter's Material SegmentedButton docs say segmented buttons are typically used with 2 to 5 options. Past five, use a select or a scrolling row of tabs.

Is a segmented control accessible to screen readers?

It is when it is built as a radiogroup of radio items with a name: one tab stop, arrow keys that move and check, and aria-checked on the selected segment. Icon-only segments also need a text name, which Wingo UI's iconOnly mode takes from each option's label.

Can a segmented control submit with a form?

Yes. Give Wingo UI's Segmented Control a name and, inside a form, it submits the selected value like a radio group, so FormData and server actions read it with formData.get.

Is the Wingo UI Segmented Control free?

Yes. The Segmented Control, Tabs, Radio Group and Select are free and install with npx wingo-ui@latest add. The Toggle Group and Nav Tabs are part of Wingo UI Pro.

  • Mobile UI
  • React
  • Accessibility
  • Tailwind CSS
  • iOS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Minimum Touch Target Size: WCAG, iOS, Android and Tailwind

Minimum touch target size per WCAG 2.2 (24 and 44px), iOS (44pt) and Android (48dp), and how to get 44px taps in React and Tailwind without desktop bloat.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI