---
title: Radio Group
description: A group of radio buttons for single-choice selection with keyboard navigation and accessibility support
---

```tsx
'use client';

import { FormControl } from '@/ui/forms/FormControl';
import { RadioGroup } from '@/ui/forms/radios/RadioGroup';
import { Radio } from '@/ui/forms/radios/Radio';
import { useState } from 'react';

export function RadioGroupDemo() {
  const [value, setValue] = useState('option1');

  return (
    <FormControl label="Choose an option" id="demo-radio-group">
      <RadioGroup
        value={value}
        onChange={(_, newValue) => setValue(newValue)}
        name="demo-radio-group"
        aria-labelledby="demo-radio-group"
      >
        <Radio value="option1">Option 1</Radio>
        <Radio value="option2">Option 2</Radio>
        <Radio value="option3">Option 3</Radio>
      </RadioGroup>
    </FormControl>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/radio-group.json
```

Or manually, copy the following code into `/ui/forms/radios/radio-group.tsx`:

```tsx
import {
  Children,
  cloneElement,
  type HTMLAttributes,
  isValidElement,
  useId,
  type ReactElement,
} from 'react';
import type { FormControlProps } from '../FormControl';
import type { RadioProps } from './Radio';
import { useValue } from '../../hooks/useValue';
import clsx from 'clsx';
import type { ButtonProps } from '../../buttons/Button';
import { KEY } from '../../hooks/keyboard.constants';

export interface RadioGroupProps<Value = string>
  extends Pick<
      FormControlProps<Value>,
      'id' | 'name' | 'value' | 'onChange' | 'disabled' | 'readOnly' | 'tabIndex'
    >,
    Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {}

export function RadioGroup<Value = string>(props: RadioGroupProps<Value>) {
  const internalId = useId();

  const {
    id = internalId,
    name = id,
    children,
    value: initialValue,
    defaultValue,
    onChange,
    disabled,
    readOnly,
    tabIndex = 0,
    ...rest
  } = props;

  const { value, setValue } = useValue<Value>({
    initialValue,
    defaultValue: defaultValue as Value,
    onChange,
  });

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;
    const options = (e.target as any)!.parentNode!.querySelectorAll('[role="radio"]');

    if (e.key === KEY.ARROW_RIGHT || e.key === KEY.ARROW_DOWN) {
      newIndex = (index + 1) % options.length;
      options[newIndex]?.focus();
    } else if (e.key === KEY.ARROW_LEFT || e.key === KEY.ARROW_UP) {
      newIndex = (index - 1 + options.length) % options.length;
      options[newIndex]?.focus();
    } else if (e.key === KEY.SPACE || e.key === KEY.ENTER) {
      setValue(options[index].dataset.value as Value, e.nativeEvent);
      return;
    }
  };

  function getTabIndex(checked: boolean, index: number) {
    if (value === undefined || value === null || value === '') {
      return tabIndex === index ? 0 : -1;
    }

    return checked ? 0 : -1;
  }

  return (
    <div {...rest} className={clsx('flex gap-12', rest.className)} role="radiogroup">
      {Children.map(children, (child, index) => {
        if (!isValidElement(child)) {
          return child;
        }

        const type = (child.type as unknown as ReactElement & { displayName?: string }).displayName;
        const itemValue = (child.props as ButtonProps | RadioProps).value;
        const checked = value === itemValue;

        if (type?.includes('Button')) {
          return cloneElement(child, {
            color: checked ? 'black' : 'white',
            ...(child.props as any),
            value: undefined,
            role: 'radio',
            name,
            component: 'span',
            id: `${id}-${index}`,
            disabled,
            readOnly,
            tabIndex: getTabIndex(checked, index),
            'data-value': itemValue,
            'aria-checked': value === itemValue,
            onKeyDown: (e: React.KeyboardEvent) => handleKeyDown(e, index),
            onClick(event: React.MouseEvent) {
              if (!disabled && !readOnly) {
                setValue(itemValue as Value, event.nativeEvent);
                (child.props as any)?.onClick?.(itemValue as Value);
              }
            },
          } as ButtonProps);
        }

        return cloneElement(child, {
          ...(child.props as any),
          name,
          id: `${id}-${index}`,
          disabled,
          readOnly,
          tabIndex: getTabIndex(checked, index),
          checked: checked,
          value: itemValue,
          onChange(event: Event) {
            if (!disabled && !readOnly) {
              setValue(itemValue as Value, event);
            }
          },
        } as RadioProps);
      })}
    </div>
  );
}
```

## Usage

```tsx showLineNumbers
'use client';

import { FormControl } from '@/docs/lib/ui/forms/form-control';
import { RadioGroup } from '@/docs/lib/ui/forms/radios/radio-group';
import { Radio } from '@/docs/lib/ui/forms/radios/radio';
import { useState } from 'react';

export function Component() {
  const [value, setValue] = useState('option1');

  return (
    <FormControl label="Choose an option" id="radio-example">
      <RadioGroup
        value={value}
        onChange={(_, newValue) => {
          console.log('selected value:', newValue);
          setValue(newValue);
        }}
        name="radio-group"
        aria-labelledby="radio-example"
      >
        <Radio value="option1">Option 1</Radio>
        <Radio value="option2">Option 2</Radio>
        <Radio value="option3">Option 3</Radio>
      </RadioGroup>
    </FormControl>
  );
}
```

## API Reference

### RadioGroup

| Prop           | Type                                                | Default     | Description                                                                               |
| -------------- | --------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------- |
| `children`     | `ReactNode`                                         | `undefined` | Radio or Button components to be grouped together                                         |
| `id`           | `string`                                            | `auto`      | Unique identifier for the radio group. Auto-generated if not provided                     |
| `name`         | `string`                                            | `id`        | Name attribute for the radio group, used in form submissions. Defaults to the id value    |
| `value`        | `Value`                                             | `undefined` | Currently selected value. Can be any type (string, number, etc.)                          |
| `defaultValue` | `Value`                                             | `undefined` | Default selected value for uncontrolled component usage                                   |
| `onChange`     | `(event: Event, value: Value) => void` | `undefined` | Callback called when selected value changes. Receives event and new value |
| `disabled`     | `boolean`                                           | `false`     | If true, disables all radio buttons in the group                                          |
| `readOnly`     | `boolean`                                           | `false`     | If true, makes the radio group read-only (no changes allowed)                             |
| `tabIndex`     | `number`                                            | `0`         | Tab index for keyboard navigation. First or selected radio gets this value, others get -1 |
| `className`    | `string`                                            | `undefined` | Additional CSS classes to apply to the radio group container                              |
| ...rest        | `Omit<HTMLAttributes<HTMLDivElement>, "onChange">`  | -           | All standard HTML div attributes except onChange                                          |

## Notes

- **FormControl Integration**: RadioGroup should typically be wrapped in a `FormControl` component for labels, descriptions, validation status, and error messages. Pass `label`, `description`, `validationStatus`, and `errorMessage` props to FormControl, not RadioGroup
- **Keyboard Navigation**: Use Arrow keys (Up/Down/Left/Right) to navigate between radio options. Press Space or Enter to select the focused option
- **Accessibility**: Implements proper ARIA attributes including `role="radiogroup"`, `role="radio"`, `aria-checked`, and roving tabindex for keyboard navigation. Use `aria-labelledby` to connect RadioGroup to FormControl's label
- **Generic Value Type**: Supports any value type (string, number, object, etc.) through TypeScript generics
- **Automatic State Management**: Uses the `useValue` hook internally to manage state, supporting both controlled and uncontrolled usage
- **Button Support**: Can accept Button components as children for a button-style radio group with automatic styling (selected buttons get black color, unselected get white)
- **Radio Support**: Can accept Radio components as children for traditional radio button appearance
- **Mixed Children**: Supports non-Radio/Button children (like text or spans) which are rendered as-is without modification
- **Roving Tabindex**: Implements the roving tabindex pattern - only one radio button is tabbable at a time, improving keyboard navigation
- **Client Component**: This component uses hooks and event handlers, so it must be used in a client component (use "use client" directive)
- **Gap Spacing**: Radio options are spaced with a 12px gap by default (can be overridden with className)
- **Data Attributes**: Uses `data-value` attribute on Button children for keyboard navigation and selection logic
