React Radio 컴포넌트 설계 — 네이티브 input + 라벨·에러·도움말, 그룹 선택

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

"use client";

/**
 * Radio Component
 * 
 * A premium, animated radio button with polished interactions.
 * Features smooth scaling, subtle hover ripples, and refined typography.
 */
import { InputHTMLAttributes, forwardRef, useId, useState, useEffect } from "react";
import { cn } from '@/lib/cn';

export interface RadioProps extends OmitInputHTMLAttributesHTMLInputElement, 'type' {
    label?: string;
    error?: string;
    helperText?: string;
}

export const Radio = forwardRefHTMLInputElement, RadioProps(
    ({ className, label, error, helperText, id, disabled, checked, onChange, ...props }, ref) = {
        const generatedId = useId();
        const radioId = id ?? radio-${generatedId.replace(/:/g, '-')};

        // Internal state to sync with props
        const [isChecked, setIsChecked] = useState(checked  false);

        useEffect(() = {
            setIsChecked(checked  false);
        }, [checked]);

        const handleChange = (e: React.ChangeEventHTMLInputElement) = {
            setIsChecked(e.target.checked);
            onChange?.(e);
        };

        return (
            div className={cn('group/radio inline-flex flex-col gap-1.5', className)}
                label
                    htmlFor={radioId}
                    className={cn(
                        "relative flex items-center gap-3 cursor-pointer group/label select-none px-1 py-0.5 rounded-lg transition-all duration-300",
                        disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-50"
                    )}
                
                    {/* Actual Invisible Input */}
                    input
                        ref={ref}
                        type="radio"
                        id={radioId}
                        className="sr-only peer"
                        disabled={disabled}
                        checked={isChecked}
                        onChange={handleChange}
                        aria-invalid={error ? 'true' : 'false'}
                        aria-describedby={error ? ${radioId}-error : helperText ? ${radioId}-helper : undefined}
                        {...props}
                    /

                    {/* Visual Radio Container */}
                    div className="relative flex items-center justify-center shrink-0"
                        {/* Hover Ripple Effect */}
                        div className={cn(
                            "absolute inset-0 -m-2 rounded-full transition-all duration-300 scale-0 group-hover/label:scale-100 group-hover/label:bg-slate-200/50",
                            isChecked && "group-hover/label:bg-slate-900/5",
                            disabled && "hidden"
                        )} /

                        {/* Outer Circle */}
                        div className={cn(
                            "w-5 h-5 rounded-full border-2 transition-all duration-300 flex items-center justify-center",
                            error
                                ? "border-red-500 bg-red-50/30"
                                : isChecked
                                    ? "border-slate-900 bg-white"
                                    : "border-slate-300 bg-white group-hover/label:border-slate-400 focus-within:ring-2 focus-within:ring-slate-900/20",
                            isActive(isChecked, error),
                            "peer-focus:ring-4 peer-focus:ring-slate-900/10"
                        )}
                            {/* Inner Circle (Indicator) */}
                            div
                                className={cn(
                                    'w-2 h-2 rounded-full transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1)',
                                    isChecked ? 'scale-100 opacity-100' : 'scale-0 opacity-0',
                                    error ? 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]' : 'bg-slate-900'
                                )}
                            /
                        /div
                    /div

                    {/* Label Text */}
                    {label && (
                        span
                            className={cn(
                                'text-sm font-semibold transition-all duration-300',
                                isChecked ? 'text-slate-900' : 'text-slate-500',
                                disabled ? 'text-slate-400' : 'group-hover/label:text-slate-800'
                            )}
                        
                            {label}
                        /span
                    )}
                /label

                {/* Support Text */}
                div className="ml-[32px] space-y-1"
                    {error && (
                        p id={${radioId}-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={${radioId}-helper} className="text-[11px] font-medium text-slate-500 leading-tight"
                            {helperText}
                        /p
                    )}
                /div
            /div
        );
    }
);

// Helper to determine active border classes
const isActive = (checked: boolean, error: string  undefined) = {
    if (error) return "";
    if (checked) return "shadow-[0_0_0_4px_rgba(15,23,42,0.06)] scale-105";
    return "";
};

Radio.displayName = 'Radio';


1. 왜 Radio를 따로 만들었는지

플랜 선택(무료/프로/엔터프라이즈), 알림 수단(이메일/SMS/푸시), 테마(라이트/다크)처럼 여러 옵션 중 하나만 선택하는 UI가 필요했는데, 기본 input type="radio"만 쓰면 디자인이 제각각이고 Checkbox처럼 에러도움말을 붙이기 번거로웠습니다. 그래서 Checkbox와 같은 패턴으로 보이는 건 커스텀 원형 + 내부 점, 동작은 네이티브 radio인 공용 Radio를 만들기로 했습니다. name으로 그룹을 묶고, valuecheckedonChange로 부모 상태와 맞추면 됩니다.

2. 컴포넌트 구성과 설계 방향

구조
Checkbox와 같이 숨긴 네이티브 input + 보이는 원형 + 라벨 + 에러/도움말로 나눴습니다. input은 sr-only peer로 숨기고, 시각용은 바깥 원(border)과 안쪽 점(체크 시만 보이는 작은 원)으로 두었습니다. 라디오는 둥근 원 + 선택 시 안쪽 점이 익숙하니까 Checkbox처럼 체크 아이콘 대신 rounded-full 작은 div를 썼고, 체크 시 scaleopacity 애니메이션으로 자연스럽게 보이게 했습니다.

제어동기화
checked는 부모가 제어할 수 있도록 받고, 내부에서는 useState + useEffect로 동기화했습니다. onChange에서 먼저 내부 isChecked를 갱신한 뒤 부모의 onChange를 호출해서, 그룹에서 다른 Radio를 선택했을 때도 이전에 선택됐던 항목이 바로 해제된 것처럼 보이게 했습니다. "use client"는 이 훅들 때문에 붙였습니다.

접근성
input에 aria-invalidaria-describedby를 에러/도움말 id와 연결하고, 라벨은 htmlFor={radioId}로 input과만 연결했습니다. name이 같으면 브라우저가 알아서 그룹으로 인식해 하나만 선택되므로, 포커스키보드스크린 리더 동작은 네이티브 그대로 유지됩니다.

스타일
에러가 있으면 테두리내부 점을 빨간색으로, 비활성이면 opacitycursor호버를 끄고, 선택됐을 때는 isActive()로 살짝 shadowscale을 줘서 Checkbox와 톤을 맞췄습니다. 호버 시 원 주변에 ripple 느낌의 배경만 넣어서 과하지 않게 했습니다.

3. 프롭스가 필요한 이유

label
각 옵션이 무엇인지 알려주려고 넣었습니다. 무료 플랜, 이메일, 라이트 모드처럼 라벨을 주면 label htmlFor={radioId}로 input과 연결되어 클릭 시 선택되고, 스크린 리더가 라벨, 라디오 버튼, N개 중 M번째처럼 읽어 줍니다. 없으면 라디오 원만 렌더링해서 아이콘만 있는 경우에도 쓸 수 있게 했습니다.

error
그룹 전체가 필수인데 하나도 선택되지 않았을 때 같은 위치에 메시지를 보여주려고 넣었습니다. 넘기면 원내부 점을 빨간색으로 바꾸고, aria-invalid="true"와 id={radioId}-error}를 aria-describedby로 연결해 에러 문구가 보조 기술에 전달되게 했습니다. 에러 문구는 animate-in fade-in slide-in-from-left-1로 살짝 등장하게 해서 Checkbox와 동일한 느낌으로 맞췄습니다.

helperText
이 옵션을 선택하면 추가 설정이 필요합니다 같은 설명을 해당 항목 아래에 두려고 넣었습니다. error가 있으면 helper는 숨기고 에러만 보이게 해서, 한 자리에서 에러와 도움말이 겹치지 않게 했고, id={radioId}-helper}로 aria-describedby에 연결해 두었습니다.

id
한 그룹 안에 여러 Radio가 있을 때 각 input이 고유 id를 가져야 라벨에러도움말과 연결할 수 있어서 받을 수 있게 했습니다. 없으면 useId()로 radio-xxx를 만들어 씁니다. 서버 렌더테스트에서 id를 고정하고 싶을 때 사용합니다.

name, value, checked, onChange
라디오 그룹은 name이 같아야 하고, 선택된 값은 value로 구분합니다. 부모는 checked={selected === value}로 현재 선택을 넘기고, onChange에서 e.target.value를 받아 상태를 갱신하면 됩니다. InputHTMLAttributes에서 type만 빼고(type="radio" 고정) 나머지는 ...props로 넘겨서 namevalue기타 속성을 그대로 쓸 수 있게 했습니다. ref는 forwardRef로 전달해 포커스 제어나 측정이 필요할 때 쓰도록 했습니다.

disabled
특정 옵션만 비활성화할 때 쓰려고 넣었습니다. input과 라벨에 반영해 opacitycursor호버ripple을 끄고, 스크린 리더도 비활성으로 인식하게 했습니다.

4. 사용 용도와 쓰는 방식

  • 플랜옵션 선택: 무료/프로/엔터프라이즈처럼 name="plan"value="free" 등으로 묶고, 부모에서 plan 상태를 두어 checked={plan === 'free'}onChange에서 setPlan(e.target.value)로 맞춥니다. 각 Radio에 label만 다르게 주면 됩니다.

  • 알림테마: 이메일/SMS/푸시, 라이트/다크처럼 name을 하나로 맞추고, value와 checkedonChange로 현재 선택 값을 제어합니다. 필요하면 특정 항목에만 helperText를 붙입니다.

  • 쇼케이스: 기본 그룹, 비활성에러도움말 예시를 같은 컴포넌트로 보여줄 때, name을 그룹별로 나눠서 여러 variant를 한 페이지에 배치해 사용합니다.

5. 정리

Radio는 네이티브 라디오 그룹 동작과 접근성은 유지하고, 보이는 부분만 Checkbox와 톤을 맞춰서 라벨에러도움말까지 한 번에 처리하자는 목적으로 만들었습니다. labelerrorhelperText는 폼 UX와 접근성을 위해, namevaluecheckedonChange는 그룹 선택 제어를 위해, iddisabledclassName...props는 연결비활성스타일네이티브 속성 전달을 위해 넣었습니다. 플랜 선택, 알림 수단, 테마 선택 등 하나만 고르기 UI에서 Checkbox와 같은 디자인 시스템으로 쓰고 있습니다.





50

댓글