Component

Radio Card Group

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

  • Component
  • React
  • TSX
  • Tailwind CSS
  • MIT

Preview

Live component

Interactive preview
Open preview
9:41 100%
devsnips.dev/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

AI-ready

Available to your agent.

This component is reachable through the DevSnips CLI, MCP server and Skills integrations — one registry, one resource path.

Resource path

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

Continue browsing

View all