Radio
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
월간 결제 (₩9,900)
연간 결제 (₩99,000)
평생 (품절)
Radio
애니메이션이 적용된 커스텀 라디오 버튼 컴포넌트입니다.
실제 input은 숨기고 시각적 요소로 선택 상태를 표현하며, 선택 시 부드러운 애니메이션이 적용됩니다.
기본 라디오 버튼
같은 name을 가진 라디오 버튼 그룹
Free Plan
Live Preview
Pro Plan
Live Preview
Enterprise
Live Preview
States
라디오 버튼의 다양한 상태 (disabled, error, helper text)
Disabled
Live Preview
Checked Disabled
Live Preview
Error
Live Preview
Helper Text
이 옵션을 선택하면 추가 설정이 필요합니다.
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';