Components
Radio
Radio
Ultra-modern radio button component with custom animations, multiple variants, and enhanced user interaction for form controls.
Radio Button Groups
Choose Size
Perfect for personal use
Great for small teams
Ideal for growing businesses
Selected: None
Choose Plan
$9/month - Core features
$29/month - Advanced features
$99/month - All features
Selected: None
Radio Variants
Default Variant
Filled Variant
Outlined Variant
Radio Sizes
Radio States
Normal State
Error State
Please select an option
Disabled State
Interactive Form Demo
Complete Form Example
Payment Method
Visa, Mastercard, Amex
Fast and secure payment
Direct bank transfer
Theme Preference
Clean and bright interface
Easy on the eyes
Follows system preference
Keyboard Navigation
Try these keyboard shortcuts:
- Tab: Move focus between radio groups
- Arrow Keys: Navigate within a group
- Space/Enter: Select focused radio
- Click: Select radio directly
Test Navigation Group
Installation
1
Install the packages
npm i motion clsx tailwind-merge lucide-react2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
radio.tsx
"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { Check, AlertCircle } from "lucide-react";
interface RadioProps {
checked?: boolean;
value?: string | number;
name?: string;
onChange?: (value: string | number) => void;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
error?: string;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
className?: string;
}
const Radio: React.FC<RadioProps> = ({
checked = false,
value,
name,
onChange,
label,
description,
disabled = false,
required = false,
error,
size = "md",
variant = "default",
className,
}) => {
const [isFocused, setIsFocused] = useState(false);
const radioId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const dotSizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
const variantClasses = {
default: "border-gray-300 bg-white hover:border-gray-400 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-gray-500",
filled: "border-transparent bg-gray-100 hover:bg-gray-200 focus:bg-white focus:border-blue-500 dark:bg-gray-700 dark:hover:bg-gray-600",
outlined: "border-2 border-gray-300 bg-transparent hover:border-gray-400 focus:border-blue-500 dark:border-gray-600",
};
const checkedClasses = {
default: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
filled: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
outlined: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
};
const handleClick = () => {
if (!disabled && value !== undefined) {
onChange?.(value);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div className={cn("relative", className)}>
<div className="flex items-start space-x-3">
{/* Radio Input */}
<div className="relative flex-shrink-0">
<motion.input
id={radioId}
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}} // Handled by click
disabled={disabled}
className="sr-only"
/>
{/* Custom Radio */}
<motion.div
className={cn(
"relative rounded-full border-2 cursor-pointer transition-all duration-200 flex items-center justify-center",
sizeClasses[size],
checked ? checkedClasses[variant] : variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed",
hasError && !checked && "border-red-500",
isFocused && "ring-2 ring-blue-500/20"
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="radio"
aria-checked={checked}
aria-labelledby={label ? radioId : undefined}
animate={{
scale: isFocused ? 1.05 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
{/* Background Animation */}
<AnimatePresence>
{checked && (
<motion.div
className="absolute inset-0 bg-current rounded-full"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
{/* Dot */}
<AnimatePresence>
{checked && (
<motion.div
className={cn("bg-white rounded-full", dotSizeClasses[size])}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
/>
)}
</AnimatePresence>
{/* Ripple Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 bg-white/30 rounded-full"
initial={{ scale: 0, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.4 }}
/>
)}
</AnimatePresence>
</motion.div>
{/* Focus Ring */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-full border-2 border-blue-500"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Label and Description */}
<div className="flex-1 min-w-0">
{label && (
<motion.label
htmlFor={radioId}
className={cn(
"block font-medium cursor-pointer transition-colors duration-200",
textSizeClasses[size],
hasError ? "text-red-600 dark:text-red-400" :
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-200"
)}
onClick={handleClick}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{description && (
<motion.p
className={cn(
"mt-1 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};
export default Radio;"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { Check, AlertCircle } from "lucide-react";
interface RadioProps {
checked?: boolean;
value?: string | number;
name?: string;
onChange?: (value: string | number) => void;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
error?: string;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
className?: string;
}
const Radio: React.FC<RadioProps> = ({
checked = false,
value,
name,
onChange,
label,
description,
disabled = false,
required = false,
error,
size = "md",
variant = "default",
className,
}) => {
const [isFocused, setIsFocused] = useState(false);
const radioId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const dotSizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
const variantClasses = {
default: "border-gray-300 bg-white hover:border-gray-400 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-gray-500",
filled: "border-transparent bg-gray-100 hover:bg-gray-200 focus:bg-white focus:border-blue-500 dark:bg-gray-700 dark:hover:bg-gray-600",
outlined: "border-2 border-gray-300 bg-transparent hover:border-gray-400 focus:border-blue-500 dark:border-gray-600",
};
const checkedClasses = {
default: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
filled: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
outlined: "bg-blue-500 border-blue-500 dark:bg-blue-600 dark:border-blue-600",
};
const handleClick = () => {
if (!disabled && value !== undefined) {
onChange?.(value);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div className={cn("relative", className)}>
<div className="flex items-start space-x-3">
{/* Radio Input */}
<div className="relative flex-shrink-0">
<motion.input
id={radioId}
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}} // Handled by click
disabled={disabled}
className="sr-only"
/>
{/* Custom Radio */}
<motion.div
className={cn(
"relative rounded-full border-2 cursor-pointer transition-all duration-200 flex items-center justify-center",
sizeClasses[size],
checked ? checkedClasses[variant] : variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed",
hasError && !checked && "border-red-500",
isFocused && "ring-2 ring-blue-500/20"
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="radio"
aria-checked={checked}
aria-labelledby={label ? radioId : undefined}
animate={{
scale: isFocused ? 1.05 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
{/* Background Animation */}
<AnimatePresence>
{checked && (
<motion.div
className="absolute inset-0 bg-current rounded-full"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
{/* Dot */}
<AnimatePresence>
{checked && (
<motion.div
className={cn("bg-white rounded-full", dotSizeClasses[size])}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
/>
)}
</AnimatePresence>
{/* Ripple Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 bg-white/30 rounded-full"
initial={{ scale: 0, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.4 }}
/>
)}
</AnimatePresence>
</motion.div>
{/* Focus Ring */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-full border-2 border-blue-500"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Label and Description */}
<div className="flex-1 min-w-0">
{label && (
<motion.label
htmlFor={radioId}
className={cn(
"block font-medium cursor-pointer transition-colors duration-200",
textSizeClasses[size],
hasError ? "text-red-600 dark:text-red-400" :
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-200"
)}
onClick={handleClick}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{description && (
<motion.p
className={cn(
"mt-1 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};
export default Radio;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | false | Whether the radio button is checked. |
| value | string | number | undefined | The value of the radio button. |
| name | string | undefined | The name attribute for grouping radio buttons. |
| onChange | (value: string | number) => void | () => {} | Callback function when radio button is selected. |
| label | string | undefined | Label text for the radio button. |
| description | string | undefined | Description text below the label. |
| disabled | boolean | false | Disable the radio button interaction. |
| required | boolean | false | Mark radio button as required. |
| error | string | undefined | Error message to display. |
| size | 'sm' | 'md' | 'lg' | 'md' | Size of the radio button. |
| variant | 'default' | 'filled' | 'outlined' | 'default' | Visual style variant. |