v1.0

RadioGroup

Preview

Basic

Loading…

Preview

Code

ts
import RadioGroup from '@/components/form/RadioGroup';

src/components/form/RadioGroup.tsx

AI prompt

text
Build a radio group component, over real radio inputs, with a roving tab stop and arrow-key selection, in React + TypeScript + Tailwind CSS.

## Look
- Group: `flex flex-col gap-2`, or horizontal `flex-row flex-wrap gap-x-4 gap-y-2`.
- Each option: a `<label>` with `flex items-start gap-2 text-xs` holding a 16px drawn circle (`mt-0.5`) and the label in `font-medium text-slate-700 dark:text-slate-200`.
- Circle: `w-4 h-4 rounded-full border-2`; unchecked `bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600`; checked `bg-indigo-600 border-indigo-600` with a 6px white dot centred on it; keyboard focus `ring-2 ring-indigo-400` (via `peer-focus-visible` from the visually-hidden input).
- Optional hint: a small ⓘ info icon (12px, slate-400) right AFTER the label (a sibling of the label, not inside it, so clicking it doesn't select), showing the hint in a tooltip.
- Disabled option or group: 50% opacity, `cursor-not-allowed`.

## Behaviour
- A real `<input type="radio" class="sr-only peer">` sits under each circle, so label clicks, form posting and focus are native. All share a `name` (generated when not given).
- Controlled: `value` (`null` = nothing chosen yet; the user can't un-select).
- ONE tab stop for the whole group: the checked option, or the first enabled one when nothing is checked; all others `tabIndex=-1`.
- Arrow keys move AND select: ↓/→ next, ↑/← previous, in either orientation, wrapping at the ends and skipping disabled options. Own this logic rather than relying on native radio behaviour, which varies by browser.
- Also export the single `RadioButton` for standalone use (props: `id`, `name?`, `value`, `checked`, `onChange`, `label`, `hint?`, `disabled?`).

## API
`type RadioOption = { value: string | number; label: ReactNode; hint?: string; disabled?: boolean }`. Props: `options`, `value: string | number | null`, `onChange(value)`, `name?`, `orientation: 'horizontal' | 'vertical' = 'vertical'`, `label?` (the group's accessible name), `disabled = false`, `className`.

## Accessibility
- Wrapper `role="radiogroup"` with `aria-label`, `aria-orientation`, `aria-disabled`.

## Demo
"Billing period": Monthly (hint "Billed on the 1st of each month."), Yearly, Lifetime (disabled), Monthly selected. A horizontal "Size" group Small / Medium / Large with nothing selected. A fully disabled group, and a standalone "I have read the notes" radio.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useId, useRef } from 'react';
import { cn } from '@/lib/cn';
import { InfoTooltip } from '@/components/overlay/Tooltip';

export type RadioValue = string | number;

export type RadioOption = {
  value: RadioValue;
  label: React.ReactNode;
  /** Behind an ⓘ beside the label, as on `Checkbox`. */
  hint?: string;
  disabled?: boolean;
};

/**
 * One styled radio. Like `Checkbox`, a real `<input type="radio">` sits under
 * the drawn circle, so the label click, form posting and focus all come from
 * the browser. Usable on its own; `RadioGroup` is what adds the keyboard model.
 */
export function RadioButton({
  id,
  name,
  value,
  checked,
  onChange,
  label,
  hint,
  disabled = false,
  tabIndex,
  onKeyDown,
  inputRef,
  className,
}: {
  id: string;
  name?: string;
  value: RadioValue;
  checked: boolean;
  onChange: (value: RadioValue) => void;
  label: React.ReactNode;
  hint?: string;
  disabled?: boolean;
  /** Set by `RadioGroup` for its roving tab stop. */
  tabIndex?: number;
  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
  inputRef?: (el: HTMLInputElement | null) => void;
  className?: string;
}) {
  return (
    <span className={cn('flex items-start gap-1.5 text-xs', disabled && 'opacity-50', className)}>
      <label
        htmlFor={id}
        className={cn('flex items-start gap-2 min-w-0', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}
      >
        <span className="relative flex items-center justify-center w-4 h-4 shrink-0 mt-0.5">
          <input
            ref={inputRef}
            id={id}
            type="radio"
            name={name}
            value={String(value)}
            checked={checked}
            disabled={disabled}
            tabIndex={tabIndex}
            onKeyDown={onKeyDown}
            onChange={() => onChange(value)}
            className="sr-only peer"
          />
          <span
            className={cn(
              'w-4 h-4 rounded-full border-2 transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400',
              checked
                ? 'bg-indigo-600 border-indigo-600'
                : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600',
            )}
          />
          {checked && <span className="absolute w-1.5 h-1.5 rounded-full bg-white" aria-hidden />}
        </span>
        <span className="min-w-0 font-medium text-slate-700 dark:text-slate-200">{label}</span>
      </label>

      {/* A sibling of the label, not a child — see Checkbox for why. */}
      {hint && <InfoTooltip content={hint} className="mt-0.5" iconClassName="w-3 h-3" />}
    </span>
  );
}

/**
 * A set of mutually exclusive options.
 *
 * The keyboard model is the ARIA radio-group one, implemented here rather than
 * left to the browser: the group is ONE tab stop (the checked option, or the
 * first enabled one when nothing is checked), and the arrow keys move AND select,
 * wrapping at the ends and skipping disabled options. Native radios do roughly
 * this, but which radio receives Tab with nothing checked, and whether a
 * disabled one is skipped, varies by browser — owning it makes it the same
 * everywhere.
 */
export default function RadioGroup({
  options,
  value,
  onChange,
  name,
  orientation = 'vertical',
  label,
  disabled = false,
  className,
}: {
  options: RadioOption[];
  /** `null` for no selection yet — a radio group cannot be un-selected by the user. */
  value: RadioValue | null;
  onChange: (value: RadioValue) => void;
  /** Form field name. Generated when omitted, since the browser groups radios by it. */
  name?: string;
  orientation?: 'horizontal' | 'vertical';
  /** Accessible name for the group, e.g. the question the options answer. */
  label?: string;
  disabled?: boolean;
  className?: string;
}) {
  const uid = useId();
  const groupName = name ?? `radio-${uid}`;
  const inputs = useRef<Array<HTMLInputElement | null>>([]);

  const enabled = (i: number) => !disabled && !options[i].disabled;
  const checkedIndex = options.findIndex((o) => o.value === value);
  const tabStop =
    checkedIndex >= 0 && enabled(checkedIndex) ? checkedIndex : options.findIndex((_, i) => enabled(i));

  const move = (from: number, dir: 1 | -1) => {
    for (let step = 1; step <= options.length; step++) {
      const i = (from + dir * step + options.length) % options.length;
      if (enabled(i)) {
        inputs.current[i]?.focus();
        onChange(options[i].value);
        return;
      }
    }
  };

  const onKeyDown = (i: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
    // Both axes work in either orientation, as the pattern specifies — a user
    // should not have to know how the group happens to be laid out.
    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
      e.preventDefault();
      move(i, 1);
    } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
      e.preventDefault();
      move(i, -1);
    }
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      aria-orientation={orientation}
      aria-disabled={disabled || undefined}
      className={cn(
        'flex',
        orientation === 'horizontal' ? 'flex-row flex-wrap gap-x-4 gap-y-2' : 'flex-col gap-2',
        className,
      )}
    >
      {options.map((option, i) => (
        <RadioButton
          key={option.value}
          id={`${uid}-${i}`}
          name={groupName}
          value={option.value}
          checked={i === checkedIndex}
          onChange={onChange}
          label={option.label}
          hint={option.hint}
          disabled={disabled || option.disabled}
          tabIndex={i === tabStop ? 0 : -1}
          onKeyDown={onKeyDown(i)}
          inputRef={(el) => {
            inputs.current[i] = el;
          }}
        />
      ))}
    </div>
  );
}

Props

PropTypeDefaultDescription
options*RadioOption[]—
value*RadioValue | null—`null` for no selection yet — a radio group cannot be un-selected by the user.
onChange*(value: RadioValue) => void—
namestring—Form field name. Generated when omitted, since the browser groups radios by it.
orientation'horizontal' | 'vertical''vertical'
labelstring—Accessible name for the group, e.g. the question the options answer.
disabledbooleanfalse
classNamestring—