Radio
Overiew
A customizable radio component with multiple variants, sizes, and animations
Preview
- Preview
- Code
RadioGroup
const options = [
{ value: "one", label: "Option One" },
{ value: "two", label: "Option Two" },
];
const [disabled, setDisabled] = React.useState(false);
<RadioGroup
options={options}
value="one"
labelPosition="right"
size="md"
checkedVariant="surface"
variant="default"
animationVariant="bounce"
disabled={disabled}
/>
Installation
- CLI
- MANUAL
ignix add component radio
import * as React from "react";
import * as RadixRadio from "@radix-ui/react-radio-group";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../../utils/cn";
import { motion } from "framer-motion";
import type { Variants } from "framer-motion";
/* ----------------------------------------
* Types
* ------------------------------------- */
export interface RadioOption {
value: string;
label: string;
disabled?: boolean;
}
export interface RadioGroupProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "defaultValue"
>,
VariantProps<typeof radioItemVariants> {
name?: string;
options: RadioOption[];
value?: string; // optional now
defaultValue?: string; // NEW (Radix-style)
onChange?: (value: string) => void;
disabled?: boolean;
labelPosition?: "left" | "right";
checkedVariant?: "default" | "classic" | "surface";
animationVariant?: "bounce" | "scale" | "pulse" | "glow" | "shake" | "flip" | "nina";
}
/* ----------------------------------------
* CVA – Radio Item (Outer Circle)
* ------------------------------------- */
const radioItemVariants = cva(
"relative inline-flex items-center justify-center rounded-full border-2 bg-background transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default:
"border-blue-500 data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500",
primary:
"border-blue-800 data-[state=checked]:bg-blue-800 data-[state=checked]:border-blue-800",
success:
"border-success data-[state=checked]:bg-success data-[state=checked]:border-success",
warning:
"border-warning data-[state=checked]:bg-warning data-[state=checked]:border-warning",
danger:
"border-destructive data-[state=checked]:bg-destructive data-[state=checked]:border-destructive",
outline:
"border-input data-[state=checked]:bg-primary data-[state=checked]:border-primary",
subtle:
"border-muted data-[state=checked]:bg-accent data-[state=checked]:border-accent",
neon:
"border-pink-500 data-[state=checked]:bg-pink-500 data-[state=checked]:border-pink-500 shadow shadow-pink-500/40",
},
disabled: {
true:
"cursor-not-allowed border-gray-500 data-[state=checked]:bg-gray-500 data-[state=checked]:border-gray-500 opacity-50",
false: "",
},
size: {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
xl: "h-7 w-7",
},
},
defaultVariants: {
variant: "default",
size: "md",
disabled: false,
},
}
);
const radioOppositeItemVariants = cva(
"relative inline-flex items-center justify-center rounded-full border-2 bg-background transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default:
"border-blue-500",
primary:
"border-blue-800",
success:
"border-success",
warning:
"border-warning",
danger:
"border-destructive",
outline:
"border-input",
subtle:
"border-muted",
neon:
"border-pink-500",
},
disabled: {
true:
"cursor-not-allowed border-gray-500 data-[state=checked]:bg-gray-500 data-[state=checked]:border-gray-500 opacity-50",
false: "",
},
size: {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
xl: "h-7 w-7",
},
},
defaultVariants: {
variant: "default",
size: "md",
disabled: false,
},
}
);
/* ----------------------------------------
* CVA – Indicator (Inner Dot)
* ------------------------------------- */
const radioIndicatorVariants = cva(
"rounded-full bg-white transition-transform duration-200 scale-0 data-[state=checked]:scale-100",
{
variants: {
size: {
xs: "h-1 w-1",
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
xl: "h-3 w-3",
},
},
defaultVariants: {
size: "md",
},
}
);
const radioIndicatorDefaultVariants = cva(
"rounded-full transition-transform duration-200 scale-0 data-[state=checked]:scale-100",
{
variants: {
variant: {
default:
"border-blue-500 data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500",
primary:
"border-blue-800 data-[state=checked]:bg-blue-800 data-[state=checked]:border-blue-800",
success:
"border-success data-[state=checked]:bg-success data-[state=checked]:border-success",
warning:
"border-warning data-[state=checked]:bg-warning data-[state=checked]:border-warning",
danger:
"border-destructive data-[state=checked]:bg-destructive data-[state=checked]:border-destructive",
outline:
"border-input data-[state=checked]:bg-primary data-[state=checked]:border-primary",
subtle:
"border-muted data-[state=checked]:bg-accent data-[state=checked]:border-accent",
neon:
"border-pink-500 data-[state=checked]:bg-pink-500 data-[state=checked]:border-pink-500 shadow shadow-pink-500/40",
},
size: {
xs: "h-1 w-1",
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
xl: "h-3 w-3",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
}
);
/* ----------------------------------------
* Motion Variants
* ------------------------------------- */
export const radioMotionVariants: Record<string, Variants> = {
bounce: {
unchecked: { scale: 1 },
checked: {
scale: [1, 1.4, 0.9, 1],
transition: { duration: 0.4 },
},
},
scale: {
unchecked: { scale: 1 },
checked: {
scale: 1.3,
transition: { type: "spring", stiffness: 300 },
},
},
pulse: {
unchecked: { boxShadow: "0 0 0 rgba(59,130,246,0)" },
checked: {
boxShadow: [
"0 0 0 rgba(59,130,246,0)",
"0 0 0 10px rgba(59,130,246,0.4)",
"0 0 0 rgba(59,130,246,0)",
],
transition: { duration: 0.6 },
},
},
glow: {
unchecked: { boxShadow: "0 0 0 transparent" },
checked: {
boxShadow: "0 0 20px rgba(59,130,246,0.8)",
transition: { duration: 0.3 },
},
},
shake: {
unchecked: { x: 0 },
checked: {
x: [0, -6, 6, -6, 6, 0],
transition: { duration: 0.4 },
},
},
flip: {
unchecked: { rotateY: 0 },
checked: {
rotateY: 180,
transition: { duration: 0.5 },
},
},
nina: {
unchecked: { scale: 1 },
checked: {
scale: [1, 0.8, 1.2, 1],
transition: { duration: 0.35 },
},
},
};
/* ----------------------------------------
* Component
* ------------------------------------- */
export const RadioGroup: React.FC<RadioGroupProps> = ({
name,
options,
value,
defaultValue,
onChange,
variant = "default",
size = "md",
disabled,
labelPosition = "right",
checkedVariant = "surface",
animationVariant = "bounce",
}) => {
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = React.useState<string>(
value ??
defaultValue ??
options.find((o) => !o.disabled)?.value ??
""
);
React.useEffect(() => {
if (isControlled && value !== undefined) {
setInternalValue(value);
}
}, [value, isControlled]);
const currentValue = isControlled ? value : internalValue;
return (
<RadixRadio.Root
name={name}
value={currentValue}
onValueChange={(val) => {
if (!isControlled) {
setInternalValue(val);
}
onChange?.(val);
}}
className="flex flex-col gap-2"
>
{options.map((opt) => (
<label
key={opt.value}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1",
opt.disabled || disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-muted/50"
)}
>
{labelPosition === "left" && (
<span className="text-sm font-medium">{opt.label}</span>
)}
<motion.div
variants={radioMotionVariants[animationVariant]}
initial="unchecked"
animate={currentValue === opt.value ? "checked" : "unchecked"}
className="rounded-full"
>
<RadixRadio.Item
value={opt.value}
disabled={disabled || opt.disabled}
className={cn(
checkedVariant !== "default" ?
radioItemVariants({
variant,
size,
disabled: disabled || opt.disabled,
}) : radioOppositeItemVariants({
variant,
size,
disabled: disabled || opt.disabled,
})
)}
>
{checkedVariant === "surface" && (
<RadixRadio.Indicator
forceMount
className={cn(
"flex items-center justify-center",
radioIndicatorVariants({ size })
)}
/>
)}
{checkedVariant === "default" && (
<RadixRadio.Indicator
forceMount
className={cn(
"flex items-center justify-center",
radioIndicatorDefaultVariants({ variant, size })
)}
/>
)}
</RadixRadio.Item>
</motion.div>
{labelPosition === "right" && (
<span className="text-sm font-medium">{opt.label}</span>
)}
</label>
))}
</RadixRadio.Root>
);
};
Usage
Import the component:
import { RadioGroup } from '@src/components/ui/radio';
RadioGroup
import { RadioGroup } from "./components/ui/radio";
function App() {
const [value, setValue] = useState("one");
const options = [
{ value: "one", label: "Option One" },
{ value: "two", label: "Option Two" },
];
const handleChange = (value: string) => {
setValue(value);
};
const [disabled, setDisabled] = React.useState(false);
return (
<>
<RadioGroup
options={options}
value={value}
onChange={handleChange}
labelPosition="right"
size="md"
checkedVariant="surface"
variant="default"
animationVariant="bounce"
disabled={disabled}
/>
</>
);
}
export default App;
Examples
Sizes Variants
Use the size prop to control the radio button size.
Extra Small
Small
Medium
Large
Extra Large
Animation Variants
Use the animationVariant prop to control the radio button animation.
Bounce
Scale
Pulse
Glow
Shake
Flip
Nina
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline' | 'subtle' | 'glass' | 'neon' | 'default' | The visual style of the checkbox |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The size of the checkbox |
options | array | - | Options for Radio |
labelPosition | 'left' | 'right' | 'right' | Position of the label relative to the checkbox |
disabled | boolean | false | Whether the checkbox is disabled |
animationVariant | 'bounce' | 'scale' | 'pulse' | 'glow' | 'shake' | 'flip' | 'nina' | 'bounce' | Animation style for interactions |
checkedVariant | 'classic' | 'default' | 'surface' | 'surface' | Default checked state for uncontrolled usage |
defaultChecked | boolean | - | Default checked state for uncontrolled usage |
onChange | (value: string) => void | - | Callback when checked state changes |