Segmented Control

A radiogroup-style segmented control with a sliding indicator behind the active option. Keyboard navigable (←/→/↑/↓ to step, Home/End to jump). Equal-width segments via flex-1; indicator position computed from activeIdx so any segment count works. Inspired by Notion.

Buttons & BadgesInspired by Notion
Live PreviewInteractive
Notion · View switcher

View

value: board

Range

value: week

Click a segment or use ←/→ · Home / End jump to ends · Indicator slides between options

SegmentedControl.tsx
// Dependencies: react ^18, lucide-react
import React, { useId, useRef } from 'react';
import type { LucideIcon } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

export type Segment = {
  id: string;
  label: string;
  icon?: LucideIcon;
};

type Props = {
  segments: Segment[];
  value: string;
  onChange: (id: string) => void;
  ariaLabel?: string;
};

export function SegmentedControl({
  segments,
  value,
  onChange,
  ariaLabel = 'View mode',
}: Props) {
  const groupId = useId();
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const activeIdx = Math.max(0, segments.findIndex((s) => s.id === value));

  const move = (delta: number) => {
    const next = (activeIdx + delta + segments.length) % segments.length;
    onChange(segments[next].id);
    requestAnimationFrame(() => btnRefs.current[next]?.focus());
  };

  const handleKey = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      move(1);
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      move(-1);
    } else if (e.key === 'Home') {
      e.preventDefault();
      onChange(segments[0].id);
      requestAnimationFrame(() => btnRefs.current[0]?.focus());
    } else if (e.key === 'End') {
      e.preventDefault();
      const last = segments.length - 1;
      onChange(segments[last].id);
      requestAnimationFrame(() => btnRefs.current[last]?.focus());
    }
  };

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      className="relative inline-grid p-1 bg-zinc-800/60 border border-white/[0.06] rounded-lg"
      style={{ gridTemplateColumns: `repeat(${segments.length}, 1fr)` }}
      onKeyDown={handleKey}
    >
      <div
        aria-hidden
        className="absolute top-1 left-1 h-[calc(100%-0.5rem)] bg-zinc-900 border border-white/[0.08] rounded-md shadow-sm transition-transform duration-200 ease-out"
        style={{
          width: `calc((100% - 0.5rem) / ${segments.length})`,
          transform: `translateX(${activeIdx * 100}%)`,
        }}
      />

      {segments.map((s, i) => {
        const isActive = i === activeIdx;
        const Icon = s.icon;
        return (
          <button
            key={s.id}
            ref={(el) => { btnRefs.current[i] = el; }}
            id={`${groupId}-${s.id}`}
            role="radio"
            aria-checked={isActive}
            tabIndex={isActive ? 0 : -1}
            onClick={() => onChange(s.id)}
            className={cn(
              'relative z-10 w-full flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-colors whitespace-nowrap rounded-md',
              isActive ? 'text-white' : 'text-zinc-500 hover:text-zinc-300'
            )}
          >
            {Icon && <Icon className="w-3.5 h-3.5" />}
            {s.label}
          </button>
        );
      })}
    </div>
  );
}

Unlock to copy

Free access to all patterns