---
title: RadioSelect
description: A dropdown trigger styled like a text field that opens a panel of radio buttons, enabling single-value selection.
---

```tsx
'use client';

import { useState } from 'react';
import { RadioSelect } from '@/ui/forms/RadioSelect';

const statusOptions = [
  { value: 'open', label: 'Open' },
  { value: 'fixed', label: 'Fixed' },
  { value: 'in-progress', label: 'In Progress' },
  { value: 'closed', label: 'Closed' },
];

export function RadioSelectDemo() {
  const [status, setStatus] = useState<string>('open');
  const [category, setCategory] = useState<string>('');

  return (
    <div className="space-y-24" style={{ minWidth: 300 }}>
      <RadioSelect
        label="Status"
        placeholder="Select a status..."
        options={statusOptions}
        value={status}
        onChange={(_, v) => setStatus(v)}
      />

      <RadioSelect
        label="Category"
        placeholder="Select a category..."
        groups={[
          {
            label: 'Travel',
            options: [
              { value: 'flight', label: 'Flight' },
              { value: 'hotel', label: 'Hotel' },
            ],
          },
          {
            label: 'Activities',
            options: [
              { value: 'ski', label: 'Ski' },
              { value: 'golf', label: 'Golf' },
              { value: 'spa', label: 'Spa' },
            ],
          },
        ]}
        value={category}
        onChange={(_, v) => setCategory(v)}
      />

      <RadioSelect
        label="Priority"
        options={[
          { value: 'high', label: 'High' },
          { value: 'medium', label: 'Medium' },
          { value: 'low', label: 'Low' },
        ]}
        validationStatus="error"
        errorMessage="Please select a priority"
      />

      <RadioSelect label="Tags" options={statusOptions} value="open" disabled />
    </div>
  );
}
```

## Installation

Using the CLI:

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

Or manually, copy the following code into `/ui/forms/RadioSelect.tsx`:

```tsx
'use client';

import { useId, useState, useEffect, type ReactNode } from 'react';
import clsx from 'clsx';
import { Icon } from '@clubmed/trident-icons';
import { Radio } from './radios/Radio';
import type { ValidationStatus } from '../hooks/useInternalStatus';
import { FormControl } from './FormControl';
import { useDropdown } from '../Dropdown';

function OptionLabel({ children, selected }: { children: ReactNode; selected: boolean }) {
  return (
    <span
      ref={(el) => {
        if (el) el.title = el.textContent ?? '';
      }}
      className={clsx('text-b3 text-black truncate', { 'font-semibold': selected })}
    >
      {children}
    </span>
  );
}

export interface RadioSelectOption<Value = string> {
  value: Value;
  label: ReactNode;
}

export interface RadioSelectGroup<Value = string> {
  label?: ReactNode;
  options: RadioSelectOption<Value>[];
}

export interface RadioSelectProps<Value = string> {
  id?: string;
  name?: string;
  label?: ReactNode;
  description?: string;
  placeholder?: string;
  value?: Value;
  options?: RadioSelectOption<Value>[];
  groups?: RadioSelectGroup<Value>[];
  onChange?: (event: Event, value: Value) => void;
  disabled?: boolean;
  required?: boolean;
  hideRequiredStar?: boolean;
  validationStatus?: ValidationStatus;
  errorMessage?: string;
  className?: string;
  'data-testid'?: string;
  /** Renders the selected value summary in the trigger */
  renderValue?: (selected: Value) => ReactNode;
}

export function RadioSelect<Value = string>({
  id: idProp,
  name: nameProp,
  label,
  description,
  placeholder = 'Select...',
  value: controlledValue,
  options,
  groups,
  onChange,
  disabled = false,
  required = false,
  hideRequiredStar,
  validationStatus = 'default',
  errorMessage,
  className,
  'data-testid': dataTestId,
  renderValue,
}: RadioSelectProps<Value>) {
  const internalId = useId();
  const id = idProp ?? internalId;
  const name = nameProp ?? id;

  const { open, toggle, close, containerRef, isLeaving } = useDropdown();
  const [selected, setSelected] = useState<Value | undefined>(controlledValue);

  useEffect(() => {
    if (controlledValue !== undefined) {
      setSelected(controlledValue);
    }
  }, [controlledValue]);

  const selectOption = (event: Event, val: Value) => {
    setSelected(val);
    onChange?.(event, val);
    close();
  };

  const allOptions: RadioSelectOption<Value>[] = options
    ? options
    : (groups ?? []).flatMap((g) => g.options);

  const selectedOption = allOptions.find((o) => o.value === selected);

  const triggerLabel =
    selected === undefined || !selectedOption ? (
      <span className="text-darkGrey">{placeholder}</span>
    ) : renderValue ? (
      renderValue(selected)
    ) : (
      <span>{selectedOption.label}</span>
    );

  const isError = validationStatus === 'error';
  const isSuccess = validationStatus === 'success';
  const isDisabled = disabled;

  const renderOptions = (opts: RadioSelectOption<Value>[]) => (
    <div className="flex flex-col gap-12">
      {opts.map((option, i) => (
        <Radio
          key={i}
          name={name}
          value={String(option.value)}
          checked={selected === option.value}
          disabled={disabled}
          onChange={(event) => selectOption(event, option.value)}
        >
          <OptionLabel selected={selected === option.value}>{option.label}</OptionLabel>
        </Radio>
      ))}
    </div>
  );

  return (
    <FormControl
      id={id}
      label={label}
      description={description}
      className={className}
      dataName="RadioSelect"
      data-testid={dataTestId}
      disabled={disabled}
      required={required}
      hideRequiredStar={hideRequiredStar}
      validationStatus={validationStatus}
      errorMessage={errorMessage}
    >
      <div ref={containerRef} className="relative">
        <button
          type="button"
          id={id}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-disabled={isDisabled}
          disabled={isDisabled}
          onClick={toggle}
          className={clsx(
            'text-b3 rounded-pill w-full inset-ring overflow-hidden px-20 py-12 font-normal outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 text-left flex items-center justify-between gap-8',
            {
              'bg-white text-black inset-ring-middleGrey focus:inset-ring-black active:inset-ring-black':
                !isDisabled && !isError && !isSuccess,
              'bg-pearl inset-ring-middleGrey text-grey': isDisabled,
              'inset-ring-red bg-white': isError,
              'inset-ring-green bg-white': isSuccess,
            },
          )}
        >
          <span className="truncate flex-1">{triggerLabel}</span>
          <span className="flex items-center gap-8 shrink-0">
            {isError && <Icon name="CrossDefault" width="24px" className="text-red" />}
            {isSuccess && <Icon name="CheckDefault" width="24px" className="text-green" />}
            <Icon
              name="ArrowDefaultDown"
              width="24px"
              className={clsx('text-black transition-transform', { 'rotate-180': open })}
            />
          </span>
        </button>

        <div
          role="listbox"
          aria-hidden={!open && !isLeaving}
          className={clsx(
            'absolute z-10 top-[calc(100%+8px)] left-0 right-0 bg-white rounded-16 border border-lightGrey shadow-md p-12 origin-top will-change-[transform,opacity]',
            {
              'animate-zoom-in': open,
              'animate-zoom-out pointer-events-none': !open && isLeaving,
              'opacity-0 scale-90 pointer-events-none': !open && !isLeaving,
            },
          )}
        >
          {groups
            ? groups.map((group, gi) => (
                <div key={gi} className={clsx({ 'mt-16 first:mt-0': gi > 0 })}>
                  {group.label && (
                    <p className="text-b4 text-darkGrey font-semibold mb-8 ps-4">{group.label}</p>
                  )}
                  {renderOptions(group.options)}
                </div>
              ))
            : renderOptions(allOptions)}
        </div>
      </div>
    </FormControl>
  );
}
```

## Usage

```tsx showLineNumbers
import { RadioSelect } from '@/ui/forms/RadioSelect';

export function Component() {
  return (
    <RadioSelect
      label="Status"
      options={[
        { value: 'open', label: 'Open' },
        { value: 'fixed', label: 'Fixed' },
      ]}
    />
  );
}
```

## Examples

### Flat options

The simplest usage — pass an `options` array and optionally a `value` to pre-select.

```tsx showLineNumbers
<RadioSelect
  label="Status"
  placeholder="Select a status..."
  options={[
    { value: 'open', label: 'Open' },
    { value: 'fixed', label: 'Fixed' },
    { value: 'in-progress', label: 'In Progress' },
    { value: 'closed', label: 'Closed' },
  ]}
  value="open"
/>
```

### Controlled

Manage the selected value in external state with `onChange`.

```tsx showLineNumbers
function Controlled() {
  const [value, setValue] = useState('open');

  return (
    <RadioSelect
      label="Status"
      options={[
        { value: 'open', label: 'Open' },
        { value: 'fixed', label: 'Fixed' },
        { value: 'in-progress', label: 'In Progress' },
        { value: 'closed', label: 'Closed' },
      ]}
      value={value}
      onChange={(_, v) => setValue(v)}
    />
  );
}
```

### Grouped options

Use `groups` instead of `options` to render options under labeled sections.

```tsx showLineNumbers
<RadioSelect
  label="Category"
  placeholder="Select a category..."
  groups={[
    {
      label: 'Travel',
      options: [
        { value: 'flight', label: 'Flight' },
        { value: 'hotel', label: 'Hotel' },
      ],
    },
    {
      label: 'Activities',
      options: [
        { value: 'ski', label: 'Ski' },
        { value: 'golf', label: 'Golf' },
        { value: 'spa', label: 'Spa' },
      ],
    },
  ]}
/>
```

### Custom trigger label

Use `renderValue` to display a custom string in the trigger instead of the option label.

```tsx showLineNumbers
<RadioSelect
  label="Priority"
  options={[
    { value: 'high', label: 'High priority' },
    { value: 'medium', label: 'Medium priority' },
    { value: 'low', label: 'Low priority' },
  ]}
  value="high"
  renderValue={(val) => `Priority: ${val}`}
/>
```

### Validation states

Pass `validationStatus` and `errorMessage` to indicate an error or success.

```tsx showLineNumbers
<RadioSelect
  label="Priority"
  options={priorityOptions}
  validationStatus="error"
  errorMessage="Please select a priority"
/>

<RadioSelect
  label="Priority"
  options={priorityOptions}
  value="high"
  validationStatus="success"
/>
```

### Disabled

```tsx showLineNumbers
<RadioSelect
  label="Status"
  options={statusOptions}
  value="open"
  disabled
/>
```

## Behaviour

- Opening the dropdown shows all options as radio buttons.
- Selecting an option updates the trigger label and **closes the dropdown immediately** — unlike `CheckboxSelect`, only one value can be active at a time.
- Clicking outside the open panel closes it without changing the selection.
- The trigger inherits the same pill-shaped border styling as `TextField` and `Select`.
- When no value is selected the placeholder text is shown in muted grey.

## Accessibility

- The trigger has `aria-haspopup="listbox"` and `aria-expanded` to communicate its state to screen readers.
- The panel uses `role="listbox"` and is hidden via `aria-hidden` when closed.
- Each option is a native `<input type="radio">` with an associated `<label>`, giving full keyboard and screen-reader support.
- The component is disabled at the native level (`disabled` on the `<button>` and each `<input>`), so it is excluded from the tab order automatically.

## API Reference

### RadioSelect

| Prop               | Type                                   | Default       | Description                                                    |
| ------------------ | -------------------------------------- | ------------- | -------------------------------------------------------------- |
| `label`            | `ReactNode`                            | `undefined`   | Label displayed above the trigger                              |
| `description`      | `string`                               | `undefined`   | Secondary description shown next to the label                  |
| `placeholder`      | `string`                               | `"Select..."` | Text shown in the trigger when no value is selected            |
| `options`          | `RadioSelectOption[]`                  | `undefined`   | Flat list of options. Use `groups` instead for grouped options |
| `groups`           | `RadioSelectGroup[]`                   | `undefined`   | Grouped options with optional group labels                     |
| `value`            | `Value`                                | `undefined`   | Controlled selected value                                      |
| `onChange`         | `(event: Event, value: Value) => void` | `undefined`   | Called with the event and new value when an option is selected |
| `renderValue`      | `(selected: Value) => ReactNode`       | `undefined`   | Custom renderer for the trigger label when a value is selected |
| `disabled`         | `boolean`                              | `false`       | Disables the trigger and all radio buttons                     |
| `required`         | `boolean`                              | `false`       | Marks the field as required                                    |
| `hideRequiredStar` | `boolean`                              | `undefined`   | Hides the required star even when `required` is true           |
| `validationStatus` | `"default" \| "error" \| "success"`   | `"default"`   | Visual validation state of the trigger                         |
| `errorMessage`     | `string`                               | `undefined`   | Error text shown below when `validationStatus` is `"error"`    |
| `className`        | `string`                               | `undefined`   | Additional CSS classes on the outer wrapper                    |

### RadioSelectOption

| Prop    | Type        | Description              |
| ------- | ----------- | ------------------------ |
| `value` | `Value`     | The option's value       |
| `label` | `ReactNode` | Label shown in the panel |

### RadioSelectGroup

| Prop      | Type                    | Description              |
| --------- | ----------------------- | ------------------------ |
| `label`   | `ReactNode`             | Optional group heading   |
| `options` | `RadioSelectOption[]`   | Options within this group |

## Notes

- Pass `groups` instead of `options` to render options under labeled sections.
- Use `renderValue` to customize the summary shown in the trigger (e.g. `"Priority: high"`).
- The dropdown closes automatically after a selection — this distinguishes `RadioSelect` from `CheckboxSelect`, where the panel stays open to allow multiple picks.
- `options` and `groups` are mutually exclusive; if both are provided, `options` takes precedence.
