React Checkbox 컴포넌트 설계 — 네이티브 input, 접근성, 에러·도움말까지 한 번에

#Checkbox#forwardRef#Next.js#React#Tailwind CSS

"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-focuspeer-*로 시각 박스에 반영되게 했습니다. 이렇게 하면 키보드 포커스탭 순서스크린 리더 해석이 모두 네이티브 그대로라서, 커스텀 디자인만 유지하면서 접근성을 지킬 수 있었습니다.

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만 빼고 나머지(namearia-*data-* 등)는 ...props로 input에 넘기므로, 폼 제출접근성테스트용 속성을 그대로 쓸 수 있습니다.

4. 사용 용도와 쓰는 방식

  • 동의 폼: 이용약관개인정보마케팅 수신 동의에 label에 (필수)/(선택)을 넣고, 필수 미체크 시 error="필수 항목입니다."를 넘깁니다. helperText로 선택 사항입니다 같은 설명을 붙일 수 있습니다.

  • 관리자 폼: 블로그 글의 추천 포스트로 설정, 유튜브의 공개 페이지에 노출처럼 단일 on/off는 idname을 지정하고 checked/onChange로 폼 상태와 연결해 씁니다.

  • 쇼케이스/문서: 기본체크됨라벨 없음비활성에러도움말 등 variant를 나눠서 보여줄 때 같은 컴포넌트로 일관되게 사용합니다.

5. 정리

Checkbox는 네이티브 체크박스 동작과 접근성은 유지하고, 보이는 부분만 우리 디자인과 폼 패턴에 맞추자는 목적으로 만들었습니다. labelerrorhelperText는 폼 UX와 접근성을 위해 넣었고, checked/onChange/id는 제어폼 연동을 위해, className과 ...props는 배치와 네이티브 속성 전달을 위해 두었습니다. 실제로는 동의 폼, 관리자 설정, 블로그/유튜브 메타 설정에서 공통으로 쓰이고 있습니다.


83

댓글