Navigation

Segmented Control

An iOS-style exclusive picker whose selected thumb slides between options, with cva sizes and full radiogroup keyboard support.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const segmentedControlVariants = cva(
  "relative items-center rounded-lg bg-muted p-1 text-muted-foreground",
  {
    variants: {
      size: {
        sm: "h-8",
        md: "h-10",
      },

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/segmented-control.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SegmentedControl" component using
class-variance-authority (cva) for the size axis. No animation library — the
thumb is a measured transform plus a CSS transition.

Contract
- Export a forwardRef <div> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> plus
  VariantProps<typeof segmentedControlVariants>.
- options: { value, label, icon?: ReactNode, disabled?: boolean }[].
- Controlled: value: string and onValueChange: (value: string) => void —
  the component holds no selection state of its own.
- size?: "sm" | "md" (default "md"), fullWidth?: boolean (default false),
  label?: string (default "View") for the group's aria-label.

Behavior
- Semantics: the track is role="radiogroup" with an aria-label; each option is
  a type="button" with role="radio" and aria-checked. Disabled options get the
  native disabled attribute.
- Roving tabindex: exactly one option is tabbable — the selected one, or the
  first enabled option when value matches nothing — so Tab enters and leaves
  the group in a single stop.
- Keyboard: ArrowRight / ArrowLeft move cyclically through the *enabled*
  options only and select immediately (selection follows focus); Home / End
  jump to the first / last enabled option. Chain the consumer's onKeyDown
  first and bail out if it called preventDefault.
- Thumb: keep a Map of option value -> button element via callback refs. In an
  effect keyed on [value, options] read the active button's offsetLeft /
  offsetWidth into state; the track is position:relative, so it is both the
  thumb's containing block and its offsetParent and offsetLeft maps straight
  to translateX. Compare before setState so an unchanged measurement never
  re-renders.
- Recompute on resize with a ResizeObserver on the track (fullWidth layouts and
  late font loading change option widths); disconnect on unmount and skip when
  ResizeObserver is undefined.
- Mount must not animate: render the thumb without a transition class, then
  enable transitions inside a requestAnimationFrame (cancelled on unmount), so
  it appears in place instead of sliding in from the left edge.

Rendering & styling
- Semantic tokens only: track bg-muted with p-1 and text-muted-foreground;
  thumb bg-background + shadow-sm + rounded-md; selected label text-foreground;
  unselected hover:text-foreground; disabled opacity-50 + cursor-not-allowed.
- cva: size drives the track height (h-8 / h-10) and a paired option class
  (h-6 px-2.5 text-xs / h-8 px-3 text-sm); fullWidth switches the track between
  inline-flex and flex w-full and makes each option flex-1.
- The thumb is absolute inset-y-1 left-0, aria-hidden, pointer-events-none and
  precedes the options in the DOM; options are position:relative so their text
  paints above it.
- Reduced motion: motion-reduce:transition-none on the thumb — it still moves,
  it just stops animating.
- focus-visible:ring-2 ring-ring with ring-offset-1 ring-offset-background so
  the ring reads against the muted track. Merge consumer className via cn().

Customization levers
- Size axis: add "lg" as one cva entry plus one OPTION_SIZE entry — keep the
  track height exactly two padding steps above the option height, or the thumb
  will not fit the track.
- Glide feel: duration-200 ease-out on transition-[transform,width]; a spring
  is unnecessary at this travel distance.
- Tokens: bg-muted track + bg-background thumb is the "recessed" look; invert
  to a transparent track with a border and a bg-primary thumb
  (text-primary-foreground) for an emphasised toggle.
- Icon-only mode: pass icon and keep label for the accessible name, then hide
  the text with sr-only — the measuring code is width-agnostic.
- fullWidth is the responsive lever: inline in toolbars, full width inside a
  narrow card or a mobile sheet.

Concepts

  • Value picker, not a tab strip — the control reports a value and nothing else; it never owns panels, so it drops into a filter bar or toolbar where tablist semantics would be wrong.
  • Measured thumb — the sliding block reads the selected option's own box instead of assuming equal widths, which is what lets "Day / Week / Month" and icon+label options share one implementation.
  • Mount snap, then glide — enabling the transition one frame after the first measurement is the difference between "the thumb is already under Week" and "the thumb flies in from the left on every page load".
  • Roving tabindex — one Tab stop for the whole group: only the selected (or first enabled) option is focusable and arrows move inside the group, matching the WAI-ARIA radio-group pattern.
  • Disabled options are skipped, not just dimmed — keyboard traversal filters them out entirely, so a disabled option can never become a focused-but-unselectable dead end.
  • Reduced-motion honesty — under prefers-reduced-motion the thumb jumps to the new option; selection, focus and semantics are untouched.

On This Page