Checkbox
체크 가능한 옵션을 선택하는 폼 컴포넌트
개요
Checkbox 컴포넌트는 Radix UI의 Checkbox Primitive를 기반으로 구축된 체크박스 컴포넌트입니다.
주요 특징
- ✅ Radix UI 기반: 접근성이 검증된 Headless UI
- ✅ Indeterminate 상태: 부분 선택 상태 지원
- ✅ 폼 통합: React Hook Form과 완벽 호환
- ✅ 키보드 네비게이션: Space 키로 토글
- ✅ WCAG 2.1 AA: 접근성 표준 준수
설치
CLI로 추가 (권장)
npx @vortex/cli add checkbox자동으로 설치되는 것들:
src/components/ui/checkbox.tsx@radix-ui/react-checkbox의존성- Tailwind CSS 스타일
수동 설치
pnpm add @radix-ui/react-checkbox기본 사용법
Default
import { Checkbox } from "@/components/ui/checkbox";
export default function CheckboxDemo() {
return (
<div className="flex items-center space-x-2">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
이용약관에 동의합니다
</label>
</div>
);
}Checked
<Checkbox id="terms" defaultChecked />Disabled
<Checkbox id="terms" disabled />Indeterminate 상태
부분 선택 상태를 표시할 때 사용합니다.
예시: 전체 선택/해제
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
export default function IndeterminateDemo() {
const [items, setItems] = useState([
{ id: "item1", checked: false },
{ id: "item2", checked: false },
{ id: "item3", checked: false },
]);
const allChecked = items.every((item) => item.checked);
const someChecked = items.some((item) => item.checked);
const indeterminate = someChecked && !allChecked;
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="all"
checked={allChecked}
// @ts-ignore - Radix UI supports indeterminate
indeterminate={indeterminate}
onCheckedChange={(checked) => {
setItems(items.map((item) => ({ ...item, checked: !!checked })));
}}
/>
<label htmlFor="all" className="text-sm font-medium">
전체 선택
</label>
</div>
<div className="ml-6 space-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-center space-x-2">
<Checkbox
id={item.id}
checked={item.checked}
onCheckedChange={(checked) => {
setItems(
items.map((i) =>
i.id === item.id ? { ...i, checked: !!checked } : i
)
);
}}
/>
<label htmlFor={item.id} className="text-sm">
{item.id}
</label>
</div>
))}
</div>
</div>
);
}언제 사용하는가
✅ 권장 사용 사례
- 다중 선택 폼: 여러 옵션을 동시에 선택 가능할 때
- 설정 토글: 기능 활성화/비활성화
- 동의 확인: 약관 동의, 정책 확인
- 필터링: 검색 결과 필터 옵션
- 권한 관리: 역할별 권한 선택
예시
// 관심 분야 선택
<div className="space-y-2">
<Checkbox id="tech" />
<label htmlFor="tech">기술</label>
<Checkbox id="design" />
<label htmlFor="design">디자인</label>
<Checkbox id="business" />
<label htmlFor="business">비즈니스</label>
</div>언제 사용하지 말아야 하는가
❌ 피해야 할 사용 사례
- 단일 선택: Radio 사용 권장
- 즉시 실행 액션: Switch 또는 Button 사용
- 5개 이상의 옵션: Select 또는 Multi-select 고려
- 복잡한 계층 구조: Tree view 컴포넌트 사용
대안
| 상황 | 대안 | 이유 |
|---|---|---|
| 단일 옵션 선택 | Radio | 명확한 단일 선택 의도 표현 |
| 즉시 실행 필요 | Switch | 상태 변경 즉시 적용 |
| 많은 옵션 (5개 이상) | Select | 공간 절약 및 검색 가능 |
| ON/OFF 토글 | Switch | 즉각적인 상태 변경 시각화 |
| 트리 구조 선택 | Tree | 계층적 관계 표현 |
Advanced Usage
React Hook Form 통합
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const formSchema = z.object({
terms: z.boolean().refine((val) => val === true, {
message: "이용약관에 동의해야 합니다",
}),
marketing: z.boolean().optional(),
});
export default function FormDemo() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
terms: false,
marketing: false,
},
});
return (
<form onSubmit={form.handleSubmit((data) => console.log(data))}>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
{...form.register("terms")}
onCheckedChange={(checked) => form.setValue("terms", !!checked)}
/>
<label htmlFor="terms">이용약관 동의 (필수)</label>
</div>
{form.formState.errors.terms && (
<p className="text-sm text-destructive">
{form.formState.errors.terms.message}
</p>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="marketing"
{...form.register("marketing")}
onCheckedChange={(checked) => form.setValue("marketing", !!checked)}
/>
<label htmlFor="marketing">마케팅 정보 수신 동의 (선택)</label>
</div>
<button type="submit">제출</button>
</div>
</form>
);
}Controlled Component
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
export default function ControlledDemo() {
const [checked, setChecked] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="controlled"
checked={checked}
onCheckedChange={setChecked}
/>
<label htmlFor="controlled">제어된 체크박스</label>
</div>
<p className="text-sm text-muted-foreground">
현재 상태: {checked ? "체크됨" : "해제됨"}
</p>
<button onClick={() => setChecked(!checked)}>토글</button>
</div>
);
}체크박스 그룹
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
const frameworks = [
{ id: "react", label: "React" },
{ id: "vue", label: "Vue" },
{ id: "angular", label: "Angular" },
{ id: "svelte", label: "Svelte" },
];
export default function CheckboxGroup() {
const [selected, setSelected] = useState<string[]>([]);
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">선호하는 프레임워크를 선택하세요</h3>
<div className="space-y-2">
{frameworks.map((framework) => (
<div key={framework.id} className="flex items-center space-x-2">
<Checkbox
id={framework.id}
checked={selected.includes(framework.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelected([...selected, framework.id]);
} else {
setSelected(selected.filter((id) => id !== framework.id));
}
}}
/>
<label htmlFor={framework.id} className="text-sm">
{framework.label}
</label>
</div>
))}
</div>
<p className="text-sm text-muted-foreground">
선택됨: {selected.join(", ") || "없음"}
</p>
</div>
);
}접근성 (Accessibility)
ARIA Attributes
Radix UI Checkbox가 자동으로 제공:
<Checkbox
// aria-checked="true" | "false" | "mixed"
// role="checkbox"
// data-state="checked" | "unchecked" | "indeterminate"
/>키보드 네비게이션
- Tab: 체크박스로 포커스 이동
- Space: 체크/해제 토글
- Shift + Tab: 이전 요소로 포커스 이동
스크린 리더
<div className="flex items-center space-x-2">
<Checkbox id="terms" aria-describedby="terms-description" />
<label htmlFor="terms">이용약관 동의</label>
</div>
<p id="terms-description" className="sr-only">
서비스 이용약관 및 개인정보 처리방침에 동의합니다
</p>포커스 관리
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useRef } from "react";
export default function FocusDemo() {
const checkboxRef = useRef<HTMLButtonElement>(null);
return (
<div className="space-y-4">
<Checkbox ref={checkboxRef} id="focus" />
<button onClick={() => checkboxRef.current?.focus()}>
체크박스에 포커스
</button>
</div>
);
}Best Practices
1. Label 연결 필수
// ✅ 좋은 예
<div className="flex items-center space-x-2">
<Checkbox id="terms" />
<label htmlFor="terms">이용약관 동의</label>
</div>
// ❌ 나쁜 예
<Checkbox />
<span>이용약관 동의</span>2. 명확한 라벨 텍스트
// ✅ 좋은 예
<label htmlFor="marketing">
마케팅 정보 수신에 동의합니다 (선택)
</label>
// ❌ 나쁜 예
<label htmlFor="marketing">동의</label>3. 필수/선택 표시
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="required" />
<label htmlFor="required">
필수 항목 <span className="text-destructive">*</span>
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="optional" />
<label htmlFor="optional">
선택 항목 <span className="text-muted-foreground">(선택)</span>
</label>
</div>
</div>4. 그룹 제목 제공
<fieldset>
<legend className="text-sm font-medium mb-2">알림 설정</legend>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="email" />
<label htmlFor="email">이메일 알림</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="sms" />
<label htmlFor="sms">SMS 알림</label>
</div>
</div>
</fieldset>5. 에러 상태 표시
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
export default function ErrorDemo() {
const [checked, setChecked] = useState(false);
const [error, setError] = useState("");
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={checked}
onCheckedChange={(checked) => {
setChecked(!!checked);
if (checked) setError("");
}}
aria-invalid={!!error}
className={error ? "border-destructive" : ""}
/>
<label htmlFor="terms">이용약관 동의 (필수)</label>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
);
}디자인 가이드라인
크기 및 간격
// 체크박스 크기: 16x16px (default)
// Label과의 간격: 8px (space-x-2)
// 체크박스 간 간격: 8px (space-y-2)
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="item1" />
<label htmlFor="item1">항목 1</label>
</div>
</div>터치 영역
모바일 접근성을 위해 최소 44x44px 터치 영역 확보:
<div className="flex items-center space-x-2 min-h-[44px]">
<Checkbox id="touch" />
<label htmlFor="touch" className="cursor-pointer flex-1">
터치 최적화 라벨
</label>
</div>색상 대비
WCAG 2.1 AA 기준 4.5:1 이상:
/* Checkbox 체크 상태 */
background: hsl(var(--primary)); /* 충분한 대비 */
color: hsl(var(--primary-foreground));
/* Focus ring */
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;TypeScript
Props 타입
import type { ComponentPropsWithoutRef } from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
type CheckboxProps = ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>;
// 사용 예시
const CustomCheckbox = (props: CheckboxProps) => {
return <Checkbox {...props} />;
};Form 데이터 타입
import { z } from "zod";
const formSchema = z.object({
terms: z.boolean().refine((val) => val === true, {
message: "이용약관에 동의해야 합니다",
}),
marketing: z.boolean().optional(),
notifications: z.object({
email: z.boolean(),
sms: z.boolean(),
push: z.boolean(),
}),
});
type FormData = z.infer<typeof formSchema>;Indeterminate 타입
type CheckedState = boolean | "indeterminate";
interface CheckboxGroupProps {
items: Array<{ id: string; checked: boolean }>;
onItemsChange: (items: Array<{ id: string; checked: boolean }>) => void;
}성능 최적화
많은 체크박스 렌더링
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { memo, useCallback, useState } from "react";
const CheckboxItem = memo(
({
id,
label,
checked,
onChange,
}: {
id: string;
label: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}) => {
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(checked) => onChange(id, !!checked)}
/>
<label htmlFor={id}>{label}</label>
</div>
);
}
);
export default function OptimizedList() {
const [items, setItems] = useState(
Array.from({ length: 100 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i}`,
checked: false,
}))
);
const handleChange = useCallback((id: string, checked: boolean) => {
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, checked } : item))
);
}, []);
return (
<div className="space-y-2">
{items.map((item) => (
<CheckboxItem
key={item.id}
id={item.id}
label={item.label}
checked={item.checked}
onChange={handleChange}
/>
))}
</div>
);
}관련 컴포넌트
참고 자료
지원 및 피드백
문제가 발생하거나 개선 제안이 있으신가요?
- 📧 이메일: dev@vortex.com
- 💬 Slack: #vortex-design-system
- 🐛 버그 리포트: GitLab Issues
Last updated on