React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

radioradio-groupformselectioncomponenttailwind

Radio Group

A set of mutually exclusive radio buttons for selecting one option from a group. Uses native radio inputs with shared name attributes for built-in keyboard navigation and accessibility.

Use Cases

  • Payment method selection (credit card, PayPal, bank transfer)
  • Shipping speed options at checkout
  • Plan or tier selection on pricing pages
  • Gender or demographic selection on profile forms
  • Sort order selection (newest, oldest, popular)
  • Theme preference (light, dark, system)
  • Survey or poll single-choice questions

Simplest Implementation

"use client";
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 space-y-2">
        {options.map((opt) => (
          <label key={opt.value} className="flex cursor-pointer items-center gap-2">
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
            />
            <span className="text-sm text-gray-700">{opt.label}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

A minimal radio group using <fieldset> and <legend> for semantic grouping. All radios share the same name attribute, which lets the browser enforce mutual exclusivity and enables arrow-key navigation between options natively.

Variations

Basic Vertical

"use client";
 
interface Option {
  value: string;
  label: string;
}
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: Option[];
  value: string;
  onChange: (value: string) => void;
  error?: string;
}
 
export function RadioGroup({ label, name, options, value, onChange, error }: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 space-y-2">
        {options.map((opt) => (
          <label key={opt.value} className="flex cursor-pointer items-center gap-2">
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              aria-invalid={!!error}
              className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
            />
            <span className="text-sm text-gray-700">{opt.label}</span>
          </label>
        ))}
      </div>
      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
    </fieldset>
  );
}

A vertical stack of radio options with optional error state. The aria-invalid attribute on each radio input communicates validation problems to screen readers. The error message appears below the entire group.

Horizontal Layout

"use client";
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroupHorizontal({ label, name, options, value, onChange }: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 flex flex-wrap gap-4">
        {options.map((opt) => (
          <label key={opt.value} className="flex cursor-pointer items-center gap-2">
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
            />
            <span className="text-sm text-gray-700">{opt.label}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

Options are arranged in a horizontal row using flex and gap-4. The flex-wrap class ensures options wrap to the next line on narrow screens instead of overflowing. Best suited for groups with 2-4 short-labeled options.

With Descriptions

"use client";
 
interface Option {
  value: string;
  label: string;
  description: string;
}
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: Option[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroupWithDescriptions({
  label,
  name,
  options,
  value,
  onChange,
}: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 space-y-3">
        {options.map((opt) => (
          <label
            key={opt.value}
            className="flex cursor-pointer items-start gap-3"
          >
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
            />
            <div>
              <span className="text-sm font-medium text-gray-900">{opt.label}</span>
              <p className="text-sm text-gray-500">{opt.description}</p>
            </div>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

Each option has a secondary description below the label. The radio input is aligned to the top of the text block with items-start and mt-0.5. This pattern works well for plan selection or shipping options where each choice needs explanation.

Card-Style Radio

"use client";
 
interface Option {
  value: string;
  label: string;
  description: string;
  price?: string;
}
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: Option[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroupCards({
  label,
  name,
  options,
  value,
  onChange,
}: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 space-y-3">
        {options.map((opt) => (
          <label
            key={opt.value}
            className={`flex cursor-pointer items-start gap-3 rounded-lg border-2 p-4 transition-colors ${
              value === opt.value
                ? "border-blue-600 bg-blue-50"
                : "border-gray-200 hover:border-gray-300"
            }`}
          >
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
            />
            <div className="flex-1">
              <div className="flex items-center justify-between">
                <span className="text-sm font-medium text-gray-900">{opt.label}</span>
                {opt.price && (
                  <span className="text-sm font-semibold text-gray-900">{opt.price}</span>
                )}
              </div>
              <p className="mt-1 text-sm text-gray-500">{opt.description}</p>
            </div>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

Wraps each radio option in a card with a visible border that highlights when selected. The selected card gets a blue border and light blue background. An optional price field is right-aligned in the header row. This pattern is common for pricing tiers or shipping method selection.

With Icons

"use client";
 
interface Option {
  value: string;
  label: string;
  icon: React.ReactNode;
}
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: Option[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroupWithIcons({
  label,
  name,
  options,
  value,
  onChange,
}: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 grid grid-cols-3 gap-3">
        {options.map((opt) => (
          <label
            key={opt.value}
            className={`flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors ${
              value === opt.value
                ? "border-blue-600 bg-blue-50 text-blue-600"
                : "border-gray-200 text-gray-500 hover:border-gray-300"
            }`}
          >
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              className="sr-only"
            />
            <span className="h-6 w-6">{opt.icon}</span>
            <span className="text-sm font-medium">{opt.label}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

A grid of icon-centric radio cards with the native radio input visually hidden using sr-only. The entire card acts as the click target. The selected card is highlighted with a blue border and icon color. This works well for view mode toggles (grid/list), theme selection, or category pickers.

Disabled Options

"use client";
 
interface Option {
  value: string;
  label: string;
  disabled?: boolean;
}
 
interface RadioGroupProps {
  label: string;
  name: string;
  options: Option[];
  value: string;
  onChange: (value: string) => void;
}
 
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700">{label}</legend>
      <div className="mt-2 space-y-2">
        {options.map((opt) => (
          <label
            key={opt.value}
            className={`flex items-center gap-2 ${
              opt.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
            }`}
          >
            <input
              type="radio"
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={(e) => onChange(e.target.value)}
              disabled={opt.disabled}
              className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed"
            />
            <span className="text-sm text-gray-700">{opt.label}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

Individual options can be disabled while others remain interactive. The opacity-50 class and cursor-not-allowed on the label provide clear visual feedback. Disabled radio buttons are skipped during keyboard navigation automatically by the browser.

Complex Implementation

"use client";
 
import { forwardRef, useId, createContext, useContext, useCallback } from "react";
 
type RadioSize = "sm" | "md" | "lg";
type RadioVariant = "default" | "card" | "icon";
 
interface RadioGroupContextValue {
  name: string;
  value: string;
  onChange: (value: string) => void;
  size: RadioSize;
  variant: RadioVariant;
  disabled: boolean;
}
 
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
 
function useRadioGroup() {
  const ctx = useContext(RadioGroupContext);
  if (!ctx) throw new Error("RadioGroupItem must be used within RadioGroup");
  return ctx;
}
 
interface RadioGroupProps {
  label?: string;
  helperText?: string;
  error?: string;
  name: string;
  value: string;
  onChange: (value: string) => void;
  size?: RadioSize;
  variant?: RadioVariant;
  disabled?: boolean;
  orientation?: "vertical" | "horizontal";
  children: React.ReactNode;
  id?: string;
}
 
const sizeClasses: Record<RadioSize, { radio: string; label: string; desc: string }> = {
  sm: { radio: "h-3.5 w-3.5", label: "text-xs", desc: "text-xs" },
  md: { radio: "h-4 w-4", label: "text-sm", desc: "text-sm" },
  lg: { radio: "h-5 w-5", label: "text-base", desc: "text-sm" },
};
 
export function RadioGroup({
  label,
  helperText,
  error,
  name,
  value,
  onChange,
  size = "md",
  variant = "default",
  disabled = false,
  orientation = "vertical",
  children,
  id: externalId,
}: RadioGroupProps) {
  const generatedId = useId();
  const groupId = externalId ?? generatedId;
  const errorId = `${groupId}-error`;
  const helperId = `${groupId}-helper`;
  const sizes = sizeClasses[size];
 
  return (
    <RadioGroupContext.Provider value={{ name, value, onChange, size, variant, disabled }}>
      <fieldset
        aria-invalid={!!error}
        aria-describedby={
          [error ? errorId : null, helperText ? helperId : null]
            .filter(Boolean)
            .join(" ") || undefined
        }
        disabled={disabled}
      >
        {label && (
          <legend className={`font-medium text-gray-700 ${sizes.label}`}>
            {label}
          </legend>
        )}
        <div
          role="radiogroup"
          className={[
            "mt-2",
            orientation === "horizontal" ? "flex flex-wrap gap-4" : "space-y-3",
          ].join(" ")}
        >
          {children}
        </div>
        {error && (
          <p id={errorId} className={`mt-2 text-red-600 ${sizes.desc}`} role="alert">
            {error}
          </p>
        )}
        {!error && helperText && (
          <p id={helperId} className={`mt-2 text-gray-500 ${sizes.desc}`}>
            {helperText}
          </p>
        )}
      </fieldset>
    </RadioGroupContext.Provider>
  );
}
 
interface RadioGroupItemProps {
  value: string;
  label: string;
  description?: string;
  icon?: React.ReactNode;
  disabled?: boolean;
}
 
export const RadioGroupItem = forwardRef<HTMLInputElement, RadioGroupItemProps>(
  function RadioGroupItem({ value: itemValue, label, description, icon, disabled: itemDisabled }, ref) {
    const { name, value, onChange, size, variant, disabled: groupDisabled } = useRadioGroup();
    const id = useId();
    const descriptionId = `${id}-desc`;
    const isDisabled = groupDisabled || itemDisabled;
    const isSelected = value === itemValue;
    const sizes = sizeClasses[size];
 
    const handleChange = useCallback(() => {
      if (!isDisabled) onChange(itemValue);
    }, [isDisabled, onChange, itemValue]);
 
    if (variant === "card") {
      return (
        <label
          className={[
            "flex items-start gap-3 rounded-lg border-2 p-4 transition-colors",
            isDisabled
              ? "cursor-not-allowed opacity-50"
              : "cursor-pointer",
            isSelected
              ? "border-blue-600 bg-blue-50"
              : isDisabled
                ? "border-gray-200"
                : "border-gray-200 hover:border-gray-300",
          ]
            .filter(Boolean)
            .join(" ")}
        >
          <input
            ref={ref}
            id={id}
            type="radio"
            name={name}
            value={itemValue}
            checked={isSelected}
            onChange={handleChange}
            disabled={isDisabled}
            aria-describedby={description ? descriptionId : undefined}
            className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
          />
          <div>
            <span className={`font-medium text-gray-900 ${sizes.label}`}>{label}</span>
            {description && (
              <p id={descriptionId} className={`mt-0.5 text-gray-500 ${sizes.desc}`}>
                {description}
              </p>
            )}
          </div>
        </label>
      );
    }
 
    if (variant === "icon" && icon) {
      return (
        <label
          className={[
            "flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors",
            isDisabled
              ? "cursor-not-allowed opacity-50"
              : "cursor-pointer",
            isSelected
              ? "border-blue-600 bg-blue-50 text-blue-600"
              : isDisabled
                ? "border-gray-200 text-gray-400"
                : "border-gray-200 text-gray-500 hover:border-gray-300",
          ]
            .filter(Boolean)
            .join(" ")}
        >
          <input
            ref={ref}
            id={id}
            type="radio"
            name={name}
            value={itemValue}
            checked={isSelected}
            onChange={handleChange}
            disabled={isDisabled}
            className="sr-only"
          />
          <span className="h-6 w-6">{icon}</span>
          <span className={`font-medium ${sizes.label}`}>{label}</span>
        </label>
      );
    }
 
    return (
      <label
        className={`flex items-start gap-3 ${
          isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
        }`}
      >
        <input
          ref={ref}
          id={id}
          type="radio"
          name={name}
          value={itemValue}
          checked={isSelected}
          onChange={handleChange}
          disabled={isDisabled}
          aria-describedby={description ? descriptionId : undefined}
          className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
        />
        <div>
          <span className={`font-medium text-gray-700 ${sizes.label}`}>{label}</span>
          {description && (
            <p id={descriptionId} className={`text-gray-500 ${sizes.desc}`}>
              {description}
            </p>
          )}
        </div>
      </label>
    );
  }
);

Key aspects:

  • Compound component pattern -- RadioGroup and RadioGroupItem share state via React context. This keeps the API clean: the parent holds value and onChange, while each item only declares its own value and label.
  • forwardRef on items -- allows parent components to attach refs to individual radio inputs for focus management or programmatic control.
  • Three visual variants -- default renders standard radios with labels, card wraps each option in a bordered card, and icon centers an icon with a hidden radio input. The variant is set once on the group and applies to all items.
  • Cascading disabled -- disabling the group disables all items via both the <fieldset disabled> attribute and context. Individual items can also be disabled independently.
  • Orientation support -- vertical uses space-y-3 for stacked layout, horizontal uses flex with wrapping. The orientation is set on the group container.
  • useId for accessibility -- each item generates its own stable ID to link labels and descriptions. The group-level aria-describedby connects error and helper text to the fieldset.
  • aria-describedby on items -- individual items link to their description text so screen readers announce the secondary information when the radio receives focus.

Gotchas

  • All radios in a group must share the same name -- If even one radio has a different name, the browser treats it as a separate group and will not enforce mutual exclusivity. This is the most common bug with radio groups.

  • Uncontrolled radios cannot be unchecked -- Unlike checkboxes, a radio button cannot be unchecked by clicking it again. Once a selection is made in a radio group, the user can only change it, not clear it. Add a "None" option if clearing is needed.

  • Missing fieldset/legend hurts accessibility -- Screen readers use <fieldset> and <legend> to announce the group label before the first option. Without them, users hear individual radio labels with no context about what the group represents.

  • Hidden radio inputs need focus styles on the card -- When using sr-only to hide radios in card or icon variants, keyboard focus indicators disappear. Add has-[:focus-visible]:ring-2 on the card label (Tailwind v3.4+) or manage a focused state manually.

  • Arrow keys vs Tab for navigation -- Within a radio group, arrow keys move between options and Tab moves to the next focusable element outside the group. If only one radio is focusable (the selected one), Tab skips the entire group. This is correct browser behavior, not a bug.

  • defaultValue with controlled radio groups -- Using defaultChecked on individual radios while also passing a controlled value causes conflicting state. Always use controlled (checked) or uncontrolled (defaultChecked) consistently across all radios in the group.

  • Checkbox -- Boolean toggle for multi-select scenarios
  • Select -- Dropdown alternative for long option lists
  • Card -- Card component used as a base for card-style radio options
  • Forms -- Form patterns and validation strategies
  • Event Handling -- onChange and event typing