RadioGroup

Accessible radio fieldset with a simple list and a card variant.

import { RadioGroup, RadioOption }from "@/components/ui"

List & cards

Size
Plan

Full source

The complete component, read from src/components/ui/radio-group.tsx.

radio-group.tsx
"use client";

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

export interface RadioOption {
  value: string;
  label: string;
  description?: string;
  disabled?: boolean;
}

interface RadioGroupProps {
  name: string;
  label?: string;
  options: RadioOption[];
  value: string | null;
  onChange: (value: string) => void;

  variant?: "list" | "card";
  disabled?: boolean;
}

export function RadioGroup({
  name,
  label,
  options,
  value,
  onChange,
  variant = "list",
  disabled,
}: RadioGroupProps) {
  if (variant === "card") {
    return (
      <fieldset className="min-w-0" disabled={disabled}>
        {label && <legend className="mb-2 text-[13px] font-medium text-text">{label}</legend>}
        <div className="grid gap-2">
          {options.map((opt) => {
            const on = opt.value === value;
            const off = opt.disabled || disabled;
            return (
              <label
                key={opt.value}
                className={cn(
                  "flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-colors",
                  "focus-within:ring-2 focus-within:ring-accent focus-within:ring-offset-2",
                  on ? "border-accent bg-accent-light" : "border-border bg-surface hover:border-border-hover",
                  off && "cursor-not-allowed opacity-50"
                )}
              >
                <input
                  type="radio"
                  name={name}
                  value={opt.value}
                  checked={on}
                  disabled={off}
                  onChange={() => onChange(opt.value)}
                  className="sr-only"
                />
                <span className={cn(
                  "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border-2",
                  on ? "border-accent" : "border-border"
                )}>
                  {on && <span className="size-2 rounded-full bg-accent" />}
                </span>
                <span className="min-w-0">
                  <span className="block text-sm font-medium text-text">{opt.label}</span>
                  {opt.description && (
                    <span className="mt-0.5 block text-sm text-text-secondary">{opt.description}</span>
                  )}
                </span>
              </label>
            );
          })}
        </div>
      </fieldset>
    );
  }

  return (
    <fieldset className="min-w-0" disabled={disabled}>
      {label && <legend className="mb-2 text-[13px] font-medium text-text">{label}</legend>}
      <div className="flex flex-col gap-2">
        {options.map((opt) => {
          const on = opt.value === value;
          const off = opt.disabled || disabled;
          return (
            <label
              key={opt.value}
              className={cn(
                "flex cursor-pointer items-center gap-2.5",
                off && "cursor-not-allowed opacity-50"
              )}
            >
              <input
                type="radio"
                name={name}
                value={opt.value}
                checked={on}
                disabled={off}
                onChange={() => onChange(opt.value)}
                className="sr-only"
              />
              <span className={cn(
                "flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
                on ? "border-accent" : "border-border"
              )}>
                {on && <span className="size-2 rounded-full bg-accent" />}
              </span>
              <span className="text-sm text-text">{opt.label}</span>
            </label>
          );
        })}
      </div>
    </fieldset>
  );
}

Dependencies

To use RadioGroup you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ radio-group.tsx
src/lib/
  └─ utils.ts