RadioGroup

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

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

Specification

Selects exactly one option from a small mutually-exclusive set, as a simple list or as selectable cards.

Props (7)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
name*string—Radio group name shared by all inputs so only one can be selected.
labelstring—Optional fieldset legend shown above the options.
options*RadioOption[]—Options to render; each has value, label and optional description and disabled.
value*string | null—The value of the currently selected option, or null.
onChange*(value: string) => void—Called with the selected option's value.
variant"list" | "card""list"Renders a compact radio list or larger selectable cards with descriptions.
disabledboolean—Disables the whole group.

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",
                  on ? "border-accent bg-accent-light" : "border-border bg-surface hover:border-border-hover",
                  "focus-within:border-accent",
                  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

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}