"use client";
/**
* Checkbox Component
*
* Premium animated checkbox with polished interactions.
* Features smooth scaling, subtle hover ripple, and refined typography.
*/
import { InputHTMLAttributes, forwardRef, useId, useState, useEffect } from "react";
import { CheckIcon } from "@/components/Icon/check";
import { cn } from "@/lib/cn";
export interface CheckboxProps extends OmitInputHTMLAttributesHTMLInputElement, "type" {
label?: string;
error?: string;
helperText?: string;
}
export const Checkbox = forwardRefHTMLInputElement, CheckboxProps(
({ className, label, error, helperText, id, disabled, checked, onChange, ...props }, ref) = {
const generatedId = useId();
const checkboxId = id ?? checkbox-${generatedId.replace(/:/g, "-")};
const [isChecked, setIsChecked] = useState(checked false);
useEffect(() = {
setIsChecked(checked false);
}, [checked]);
const handleChange = (e: React.ChangeEventHTMLInputElement) = {
setIsChecked(e.target.checked);
onChange?.(e);
};
const activeClasses = (checked: boolean, error?: string) = {
if (error) return "";
if (checked) return "shadow-[0_0_0_4px_rgba(15,23,42,0.06)] scale-105";
return "";
};
return (
div className={cn("group/checkbox inline-flex flex-col items-center justify-center gap-1.5", className)}
label
htmlFor={checkboxId}
className={cn(
"relative flex items-center gap-3 cursor-pointer select-none px-1 py-0.5 rounded-lg transition-all duration-300",
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-50"
)}
{/* Invisible native input */}
input
ref={ref}
type="checkbox"
id={checkboxId}
className="sr-only peer"
disabled={disabled}
checked={isChecked}
onChange={handleChange}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? ${checkboxId}-error : helperText ? ${checkboxId}-helper : undefined}
{...props}
/
{/* Visual box with ripple */}
div className="relative flex items-center justify-center shrink-0"
{/* Hover ripple */}
div
className={cn(
"absolute inset-0 -m-2 rounded-md transition-all duration-300 scale-0 group-hover/checkbox:scale-100 group-hover/checkbox:bg-slate-200/50",
isChecked && "group-hover/checkbox:bg-slate-900/5",
disabled && "hidden"
)}
/
{/* Outer square */}
div
className={cn(
"w-5 h-5 rounded border-2 flex items-center justify-center transition-all duration-300",
error
? "border-red-500 bg-red-50/30"
: isChecked
? "border-slate-900 bg-white"
: "border-slate-300 bg-white hover:border-slate-400 focus-within:ring-2 focus-within:ring-slate-900/20",
activeClasses(isChecked, error),
"peer-focus:ring-4 peer-focus:ring-slate-900/10"
)}
CheckIcon
className={cn(
"w-3 h-3 text-white transition-all duration-200",
isChecked ? "scale-100 opacity-100" : "scale-0 opacity-0",
error ? "text-red-500" : "text-slate-900"
)}
/
/div
/div
{/* Label text */}
{label && (
span
className={cn(
"text-sm font-medium transition-colors duration-300",
isChecked ? "text-slate-900" : "text-slate-500",
disabled ? "text-slate-400" : "group-hover/checkbox:text-slate-800"
)}
{label}
/span
)}
/label
{/* Helper / error messages */}
{(error helperText) && (
div className="ml-[32px] space-y-1"
{error && (
p id={${checkboxId}-error} className="text-[11px] font-bold text-red-600 flex items-center gap-1 animate-in fade-in slide-in-from-left-1"
span className="w-1 h-1 rounded-full bg-red-600" /
{error}
/p
)}
{helperText && !error && (
p id={${checkboxId}-helper} className="text-[11px] font-medium text-slate-500 leading-tight"
{helperText}
/p
)}
/div
)}
/div
);
}
);
Checkbox.displayName = "Checkbox";
1. 왜 Checkbox를 따로 만들었는지
폼에서 이용약관개인정보 동의, 추천 포스트 여부, 공개 노출 여부처럼 on/off 값을 받을 일이 많았는데, 기본 input type="checkbox"만 쓰면 디자인이 제각각이고 에러도움말을 붙일 때마다 반복 코드가 늘었습니다. 그래서 보이는 건 커스텀 박스+체크 아이콘, 동작은 네이티브 checkbox로 통일한 공용 Checkbox를 만들기로 했습니다. 접근성(스크린 리더, 키보드)은 네이티브 input에 맡기고, 시각만 우리 디자인 시스템에 맞추는 방식입니다.
2. 컴포넌트 구성과 설계 방향
구조는 숨긴 네이티브 input + 보이는 박스/아이콘 + 라벨 + 에러/도움말 영역으로 나눴습니다. input은 sr-only peer로 화면에서만 숨기고, 포커스체크 상태는 peer-focus, peer-*로 시각 박스에 반영되게 했습니다. 이렇게 하면 키보드 포커스탭 순서스크린 리더 해석이 모두 네이티브 그대로라서, 커스텀 디자인만 유지하면서 접근성을 지킬 수 있었습니다.
checked는 제어 컴포넌트로 쓰일 수 있도록 부모에서 넘기고, 내부에서는 useState + useEffect로 동기화했습니다. 그래서 폼 라이브러리(React Hook Form 등)와 연동할 때도 value/onChange 패턴만 맞추면 됩니다. 애니메이션은 체크 시 박스에 살짝 scaleshadow를 주고, 호버 시 ripple 느낌의 배경만 넣어서 과하지 않게 맞췄습니다. "use client"를 둔 이유는 useStateuseEffect를 쓰기 위해서입니다.
3. 프롭스가 필요한 이유
label
체크박스마다 이용약관 동의, 추천 포스트로 설정 같은 문맥이 필요해서 넣었습니다. label이 있으면 label htmlFor={checkboxId}로 input과 연결해 클릭 시 토글이 되고, 스크린 리더가 라벨 + 체크박스로 읽어 줍니다. 없으면 체크박스만 렌더링해서 테이블 셀 안의 단일 체크 등에도 쓸 수 있게 했습니다.
error
필수 동의를 안 했을 때 같은 검증 메시지를 같은 위치에 보여주고 싶어서 넣었습니다. error가 있으면 박스 테두리배경을 빨간 계열로 바꾸고, aria-invalid="true"와 id={checkboxId}-error로 에러 문구를 aria-describedby에 연결해 두었습니다. 그래서 에러가 있을 때 스크린 리더가 유효하지 않음, 필수 항목입니다처럼 읽어 줍니다. 에러 문구는 animate-in fade-in slide-in-from-left-1로 살짝 등장하게 해서 새로 보이는 메시지라는 걸 구분하기 쉽게 했습니다.
helperText
선택 사항입니다, 체크 시 메일을 받습니다 같은 설명을 한 곳에서 처리하려고 넣었습니다. error가 있으면 helper는 숨기고 에러만 보이게 해서, 같은 자리에 두 가지가 동시에 나오지 않게 했습니다. id={checkboxId}-helper로 aria-describedby에 연결해 설명이 있을 때만 보조 설명으로 읽히게 했습니다.
id
폼에 체크박스가 여러 개일 때 각각 고유 id가 필요해서 받을 수 있게 했습니다. 없으면 useId()로 checkbox-xxx 형태를 만들어 씁니다. label의 htmlFor, 에러/도움말의 id가 이 값에 의존하므로 id를 넘기면 서버 렌더테스트에서도 예측 가능해집니다.
checked, onChange, disabled
제어 모드와 비제어 모드 모두 지원하려고 checked와 onChange를 그대로 받습니다. onChange에서 내부 isChecked를 먼저 갱신한 뒤 부모의 onChange를 호출해서, 시각 반영과 부모 상태가 같이 맞춰지게 했습니다. disabled는 input과 라벨에 모두 반영해 opacitycursor호버 효과를 끄고, ripple도 숨겨서 비활성 느낌을 분명히 했습니다.
className, ...props
컨테이너에 className을 줄 수 있게 해서 레이아웃(간격, 정렬)만 바꿀 수 있게 했습니다. InputHTMLAttributes에서 type만 빼고 나머지(name, aria-*, data-* 등)는 ...props로 input에 넘기므로, 폼 제출접근성테스트용 속성을 그대로 쓸 수 있습니다.
4. 사용 용도와 쓰는 방식
동의 폼: 이용약관개인정보마케팅 수신 동의에
label에 (필수)/(선택)을 넣고, 필수 미체크 시error="필수 항목입니다."를 넘깁니다.helperText로 선택 사항입니다 같은 설명을 붙일 수 있습니다.관리자 폼: 블로그 글의 추천 포스트로 설정, 유튜브의 공개 페이지에 노출처럼 단일 on/off는
idname을 지정하고checked/onChange로 폼 상태와 연결해 씁니다.쇼케이스/문서: 기본체크됨라벨 없음비활성에러도움말 등 variant를 나눠서 보여줄 때 같은 컴포넌트로 일관되게 사용합니다.
5. 정리
Checkbox는 네이티브 체크박스 동작과 접근성은 유지하고, 보이는 부분만 우리 디자인과 폼 패턴에 맞추자는 목적으로 만들었습니다. labelerrorhelperText는 폼 UX와 접근성을 위해 넣었고, checked/onChange/id는 제어폼 연동을 위해, className과 ...props는 배치와 네이티브 속성 전달을 위해 두었습니다. 실제로는 동의 폼, 관리자 설정, 블로그/유튜브 메타 설정에서 공통으로 쓰이고 있습니다.