# Radio Group

Roving-focus radios whose dot springs in, with a shared highlight that glides between card options.

**Category:** Inputs  
**Exports:** `RadioGroup`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/radio-group.json
```
The file lands at `components/ui/radio-group.tsx`; import from `@/components/ui/radio-group`.

## API — Radio Group
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | — | Controlled value. |
| `defaultValue` | `string` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — |  |
| `options` | `{ value: string; label: ReactNode; description?: ReactNode; disabled?: boolean }[]` | — |  |
| `variant` | `"list" \| "cards"` | `"list"` |  |
| `aria-label` | `string` | — |  |

## Example — Radio Group

```tsx
import { RadioGroup } from "@/components/ui/radio-group";

export default function RadioGroupDemo({ variant = "cards" }: { variant?: "list" | "cards" }) {
  return (
    <div className="grid w-full max-w-sm gap-8">
      <RadioGroup
        aria-label="Plan"
        variant={variant}
        defaultValue="pro"
        options={[
          { value: "hobby", label: "Hobby", description: "For side projects. Free forever." },
          { value: "pro", label: "Pro", description: "$12 a month. Unlimited projects." },
          { value: "team", label: "Team", description: "$40 a month. Shared workspaces." },
        ]}
      />
      <RadioGroup
        aria-label="Density"
        defaultValue="comfortable"
        options={[
          { value: "compact", label: "Compact" },
          { value: "comfortable", label: "Comfortable" },
          { value: "spacious", label: "Spacious", disabled: true },
        ]}
      />
    </div>
  );
}
```

## Source — components/ui/radio-group.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type RadioOption = { value: string; label: React.ReactNode; description?: React.ReactNode; disabled?: boolean };

export type RadioGroupProps = {
  options: RadioOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  variant?: "list" | "cards";
  className?: string;
  "aria-label"?: string;
};

export function RadioGroup({ options, value: valueProp, defaultValue, onValueChange, variant = "list", className, ...aria }: RadioGroupProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue);
  const value = valueProp ?? inner;
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const focusIndex = Math.max(0, options.findIndex((o) => o.value === value && !o.disabled));
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 34 };

  const select = (v: string) => {
    if (valueProp === undefined) setInner(v);
    onValueChange?.(v);
  };

  const onKeyDown = (e: React.KeyboardEvent, i: number) => {
    const dir = ["ArrowDown", "ArrowRight"].includes(e.key) ? 1 : ["ArrowUp", "ArrowLeft"].includes(e.key) ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    for (let s = 1; s <= options.length; s++) {
      const j = (i + dir * s + options.length) % options.length;
      if (!options[j].disabled) {
        select(options[j].value);
        refs.current[j]?.focus();
        return;
      }
    }
  };

  return (
    <div role="radiogroup" {...aria} className={cn("grid", variant === "cards" ? "gap-2" : "gap-3", className)}>
      {options.map((o, i) => {
        const checked = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={checked}
            aria-describedby={o.description ? `${id}-${o.value}-d` : undefined}
            disabled={o.disabled}
            tabIndex={i === focusIndex ? 0 : -1}
            onClick={() => select(o.value)}
            onKeyDown={(e) => onKeyDown(e, i)}
            className={cn(
              "group relative flex cursor-pointer items-start gap-3 text-left outline-none disabled:cursor-not-allowed disabled:opacity-50",
              "focus-visible:ring-2 focus-visible:ring-ring",
              variant === "cards" ? "rounded-xl p-4" : "rounded-md",
            )}
          >
            {variant === "cards" && (
              <span aria-hidden className="absolute inset-0 rounded-xl border bg-card transition-colors group-hover:bg-accent/40" />
            )}
            {variant === "cards" && checked && (
              <motion.span
                layoutId={`${id}-card`}
                aria-hidden
                className="absolute inset-0 rounded-xl border border-foreground/70 bg-accent/60"
                transition={spring}
              />
            )}
            <span
              className={cn(
                "relative z-10 mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border transition-colors duration-150",
                checked ? "border-foreground" : "border-input group-hover:border-muted-foreground",
              )}
            >
              <motion.span
                className="size-2.5 rounded-full bg-foreground"
                initial={false}
                animate={{ scale: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 22 }}
              />
            </span>
            <span className="relative z-10 grid gap-0.5">
              <span className="text-sm font-medium leading-6">{o.label}</span>
              {o.description && (
                <span id={`${id}-${o.value}-d`} className="text-[13px] leading-5 text-muted-foreground">
                  {o.description}
                </span>
              )}
            </span>
          </button>
        );
      })}
    </div>
  );
}
```