Skip to Content

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> ); }

관련 컴포넌트


참고 자료


지원 및 피드백

문제가 발생하거나 개선 제안이 있으신가요?

Last updated on