Component

Radio Card Group

Group of selectable card radios in a fieldset/legend; only one may be selected.

  • Component
  • React
  • TSX
  • Tailwind CSS
  • MIT

What it does

Capabilities

  • Semantic HTML
  • Native form control
  • Native checkbox
  • Associated labels
  • ARIA

Preview

Live component

Interactive preview
Open preview
9:41 100%
devsnips.site/library/react/components/radios/radio-card-group/
fluid · no fixed width 100%
No dependencies Responsive · Light/dark · Focus-visible

Install

Add to your project

terminal
npx devsnips add React/Components/Radios/radio-card-group
registry path: React/Components/Radios/radio-card-group

Source

Implementation

code.tsx
import type { ChangeEvent, ReactNode } from "react";
import { useId, useState } from "react";

function cx(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

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

export interface RadioCardGroupProps {
  legend: ReactNode;
  options: RadioCardOption[];
  value?: string;
  defaultValue?: string;
  onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  error?: string;
  helperText?: ReactNode;
  name?: string;
  id?: string;
  columns?: 1 | 2 | 3;
  className?: string;
}

/**
 * A group of selectable card radios inside a `<fieldset>`/`<legend>`. Only
 * one card may be selected. Controlled and uncontrolled modes both supported.
 * Each card is a clickable `<label>` wrapping a real `<input type="radio">`.
 */
export function RadioCardGroup({
  legend,
  options,
  value,
  defaultValue = "",
  onChange,
  disabled,
  required,
  invalid,
  error,
  helperText,
  name,
  id,
  columns = 1,
  className,
}: RadioCardGroupProps) {
  const generatedId = useId();
  const groupId = id ?? `radio-card-group-${generatedId}`;
  const groupName = name ?? groupId;
  const messageId = `${groupId}-msg`;
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState<string>(defaultValue);
  const selected = isControlled ? value : internal;

  function handleChange(option: RadioCardOption, event: ChangeEvent<HTMLInputElement>) {
    if (!isControlled) setInternal(option.value);
    onChange?.(option.value, event);
  }

  const describedby = error || helperText ? messageId : undefined;
  const gridCols = columns === 3 ? "sm:grid-cols-3" : columns === 2 ? "sm:grid-cols-2" : "grid-cols-1";

  return (
    <fieldset
      id={groupId}
      className={cx("min-w-0 border-0 p-0", className)}
      aria-invalid={invalid || error ? true : undefined}
      aria-describedby={describedby}
    >
      <legend className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">
        {legend}
        {required ? (
          <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
        ) : null}
      </legend>
      <div className={cx("grid gap-2", gridCols)}>
        {options.map((option) => {
          const isOn = selected === option.value;
          const isDisabled = option.disabled || disabled;
          return (
            <label
              key={option.value}
              htmlFor={`${groupId}-${option.value}`}
              className={cx(
                "relative flex w-full items-start gap-3 rounded-[var(--ds-radius-md)] border bg-[var(--ds-color-surface)] p-3.5 text-left transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-within:border-[var(--ds-color-border-strong)] motion-reduce:transition-none",
                isDisabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
                isOn ? "border-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
                (invalid || error) && "border-[var(--ds-color-destructive)]",
              )}
            >
              <span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
                <input
                  id={`${groupId}-${option.value}`}
                  type="radio"
                  name={groupName}
                  value={option.value}
                  checked={isOn}
                  disabled={isDisabled}
                  required={required}
                  aria-invalid={invalid || error ? true : undefined}
                  aria-describedby={option.description ? `${groupId}-${option.value}-desc` : undefined}
                  className={cx(
                    "size-[18px] cursor-pointer appearance-none rounded-full border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
                    invalid || error
                      ? "border-[var(--ds-color-destructive)]"
                      : "border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]",
                  )}
                  onChange={(e) => handleChange(option, e)}
                />
                <span
                  aria-hidden="true"
                  className={cx(
                    "pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 motion-reduce:transition-none",
                    isOn ? "opacity-100" : "opacity-0",
                  )}
                >
                  <span
                    className={cx(
                      "block size-[8px] rounded-full",
                      invalid || error ? "bg-[var(--ds-color-destructive)]" : "bg-[var(--ds-color-primary)]",
                    )}
                  />
                </span>
              </span>
              <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">{option.label}</span>
                {option.description ? (
                  <span id={`${groupId}-${option.value}-desc`} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
                    {option.description}
                  </span>
                ) : null}
              </span>
            </label>
          );
        })}
      </div>
      {error ? (
        <p id={messageId} role="alert" className="mt-2 text-xs leading-4 text-[var(--ds-color-destructive)]">
          {error}
        </p>
      ) : helperText ? (
        <p id={messageId} className="mt-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
          {helperText}
        </p>
      ) : null}
    </fieldset>
  );
}

export default RadioCardGroup;
156 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf

Agent skill & Model Context Protocol

Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.

devsnips://components/radios/radio-card-group
Prompt instruction
I want to use this DevSnips resource in production:

Page:
https://devsnips.site/library/react/components/radios/radio-card-group/

Install: npx devsnips add
React/Components/Radios/radio-card-group

This is the Radio Card Group — Group of selectable card radios in a fieldset/legend; only one may be selected.

Use this DevSnips resource as the starting point for my project.
Adapt it to my project's stack and conventions, fix any issues,
and make it production-ready while preserving its intended design
and behavior.

Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.

Resource URI
devsnips://components/radios/radio-card-group