Radio

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
월간 결제 (₩9,900)
연간 결제 (₩99,000)
평생 (품절)

Radio

애니메이션이 적용된 커스텀 라디오 버튼 컴포넌트입니다.
실제 input은 숨기고 시각적 요소로 선택 상태를 표현하며, 선택 시 부드러운 애니메이션이 적용됩니다.

기본 라디오 버튼

같은 name을 가진 라디오 버튼 그룹

Free Plan
<Radio label="무료 플랜" name="plan" value="free" checked={plan === "free"} onChange={...} />
Live Preview
Pro Plan
<Radio label="프로 플랜" name="plan" value="pro" checked={plan === "pro"} onChange={...} />
Live Preview
Enterprise
<Radio label="엔터프라이즈" name="plan" value="enterprise" checked={plan === "enterprise"} onChange={...} />
Live Preview

States

라디오 버튼의 다양한 상태 (disabled, error, helper text)

Disabled
<Radio label="비활성화" name="group" disabled />
Live Preview
Checked Disabled
<Radio label="체크 비활성" name="group" checked disabled />
Live Preview
Error
<Radio label="에러 상태" name="group" error="..." />

필수 선택 항목입니다.

Live Preview
Helper Text
<Radio label="도움말 포함" name="group" helperText="..." />

이 옵션을 선택하면 추가 설정이 필요합니다.

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Radiotypescript
"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 Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
    label?: string;
    error?: string;
    helperText?: string;
}

export const Radio = forwardRef<HTMLInputElement, 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.ChangeEvent<HTMLInputElement>) => {
            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-rose-400 bg-rose-50/40"
                                : isChecked
                                    ? "border-slate-900 bg-white"
                                    : "border-slate-300 bg-white group-hover/label:border-slate-400",
                            isActive(isChecked, error),
                            error
                                ? "peer-focus-visible:ring-2 peer-focus-visible:ring-rose-500/20"
                                : "peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/20"
                        )}>
                            {/* 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-rose-500' : '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-rose-600 flex items-center gap-1 animate-in fade-in slide-in-from-left-1">
                            <span className="w-1 h-1 rounded-full bg-rose-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';