SegmentedControl
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
SegmentedControl
선택지 사이로 흰 thumb가 미끄러지는 라디오 그룹입니다. 키보드 ←/→ 이동, 반응형 폭 재측정, role=radiogroup을 지원합니다.
옵션 수 — 2개 / 4개
옵션 개수에 따라 thumb 폭이 자동으로 맞춰집니다.
Source Code
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>
);
}