Skip to content
UI Kit
Accent color

Segmented control

One choice from a few with a gliding thumb, in three surfaces, with icons, counts and second lines, upright for side panels and full width on phones. A radio group for the arrow keys.

5 variantsOne of 9 components in Buttons

One choice from a few, at a glance

A segmented control picks one option from a few, with a thumb that glides to the chosen one. It works as an APG radio group, so the arrow keys move and screen readers announce the choice, and every segment reaches 44 px on phones.

View switch toggles list, grid or map for the same results, names the panel it controls and fills the width on phones. With counts shows a count in each segment, tinted on the chosen one, on the quiet underline surface above a list. Billing switch puts the thumb in the accent above a price table, prices count to their new value and the saving appears with the yearly choice. Upright with details stands upright from 1024 px with a second line per option next to the detail and becomes a full width row below. Sizes and surfaces shows three sizes, icons only with names for screen readers and the raised, accent and underline surfaces side by side.

Use a segmented control for two to five options that switch a view right away. For more options or choices that need a submit, a select or a radio group fits better. Keep labels the same length where you can, then the thumb glides evenly.

Variants

View switchNew in 1.2

List, grid or map for the same results. The control names the panel it switches (aria-controls), on phones it fills the width.

With countsNew in 1.2

Counts in each segment, tinted on the chosen one. The underline surface sits quietly above a list and the row scrolls sideways when it runs out of room.

Billing switchNew in 1.2

The thumb in the accent color above a price table. Prices count to their new value, the saving appears with the yearly choice.

Upright with detailsNew in 1.2

From 1024 px an upright control with a second line per option next to the detail. Below it becomes a full width row, with icons only on phones.

Sizes and surfacesNew in 1.2

Three sizes, icons only with names for screen readers, and the three surfaces side by side. Small segments still reach 44 px on phones.

Details

What Segmented control brings along, read from the registry and the source.

Category
Buttons
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/segmented-control.tsx
Brings along
utilskiticon
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The SegmentedControlProps interface from components/ui/segmented-control.tsx, with the comments from the code.

  • optionsrequired
    SegmentOption[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • labelrequired
    string

    Accessible name of the group, e.g. "View"

  • sizeoptional
    SegmentedSize

    smmdlg

  • appearanceoptional
    SegmentedAppearance

    surfacesolidunderline

    surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice.

  • orientationoptional
    'horizontal' | 'vertical'
  • stretchoptional
    boolean | 'mobile'

    Fill the width. mobile: only below 640 px, the segments share the width equally there.

  • controlsoptional
    string

    id of the panel the control switches, for aria-controls

Accessibility

Read from the source of the component.

  • Segmented control handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-checked, aria-controls, aria-label and aria-orientation, so screen readers announce purpose and state.
  • Uses the roles radio and radiogroup.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 243 lines
components/ui/segmented-control.tsx
'use client';

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * Segmented control: one value out of two to six, as a single track with a
 * thumb that glides to the choice. More than the plain segmented toggle:
 * three surfaces (raised, accent, underline), counts that stay readable on
 * the thumb, options with a second line, an upright form for side panels and
 * a track that fills the width on phones. APG radio group: one tab stop,
 * the arrow keys move and choose, Home and End jump to the ends.
 *
 * Without JavaScript the chosen segment carries the surface itself, so the
 * server render already shows the choice.
 */

const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

export type SegmentedSize = 'sm' | 'md' | 'lg';
export type SegmentedAppearance = 'surface' | 'solid' | 'underline';

export interface SegmentOption {
  value: string;
  label: string;
  icon?: IconName;
  /** Small number after the label, e.g. unread items */
  count?: number;
  /** Show only the icon, the label becomes the accessible name */
  iconOnly?: boolean;
  /** Second line, for upright controls */
  description?: string;
  disabled?: boolean;
}

export interface SegmentedControlProps {
  options: SegmentOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Accessible name of the group, e.g. "View" */
  label: string;
  size?: SegmentedSize;
  /** surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice. */
  appearance?: SegmentedAppearance;
  orientation?: 'horizontal' | 'vertical';
  /** Fill the width. mobile: only below 640 px, the segments share the width equally there. */
  stretch?: boolean | 'mobile';
  /** id of the panel the control switches, for aria-controls */
  controls?: string;
  className?: string;
}

const SIZE: Record<SegmentedSize, { track: string; item: string; square: string; icon: number; text: string; pad: string }> = {
  sm: { track: 'rounded-lg p-[3px]', item: 'min-h-[30px] gap-1.5 rounded-[6px] px-2.5 max-sm:min-h-[38px]', square: 'w-[30px] px-0 max-sm:w-[38px]', icon: 15, text: 'text-[13px]', pad: '[--pad:3px]' },
  md: { track: 'rounded-[10px] p-1', item: 'min-h-9 gap-2 rounded-[7px] px-3.5', square: 'w-9 px-0', icon: 16, text: 'text-sm', pad: '[--pad:4px]' },
  lg: { track: 'rounded-xl p-1', item: 'min-h-11 gap-2 rounded-[9px] px-4', square: 'w-11 px-0', icon: 18, text: 'text-[15px]', pad: '[--pad:4px]' },
};

interface Thumb {
  x: number;
  y: number;
  w: number;
  h: number;
  animate: boolean;
}

/** A choice of one value from a few, with a gliding thumb. Radio group semantics, arrow keys move and choose. */
export function SegmentedControl({
  options,
  value,
  defaultValue,
  onValueChange,
  label,
  size = 'md',
  appearance = 'surface',
  orientation = 'horizontal',
  stretch,
  controls,
  className,
}: SegmentedControlProps) {
  const locale = useLocale();
  const [inner, setInner] = useState(defaultValue ?? options.find((o) => !o.disabled)?.value ?? '');
  const current = value ?? inner;
  const trackRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<Thumb | null>(null);
  const vertical = orientation === 'vertical';
  const underline = appearance === 'underline';
  const s = SIZE[size];

  const choose = (v: string) => {
    if (value === undefined) setInner(v);
    onValueChange?.(v);
  };

  const measure = useCallback(
    (animate: boolean) => {
      const el = trackRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(current)}"]`);
      if (!el) return setThumb(null);
      setThumb({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, animate });
    },
    [current],
  );
  const first = useRef(true);
  useIsoLayoutEffect(() => {
    measure(!first.current);
    first.current = false;
  }, [measure]);
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const ro = new ResizeObserver(() => measure(false));
    ro.observe(track);
    // Late web fonts change the widths once more
    document.fonts?.ready.then(() => measure(false));
    return () => ro.disconnect();
  }, [measure]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
    if (!keys.includes(e.key)) return;
    e.preventDefault();
    const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
    if (!enabled.length) return;
    const pos = Math.max(0, enabled.indexOf(options.findIndex((o) => o.value === current)));
    const forward = e.key === 'ArrowRight' || e.key === 'ArrowDown';
    const next = e.key === 'Home' ? enabled[0] : e.key === 'End' ? enabled[enabled.length - 1] : enabled[(pos + (forward ? 1 : -1) + enabled.length) % enabled.length];
    choose(options[next].value);
    trackRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus();
  };

  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
  const ready = thumb !== null;
  const full = stretch === true;
  const phoneFull = stretch === 'mobile';

  return (
    <div
      ref={trackRef}
      role="radiogroup"
      aria-label={label}
      aria-orientation={vertical ? 'vertical' : undefined}
      onKeyDown={onKeyDown}
      className={cn(
        'relative isolate max-w-full',
        vertical ? 'flex flex-col items-stretch' : 'inline-flex items-stretch',
        !vertical && full && 'flex w-full',
        !vertical && phoneFull && 'max-sm:flex max-sm:w-full',
        underline
          ? cn(vertical ? 'gap-0.5 border-s border-border ps-0' : 'gap-1 border-b border-border')
          : cn('bg-muted shadow-[inset_0_0_0_1px_var(--border)]', s.track, vertical && 'gap-0.5'),
        s.pad,
        className,
      )}
    >
      {ready && (
        <span
          aria-hidden
          className={cn(
            'pointer-events-none absolute left-0 top-0 -z-10',
            appearance === 'surface' && 'rounded-[inherit] bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_11%,var(--background))]',
            appearance === 'solid' && 'bg-primary shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)]',
            appearance === 'underline' && 'bg-primary',
            !underline && (size === 'sm' ? 'rounded-[6px]' : size === 'md' ? 'rounded-[7px]' : 'rounded-[9px]'),
            thumb.animate && 'transition-[translate,width,height] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none',
          )}
          style={
            underline
              ? vertical
                ? { translate: `-1px ${thumb.y}px`, width: 2, height: thumb.h }
                : { translate: `${thumb.x}px ${thumb.y + thumb.h - 1}px`, width: thumb.w, height: 2 }
              : { translate: `${thumb.x}px ${thumb.y}px`, width: thumb.w, height: thumb.h }
          }
        />
      )}
      {options.map((o) => {
        const on = o.value === current;
        const iconOnly = o.iconOnly && o.icon;
        const onSolid = appearance === 'solid' && on;
        return (
          <button
            key={o.value}
            type="button"
            role="radio"
            aria-checked={on}
            aria-label={iconOnly ? o.label : undefined}
            aria-controls={controls}
            title={iconOnly ? o.label : undefined}
            data-value={o.value}
            disabled={o.disabled}
            tabIndex={on ? 0 : -1}
            onClick={() => choose(o.value)}
            className={cn(
              'relative inline-flex cursor-pointer select-none items-center whitespace-nowrap font-medium outline-none transition-[color,scale,background-color] duration-200 ease-[var(--ease-kit)] active:scale-[0.97] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
              'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
              s.item,
              s.text,
              // The track padding counts toward the finger target: 36 px segment plus 4 px above and below
              !underline && 'after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""]',
              vertical ? 'justify-start text-start' : 'justify-center',
              iconOnly && !vertical && s.square,
              !vertical && (full || phoneFull) && (full ? 'flex-1' : 'max-sm:flex-1'),
              underline && (vertical ? 'min-h-11 rounded-none ps-4 max-sm:min-h-11' : 'min-h-11 rounded-none px-3 pb-px max-sm:min-h-11'),
              on ? (onSolid ? 'text-primary-foreground' : 'text-foreground') : 'text-muted-foreground hover:text-foreground',
              !on && !underline && 'hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]',
              // Until the thumb is measured (server render, no JavaScript) the chosen segment carries the surface itself
              on && !ready && appearance === 'surface' && 'bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)]',
              on && !ready && appearance === 'solid' && 'bg-primary',
              on && !ready && underline && (vertical ? 'shadow-[inset_2px_0_0_var(--primary)]' : 'shadow-[inset_0_-2px_0_var(--primary)]'),
              o.description && vertical && 'py-2.5',
            )}
          >
            {o.icon && <Icon name={o.icon} size={s.icon} className={cn('shrink-0', !on && !onSolid && 'opacity-80', o.description && vertical && 'self-start mt-0.5')} />}
            {!iconOnly &&
              (o.description && vertical ? (
                <span className="min-w-0 flex-1 whitespace-normal">
                  <span className="block leading-5">{o.label}</span>
                  <span className={cn('mt-0.5 block text-[12px] font-normal leading-4', onSolid ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{o.description}</span>
                </span>
              ) : (
                <span className={cn(vertical && 'flex-1')}>{o.label}</span>
              ))}
            {o.count !== undefined && (
              <span
                className={cn(
                  'inline-grid h-5 min-w-5 shrink-0 place-items-center rounded-md px-1.5 text-[11px] font-semibold tabular-nums leading-none transition-[background-color,color] duration-200',
                  onSolid
                    ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] text-primary-foreground'
                    : on
                      ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]'
                      : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground',
                )}
              >
                {nf.format(o.count)}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

Frequent questions

How do I install Segmented control?

With aiw add @ai-website/segmented-control. The command puts components/ui/segmented-control.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Segmented control have?

5 variants, View switch, With counts, Billing switch, Upright with details and Sizes and surfaces.

Can an AI assistant build with Segmented control?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Segmented control with aiw_components and install it with aiw_add @ai-website/segmented-control.

Often searched

Jump to

Copied to the clipboard