Inputs & Forms

Radio Group

A Radix RadioGroup whose dot scales in from 0.5 with the house ease-out, plus a selectable bordered card variant.

Install

npx shadcn@latest add @paragon/radio-group

radio-group.tsx

"use client";

import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@/lib/utils";

function RadioGroup({
  className,
  ...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
  return (
    <RadioGroupPrimitive.Root
      data-slot="radio-group"
      className={cn("grid gap-2.5", className)}
      {...props}
    />
  );
}

/**
 * Standard radio. The dot is permanently mounted and scales in from 0.5 with
 * the house ease-out, so rapid re-selection retargets instead of restarting.
 */
function RadioGroupItem({
  className,
  ...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
  return (
    <RadioGroupPrimitive.Item
      data-slot="radio-group-item"
      className={cn(
        "group relative aspect-square size-4 shrink-0 rounded-full border border-input bg-transparent",
        "transition-[border-color,box-shadow] duration-150 ease-out",
        "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "data-[state=checked]:border-primary",
        "disabled:cursor-not-allowed disabled:opacity-50",
        // Extend the hit area to 40px without growing the visual.
        "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2",
        className,
      )}
      {...props}
    >
      <span
        aria-hidden
        className={cn(
          "pointer-events-none absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary",
          "scale-50 opacity-0 transition-[scale,opacity] duration-200 ease-out motion-reduce:transition-[opacity]",
          "group-data-[state=checked]:scale-100 group-data-[state=checked]:opacity-100",
        )}
      />
    </RadioGroupPrimitive.Item>
  );
}

/**
 * Card variant: a selectable bordered card. Compose children freely; a radio
 * dot renders in the top-right corner.
 */
function RadioGroupCard({
  className,
  children,
  ...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
  return (
    <RadioGroupPrimitive.Item
      data-slot="radio-group-card"
      className={cn(
        "group relative w-full rounded-xl border bg-card p-4 text-left",
        "transition-[border-color,box-shadow,background-color] duration-150 ease-out",
        "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "data-[state=checked]:border-primary data-[state=checked]:ring-1 data-[state=checked]:ring-primary",
        "disabled:cursor-not-allowed disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <span
        aria-hidden
        className={cn(
          "pointer-events-none absolute top-3.5 right-3.5 flex size-4 items-center justify-center rounded-full border border-input",
          "transition-[border-color] duration-150 ease-out",
          "group-data-[state=checked]:border-primary",
        )}
      >
        <span
          className={cn(
            "size-2 rounded-full bg-primary",
            "scale-50 opacity-0 transition-[scale,opacity] duration-200 ease-out motion-reduce:transition-[opacity]",
            "group-data-[state=checked]:scale-100 group-data-[state=checked]:opacity-100",
          )}
        />
      </span>
      <div className="pr-7">{children}</div>
    </RadioGroupPrimitive.Item>
  );
}

export { RadioGroup, RadioGroupItem, RadioGroupCard };