SegmentedControl

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

SegmentedControl

선택지 사이로 흰 thumb가 미끄러지는 라디오 그룹입니다. 키보드 ←/→ 이동, 반응형 폭 재측정, role=radiogroup을 지원합니다.

옵션 수 — 2개 / 4개

옵션 개수에 따라 thumb 폭이 자동으로 맞춰집니다.

Source Code
<SegmentedControl
  options={[{ value: 'all', label: '전체' }, ...]}
  value={view}
  onChange={setView}
/>
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

SegmentedControltypescript
'use client';

/**
 * SegmentedControl 컴포넌트
 *
 * 선택지 사이로 흰 thumb가 미끄러지는 세그먼티드 컨트롤(라디오 그룹).
 * - 선택된 버튼의 위치/너비를 측정해 thumb를 transform 없이 left/width로 이동.
 * - 컨테이너 폭이 바뀌면(반응형) 다시 측정.
 * - 키보드 ←/→·↑/↓ 로 이동, role=radiogroup/radio + roving tabindex.
 */

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export interface SegmentedOption {
  value: string;
  label: ReactNode;
}

export interface SegmentedControlProps {
  options: SegmentedOption[];
  value: string;
  onChange: (value: string) => void;
  ariaLabel?: string;
  size?: 'sm' | 'md';
  className?: string;
}

export function SegmentedControl({
  options,
  value,
  onChange,
  ariaLabel = '선택',
  size = 'md',
  className,
}: SegmentedControlProps) {
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [thumb, setThumb] = useState({ left: 0, width: 0 });
  const index = Math.max(0, options.findIndex((o) => o.value === value));

  const measure = () => {
    const btn = btnRefs.current[index];
    if (btn) setThumb({ left: btn.offsetLeft, width: btn.offsetWidth });
  };

  // 선택 변경·옵션 수 변경 시 thumb 위치 재계산
  useLayoutEffect(measure, [index, options.length]);

  // 컨테이너 폭이 바뀌면 다시 측정 (핸들러에서 setState — effect 본문 아님)
  useEffect(() => {
    const onResize = () => measure();
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [index]);

  const move = (dir: number) => {
    const next = (index + dir + options.length) % options.length;
    onChange(options[next].value);
    btnRefs.current[next]?.focus();
  };

  const pad = size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-2 text-sm';

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      onKeyDown={(e) => {
        if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
          e.preventDefault();
          move(1);
        } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
          e.preventDefault();
          move(-1);
        }
      }}
      className={cn('relative inline-flex w-full rounded-2xl bg-slate-100 p-1', className)}
    >
      <span
        aria-hidden
        className="absolute bottom-1 top-1 rounded-xl bg-white shadow-sm transition-[left,width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
        style={{ left: thumb.left, width: thumb.width }}
      />
      {options.map((o, i) => {
        const active = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              btnRefs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(o.value)}
            className={cn(
              'relative z-10 flex-1 cursor-pointer rounded-xl font-bold outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-violet-400',
              pad,
              active ? 'text-slate-900' : 'text-slate-500 hover:text-slate-700',
            )}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}