Radio Group
Radio Group is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
forms
What it is
Radio Group is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion radix-ui
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Radio Group
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion } from "motion/react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import type React from "react";
import { Children, cloneElement, isValidElement } from "react";
import { SPRING_DEFAULT } from "@/components/smoothui/lib/animation";
export interface RadioGroupProps {
/** Radio items to render */
children: React.ReactNode;
/** Optional CSS class for the group container */
className?: string;
/** The default value when uncontrolled */
defaultValue?: string;
/** Whether the entire group is disabled */
disabled?: boolean;
/** Name attribute for form submission */
name?: string;
/** Callback when the selected value changes */
onValueChange?: (value: string) => void;
/** Orientation of the radio group for arrow key navigation */
orientation?: "horizontal" | "vertical";
/** Whether a selection is required */
required?: boolean;
/** The controlled value of the selected radio item */
value?: string;
}
export interface RadioProps {
/** @internal Stagger index passed by RadioGroup */
_staggerIndex?: number;
/** Children (label content) to render next to the radio */
children?: React.ReactNode;
/** Optional CSS class */
className?: string;
/** Whether this radio is disabled */
disabled?: boolean;
/** ID for label association */
id?: string;
/** The value of this radio option */
value: string;
}
/** Spring for the selection dot — playful bounce per CLAUDE.md (0.2-0.3 for playful interactions) */
const SPRING_DOT = {
bounce: 0.2,
duration: 0.25,
type: "spring" as const,
};
/** Stagger delay per radio item on initial render */
const STAGGER_DELAY = 0.04;
export function RadioGroup({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
name,
required,
orientation = "vertical",
}: RadioGroupProps) {
const shouldReduceMotion = useReducedMotion();
// Inject stagger index into Radio children for entrance stagger
let radioIndex = 0;
const enhancedChildren = Children.map(children, (child) => {
if (
isValidElement(child) &&
(child.type === Radio ||
(child.type as { displayName?: string })?.displayName === "Radio")
) {
const currentIndex = radioIndex;
radioIndex += 1;
return cloneElement(child as React.ReactElement<RadioProps>, {
_staggerIndex: currentIndex,
});
}
return child;
});
return (
<RadioGroupPrimitive.Root
className={cn(
orientation === "vertical" ? "grid gap-3" : "flex items-center gap-4",
className
)}
data-slot="radio-group"
defaultValue={defaultValue}
disabled={disabled}
name={name}
onValueChange={onValueChange}
orientation={orientation}
required={required}
value={value}
>
{/* biome-ignore lint/suspicious/noLeakedRender: both branches are ReactNode, which is exactly what a wrapper should render */}
{shouldReduceMotion ? children : enhancedChildren}
</RadioGroupPrimitive.Root>
);
}
export function Radio({
value,
disabled = false,
className,
id,
children,
_staggerIndex,
}: RadioProps) {
const shouldReduceMotion = useReducedMotion();
const staggerDelay =
_staggerIndex === undefined ? 0 : _staggerIndex * STAGGER_DELAY;
const wrapper = (content: React.ReactNode) => {
if (shouldReduceMotion || _staggerIndex === undefined) {
return <div className="flex items-center gap-2">{content}</div>;
}
return (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
className="flex items-center gap-2"
initial={{ opacity: 0, transform: "translateY(4px)" }}
transition={{
...SPRING_DEFAULT,
delay: staggerDelay,
}}
>
{content}
</motion.div>
);
};
return wrapper(
<>
<motion.div
className="relative"
transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
whileHover={shouldReduceMotion ? {} : { transform: "scale(1.1)" }}
>
<RadioGroupPrimitive.Item
className={cn(
"group/radio aspect-square size-4 shrink-0 rounded-full border border-input bg-background shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-brand dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
data-slot="radio-group-item"
disabled={disabled}
id={id}
value={value}
>
<RadioGroupPrimitive.Indicator
className="flex items-center justify-center"
data-slot="radio-group-indicator"
>
<motion.span
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, transform: "scale(1)" }
}
className="block size-2 rounded-full bg-brand"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
transform: "scale(0)",
transition: { ...SPRING_DEFAULT, bounce: 0 },
}
}
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, transform: "scale(0)" }
}
transition={shouldReduceMotion ? { duration: 0 } : SPRING_DOT}
/>
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
</motion.div>
{children ? (
<label
className={cn(
"font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
disabled && "cursor-not-allowed opacity-70"
)}
htmlFor={id}
>
{children}
</label>
) : null}
</>
);
}
Radio.displayName = "Radio";
export default RadioGroup;