Radio
단일 선택 옵션을 제공하는 폼 컴포넌트
개요
Radio 컴포넌트는 Radix UI의 Radio Group Primitive를 기반으로 구축된 라디오 버튼 컴포넌트입니다.
주요 특징
- ✅ Radix UI 기반: 접근성이 검증된 Headless UI
- ✅ 단일 선택: 그룹 내에서 하나만 선택 가능
- ✅ 수평/수직 레이아웃: 유연한 배치 옵션
- ✅ 키보드 네비게이션: 화살표 키로 선택 이동
- ✅ WCAG 2.1 AA: 접근성 표준 준수
설치
CLI로 추가 (권장)
npx @vortex/cli add radio-group자동으로 설치되는 것들:
src/components/ui/radio-group.tsx@radix-ui/react-radio-group의존성- Tailwind CSS 스타일
수동 설치
pnpm add @radix-ui/react-radio-group기본 사용법
Default (수직 레이아웃)
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export default function RadioDemo() {
return (
<RadioGroup defaultValue="option1">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="option1" />
<Label htmlFor="option1">옵션 1</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="option2" />
<Label htmlFor="option2">옵션 2</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option3" id="option3" />
<Label htmlFor="option3">옵션 3</Label>
</div>
</RadioGroup>
);
}수평 레이아웃
<RadioGroup defaultValue="option1" className="flex space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="h-option1" />
<Label htmlFor="h-option1">옵션 1</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="h-option2" />
<Label htmlFor="h-option2">옵션 2</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option3" id="h-option3" />
<Label htmlFor="h-option3">옵션 3</Label>
</div>
</RadioGroup>Disabled
<RadioGroup defaultValue="option1">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="disabled1" />
<Label htmlFor="disabled1">활성화됨</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="disabled2" disabled />
<Label htmlFor="disabled2">비활성화됨</Label>
</div>
</RadioGroup>언제 사용하는가
✅ 권장 사용 사례
- 배타적 선택: 2-5개의 옵션 중 하나만 선택
- 필수 선택: 반드시 하나를 선택해야 하는 경우
- 설정 옵션: 테마 선택, 정렬 방식 등
- 결제 방법: 신용카드, 계좌이체, 페이팔 등
- 배송 방법: 일반 배송, 빠른 배송, 매장 픽업 등
예시
// 테마 선택
<RadioGroup defaultValue="light">
<div className="flex items-center space-x-2">
<RadioGroupItem value="light" id="light" />
<Label htmlFor="light">라이트 모드</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dark" id="dark" />
<Label htmlFor="dark">다크 모드</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="system" id="system" />
<Label htmlFor="system">시스템 설정</Label>
</div>
</RadioGroup>언제 사용하지 말아야 하는가
❌ 피해야 할 사용 사례
- 다중 선택: Checkbox 사용 권장
- 즉시 실행 액션: Switch 또는 Button 사용
- 5개 이상의 옵션: Select 사용 권장
- 선택 해제 가능: Checkbox 사용
대안
| 상황 | 대안 | 이유 |
|---|---|---|
| 다중 선택 | Checkbox | 여러 항목 동시 선택 가능 |
| 즉시 실행 필요 | Switch | 상태 변경 즉시 적용 |
| 많은 옵션 (5개 이상) | Select | 공간 절약 및 검색 가능 |
| 선택 해제 가능 | Checkbox | 선택/해제 자유롭게 가능 |
| ON/OFF 토글 | Switch | 즉각적인 상태 변경 시각화 |
Advanced Usage
React Hook Form 통합
"use client";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const formSchema = z.object({
paymentMethod: z.enum(["card", "bank", "paypal"], {
required_error: "결제 방법을 선택해주세요",
}),
});
export default function FormDemo() {
const form = useForm({
resolver: zodResolver(formSchema),
});
return (
<form onSubmit={form.handleSubmit((data) => console.log(data))}>
<RadioGroup
onValueChange={(value) =>
form.setValue("paymentMethod", value as "card" | "bank" | "paypal")
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="card" id="card" />
<Label htmlFor="card">신용카드</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="bank" id="bank" />
<Label htmlFor="bank">계좌이체</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="paypal" id="paypal" />
<Label htmlFor="paypal">PayPal</Label>
</div>
</RadioGroup>
{form.formState.errors.paymentMethod && (
<p className="text-sm text-destructive mt-2">
{form.formState.errors.paymentMethod.message}
</p>
)}
<button type="submit" className="mt-4">
제출
</button>
</form>
);
}Controlled Component
"use client";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
export default function ControlledDemo() {
const [value, setValue] = useState("option1");
return (
<div className="space-y-4">
<RadioGroup value={value} onValueChange={setValue}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="c-option1" />
<Label htmlFor="c-option1">옵션 1</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="c-option2" />
<Label htmlFor="c-option2">옵션 2</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option3" id="c-option3" />
<Label htmlFor="c-option3">옵션 3</Label>
</div>
</RadioGroup>
<p className="text-sm text-muted-foreground">선택된 값: {value}</p>
<button onClick={() => setValue("option2")}>옵션 2로 변경</button>
</div>
);
}설명 텍스트 포함
<RadioGroup defaultValue="basic">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="basic" id="basic" />
<div>
<Label htmlFor="basic">Basic</Label>
<p className="text-sm text-muted-foreground">
기본 기능만 포함된 무료 플랜
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pro" id="pro" />
<div>
<Label htmlFor="pro">Pro</Label>
<p className="text-sm text-muted-foreground">
고급 기능이 포함된 유료 플랜 ($29/월)
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="enterprise" id="enterprise" />
<div>
<Label htmlFor="enterprise">Enterprise</Label>
<p className="text-sm text-muted-foreground">
팀을 위한 맞춤형 플랜 (문의)
</p>
</div>
</div>
</div>
</RadioGroup>카드 형태 Radio
<RadioGroup defaultValue="starter" className="grid grid-cols-3 gap-4">
<Label
htmlFor="starter"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
>
<RadioGroupItem value="starter" id="starter" className="sr-only" />
<div className="text-center">
<p className="text-lg font-medium">Starter</p>
<p className="text-sm text-muted-foreground">무료</p>
</div>
</Label>
<Label
htmlFor="pro-card"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
>
<RadioGroupItem value="pro" id="pro-card" className="sr-only" />
<div className="text-center">
<p className="text-lg font-medium">Pro</p>
<p className="text-sm text-muted-foreground">$29/월</p>
</div>
</Label>
<Label
htmlFor="enterprise-card"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
>
<RadioGroupItem
value="enterprise"
id="enterprise-card"
className="sr-only"
/>
<div className="text-center">
<p className="text-lg font-medium">Enterprise</p>
<p className="text-sm text-muted-foreground">문의</p>
</div>
</Label>
</RadioGroup>접근성 (Accessibility)
ARIA Attributes
Radix UI RadioGroup이 자동으로 제공:
<RadioGroup>
{/* role="radiogroup" */}
<RadioGroupItem />
{/* role="radio" */}
{/* aria-checked="true" | "false" */}
{/* data-state="checked" | "unchecked" */}
</RadioGroup>키보드 네비게이션
- Tab: Radio 그룹으로 포커스 이동
- Arrow Up/Down: 수직 레이아웃에서 이전/다음 옵션 선택
- Arrow Left/Right: 수평 레이아웃에서 이전/다음 옵션 선택
- Space: 현재 포커스된 옵션 선택
- Shift + Tab: 이전 요소로 포커스 이동
스크린 리더
<RadioGroup aria-label="결제 방법 선택">
<div className="flex items-center space-x-2">
<RadioGroupItem value="card" id="card-sr" />
<Label htmlFor="card-sr">신용카드</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="bank" id="bank-sr" />
<Label htmlFor="bank-sr">계좌이체</Label>
</div>
</RadioGroup>에러 상태
<div>
<RadioGroup aria-invalid={!!error} aria-describedby="error-message">
{/* ... */}
</RadioGroup>
{error && (
<p
id="error-message"
className="text-sm text-destructive mt-2"
role="alert"
>
{error}
</p>
)}
</div>Best Practices
1. Label 연결 필수
// ✅ 좋은 예
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="option1" />
<Label htmlFor="option1">옵션 1</Label>
</div>
// ❌ 나쁜 예
<RadioGroupItem value="option1" />
<span>옵션 1</span>2. 명확한 그룹 제목
// ✅ 좋은 예
<fieldset>
<legend className="text-sm font-medium mb-2">배송 방법을 선택하세요</legend>
<RadioGroup defaultValue="standard">
{/* ... */}
</RadioGroup>
</fieldset>
// ❌ 나쁜 예
<RadioGroup>
{/* 제목 없음 */}
</RadioGroup>3. 기본값 제공
// ✅ 좋은 예
<RadioGroup defaultValue="option1">
{/* ... */}
</RadioGroup>
// ❌ 나쁜 예
<RadioGroup>
{/* 기본값 없음 - 사용자가 선택 안 할 수 있음 */}
</RadioGroup>4. 적절한 옵션 수
// ✅ 좋은 예: 2-5개 옵션
<RadioGroup>
<RadioGroupItem value="s" id="s" />
<RadioGroupItem value="m" id="m" />
<RadioGroupItem value="l" id="l" />
</RadioGroup>
// ❌ 나쁜 예: 5개 이상 - Select 사용 권장
<RadioGroup>
{Array.from({ length: 10 }).map((_, i) => (
<RadioGroupItem value={`option${i}`} id={`option${i}`} key={i} />
))}
</RadioGroup>5. 레이아웃 일관성
// ✅ 좋은 예: 일관된 간격
<RadioGroup className="space-y-2">
{/* ... */}
</RadioGroup>
// 수평 레이아웃
<RadioGroup className="flex space-x-4">
{/* ... */}
</RadioGroup>디자인 가이드라인
크기 및 간격
// Radio 버튼 크기: 16x16px (default)
// Label과의 간격: 8px (space-x-2)
// Radio 간 간격: 8px (space-y-2)
<RadioGroup className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="option1" />
<Label htmlFor="option1">옵션 1</Label>
</div>
</RadioGroup>터치 영역
모바일 접근성을 위해 최소 44x44px 터치 영역 확보:
<RadioGroup className="space-y-2">
<div className="flex items-center space-x-2 min-h-[44px]">
<RadioGroupItem value="option1" id="touch-option1" />
<Label htmlFor="touch-option1" className="cursor-pointer flex-1">
터치 최적화 라벨
</Label>
</div>
</RadioGroup>색상 대비
WCAG 2.1 AA 기준 4.5:1 이상:
/* Radio 선택 상태 */
background: hsl(var(--primary)); /* 충분한 대비 */
border: hsl(var(--primary));
/* Focus ring */
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;TypeScript
Props 타입
import type { ComponentPropsWithoutRef } from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
type RadioGroupProps = ComponentPropsWithoutRef<
typeof RadioGroupPrimitive.Root
>;
type RadioGroupItemProps = ComponentPropsWithoutRef<
typeof RadioGroupPrimitive.Item
>;
// 사용 예시
const CustomRadioGroup = (props: RadioGroupProps) => {
return <RadioGroup {...props} />;
};Form 데이터 타입
import { z } from "zod";
const formSchema = z.object({
size: z.enum(["small", "medium", "large"], {
required_error: "사이즈를 선택해주세요",
}),
color: z.enum(["red", "blue", "green"]).optional(),
});
type FormData = z.infer<typeof formSchema>;Custom Radio 컴포넌트
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface Option {
value: string;
label: string;
description?: string;
}
interface CustomRadioGroupProps {
options: Option[];
value?: string;
onValueChange?: (value: string) => void;
orientation?: "horizontal" | "vertical";
}
export function CustomRadioGroup({
options,
value,
onValueChange,
orientation = "vertical",
}: CustomRadioGroupProps) {
return (
<RadioGroup
value={value}
onValueChange={onValueChange}
className={orientation === "horizontal" ? "flex space-x-4" : "space-y-2"}
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={option.value} />
<div>
<Label htmlFor={option.value}>{option.label}</Label>
{option.description && (
<p className="text-sm text-muted-foreground">
{option.description}
</p>
)}
</div>
</div>
))}
</RadioGroup>
);
}성능 최적화
많은 옵션 렌더링
"use client";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { memo } from "react";
const RadioOption = memo(
({ value, label }: { value: string; label: string }) => {
return (
<div className="flex items-center space-x-2">
<RadioGroupItem value={value} id={value} />
<Label htmlFor={value}>{label}</Label>
</div>
);
}
);
export default function OptimizedRadio() {
const options = Array.from({ length: 50 }, (_, i) => ({
value: `option-${i}`,
label: `Option ${i}`,
}));
return (
<RadioGroup defaultValue="option-0">
{options.map((option) => (
<RadioOption key={option.value} {...option} />
))}
</RadioGroup>
);
}관련 컴포넌트
참고 자료
지원 및 피드백
문제가 발생하거나 개선 제안이 있으신가요?
- 📧 이메일: dev@vortex.com
- 💬 Slack: #vortex-design-system
- 🐛 버그 리포트: GitLab Issues
Last updated on