# Radio Card Group

> Selectable radio cards built on native radios with arrow-key support, great for plan pickers.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/radio-card-group
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/radio-card-group.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/radio-card-group-demo.tsx"
"use client"

import * as React from "react"
import { Rocket, Sprout, Building2 } from "lucide-react"

import { RadioCardGroup } from "@/components/radio-card-group"

const plans = [
  { value: "hobby", title: "Hobby", description: "For side projects.", meta: "$0", icon: <Sprout /> },
  { value: "pro", title: "Pro", description: "For growing teams.", meta: "$24", icon: <Rocket /> },
  { value: "scale", title: "Scale", description: "SSO, audit logs, SLA.", meta: "$99", icon: <Building2 /> },
]

export default function RadioCardGroupDemo() {
  const [plan, setPlan] = React.useState("pro")
  return (
    <div className="w-full max-w-md">
      <h3 className="mb-3 text-sm font-semibold">Choose a plan</h3>
      <RadioCardGroup aria-label="Plan" options={plans} value={plan} onValueChange={setPlan} />
      <p className="mt-3 text-xs text-muted-foreground" role="status">
        Selected: {plan}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/radio-card-group.tsx"
"use client"

import * as React from "react"
import { Check } from "lucide-react"

import { cn } from "@/lib/utils"

export interface RadioCardOption {
  value: string
  title: string
  description?: string
  /** Small text on the right, such as a price */
  meta?: string
  icon?: React.ReactNode
  disabled?: boolean
}

export interface RadioCardGroupProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  options: RadioCardOption[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  columns?: 1 | 2 | 3
  "aria-label"?: string
}

export function RadioCardGroup({
  options,
  value,
  defaultValue,
  onValueChange,
  columns = 1,
  className,
  ...props
}: RadioCardGroupProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const name = React.useId()
  const selected = value ?? internal

  return (
    <div
      {...props}
      role="radiogroup"
      className={cn("grid gap-3", columns === 2 && "sm:grid-cols-2", columns === 3 && "sm:grid-cols-3", className)}
    >
      {options.map((option) => {
        const checked = selected === option.value
        return (
          <label
            key={option.value}
            className={cn(
              "relative flex cursor-pointer items-start gap-3 rounded-xl border bg-card p-4 text-sm transition-all hover:border-foreground/30 has-focus-visible:ring-4 has-focus-visible:ring-ring/40",
              checked && "border-primary bg-primary/5 shadow-sm hover:border-primary",
              option.disabled && "cursor-not-allowed opacity-50 hover:border-border",
            )}
          >
            <input
              type="radio"
              name={name}
              value={option.value}
              checked={checked}
              disabled={option.disabled}
              className="sr-only"
              onChange={() => {
                setInternal(option.value)
                onValueChange?.(option.value)
              }}
            />
            {option.icon && (
              <span className={cn("mt-0.5 shrink-0 text-muted-foreground [&_svg]:size-5", checked && "text-primary")}>{option.icon}</span>
            )}
            <span className="grid min-w-0 flex-1 gap-0.5 leading-tight">
              <span className="font-medium">{option.title}</span>
              {option.description && <span className="text-muted-foreground">{option.description}</span>}
            </span>
            {option.meta && <span className="shrink-0 font-medium tabular-nums">{option.meta}</span>}
            <span
              aria-hidden
              className={cn(
                "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
                checked ? "border-primary bg-primary text-primary-foreground" : "border-input",
              )}
            >
              {checked && <Check className="size-3" strokeWidth={3} />}
            </span>
          </label>
        )
      })}
    </div>
  )
}
```
