All components

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.

Open in the catalogue

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

index.tsx
"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;

Similar components