Form controls

Radio
v0.4.2
Documentation Under Review

A deprecated radio input component with size variants and danger states. Use RadioGroup component for new implementations with better accessibility and functionality.

Deprecated Component

This component is deprecated and should not be used in new projects. Please use the RadioGroup component instead, which provides better accessibility, built-in label management, and improved functionality.

Installation

Install the Radio component from the Catalyst component library:

npm install @pmi/catalyst-radio

Overview

The Radio component is a basic radio input with CVA-based styling for size variants and danger states. It provides a styled alternative to native HTML radio inputs with consistent design tokens and accessibility support.

Migration Required: This component is deprecated. For new implementations, use RadioGroup which provides: - Better accessibility with built-in ARIA attributes - Automatic label association and state management - Radio group context for easier form handling

  • Improved keyboard navigation

Key Features

  • Four Size Variants: xs, sm, md, lg for different contexts
  • Danger State: Visual warning for validation errors
  • CVA Styling: Consistent variant management with design tokens
  • Polymorphic: asChild prop for custom rendering
  • Accessibility: Basic keyboard and screen reader support
  • Disabled State: Visual indication with opacity reduction

Examples

Basic Radio

A simple radio input with default styling.

Preview

Danger State

Use danger prop for validation errors or warning states.

Preview

Disabled State

Disabled radios prevent user interaction.

Preview

With Label

Combine with Label component for accessible form inputs.

Preview

With Label and Description

Add SubText for additional context.

Preview
Full access to all features and priority support
Access to core features with email support
Essential features for individual use

As Child (Polymorphic)

Use asChild to render as a custom element.

Preview

API Reference

Radio

The main radio input component with size and danger variants.

Prop

Type

Size Specifications

The Radio component provides four size variants:

Prop

Type

Gap Recommendations with Labels

Match gap spacing to radio size for visual alignment:

// Large - 16px gap
<div className="flex gap-[var(--scale-16)] items-center">
  <Radio size="lg" />
  <Label size="lg">Label</Label>
</div>

// Medium - 12px gap
<div className="flex gap-[var(--scale-12)] items-center">
  <Radio size="md" />
  <Label size="md">Label</Label>
</div>

// Small - 8px gap
<div className="flex gap-[var(--scale-8)] items-center">
  <Radio size="sm" />
  <Label size="sm">Label</Label>
</div>

// Extra Small - 6px gap
<div className="flex gap-[var(--scale-6)] items-center">
  <Radio size="xs" />
  <Label className="text-xs">Label</Label>
</div>

Styling with CVA

The Radio component uses Class Variance Authority for variant management:

const radioVariants = cva(baseClassNames, {
  variants: {
    danger: {
      true: [
        'border-[var(--border-danger)]',
        'checked:bg-[var(--border-danger)]',
        'focus-visible:outline-[var(--border-danger)]',
      ],
      false: [
        'border-[var(--components-radio-root-border-unchecked)]',
        'checked:bg-[var(--fill-off-black-darkest)]',
        'focus-visible:outline-[var(--border-off-black-dark)]',
      ],
    },
    size: {
      xs: 'h-[var(--scale-20)] w-[var(--scale-20)] before:w-[var(--scale-8)] before:h-[var(--scale-8)]',
      sm: 'h-[var(--scale-24)] w-[var(--scale-24)] before:w-[var(--scale-10)] before:h-[var(--scale-10)]',
      md: 'h-[var(--scale-28)] w-[var(--scale-28)] before:w-[var(--scale-12)] before:h-[var(--scale-12)]',
      lg: 'h-[var(--scale-32)] w-[var(--scale-32)] before:w-[var(--scale-14)] before:h-[var(--scale-14)]',
    },
  },
  defaultVariants: {
    size: 'md',
    danger: false,
  },
});

Pseudo-Element Structure

The radio uses ::before and ::after pseudo-elements for styling:

  • ::before: Inner circle background (white/dark surface)
  • ::after: Checked indicator dot (appears when checked)
  • Border: Native border for unchecked state ring

Design Tokens

The Radio component uses Catalyst design tokens:

  • Colors:

    • Border (unchecked): --components-radio-root-border-unchecked
    • Background (checked): --fill-off-black-darkest
    • Indicator (checked): --components-radio-indicator-default
    • Indicator (disabled): --components-radio-indicator-disabled
    • Danger border: --border-danger
    • Danger background: --border-danger (same as border)
    • Disabled background: --fill-neutral-softer
  • Sizing: CSS variables via --scale-* tokens

    • Outer: --scale-20 (xs) through --scale-32 (lg)
    • Inner indicator: --scale-8 (xs) through --scale-14 (lg)
  • Spacing: Gap tokens for label alignment

    • --scale-6 (xs), --scale-8 (sm), --scale-12 (md), --scale-16 (lg)
  • Effects:

    • Border radius: rounded-full
    • Disabled opacity: --opacity-disabled
    • Focus outline: Size-responsive

Accessibility

Limited Accessibility: This component has basic accessibility. Use RadioGroup for enhanced accessibility features including automatic ARIA attributes and keyboard navigation.

Keyboard Navigation

  • Tab: Move focus between radio inputs
  • Arrow Keys: Navigate between radios in same group (native browser behavior)
  • Space: Select focused radio

ARIA Attributes

// Associate with label using id/htmlFor
<Radio id="option-1" name="group-1" />
<Label htmlFor="option-1">Option Label</Label>

// Use name attribute to group radios
<Radio name="group-1" value="option-1" />
<Radio name="group-1" value="option-2" />
<Radio name="group-1" value="option-3" />

WCAG 2.1 AA Compliance

  • Contrast Ratios: Border and indicator meet 3:1 minimum for UI components
  • Touch Targets: All sizes meet minimums (xs: 20×20px, others: 24×24px+)
  • Focus Indicators: Visible outline on focus
  • Disabled State: Opacity reduces visual prominence

Best Practices

// ✓ Always associate with label
<div className="flex gap-[var(--scale-12)] items-center">
  <Radio id="radio-1" name="options" />
  <Label htmlFor="radio-1">Clear Label</Label>
</div>

// ✓ Group related radios with same name
<Radio name="plan" value="basic" />
<Radio name="plan" value="pro" />
<Radio name="plan" value="enterprise" />

// ✓ Provide meaningful values
<Radio name="size" value="small" />
<Radio name="size" value="medium" />
<Radio name="size" value="large" />

Common Patterns

Form Radio Group

import { useState } from 'react';

function FormRadioGroup() {
  const [selected, setSelected] = useState('option-1');

  return (
    <form>
      <fieldset>
        <legend className="font-semibold mb-4">Choose an option</legend>

        <div className="flex flex-col gap-3">
          <div className="flex gap-[var(--scale-12)] items-center">
            <Radio
              id="opt-1"
              name="options"
              value="option-1"
              checked={selected === 'option-1'}
              onChange={(e) => setSelected(e.target.value)}
            />
            <Label htmlFor="opt-1" size="md">
              Option 1
            </Label>
          </div>

          <div className="flex gap-[var(--scale-12)] items-center">
            <Radio
              id="opt-2"
              name="options"
              value="option-2"
              checked={selected === 'option-2'}
              onChange={(e) => setSelected(e.target.value)}
            />
            <Label htmlFor="opt-2" size="md">
              Option 2
            </Label>
          </div>
        </div>
      </fieldset>
    </form>
  );
}

Validation Error State

<div className="flex flex-col gap-2">
  <div className="flex gap-[var(--scale-12)] items-center">
    <Radio id="invalid-1" name="invalid-group" danger />
    <Label htmlFor="invalid-1" size="md">
      Invalid Option
    </Label>
  </div>
  <span className="text-sm text-red-600 ml-[calc(var(--scale-28)+var(--scale-12))]">
    Please select a valid option
  </span>
</div>

Pricing Plans

<div className="flex flex-col gap-4">
  {[
    { id: 'basic', title: 'Basic', price: '$9/mo', desc: 'Essential features' },
    { id: 'pro', title: 'Pro', price: '$29/mo', desc: 'Advanced features' },
    { id: 'enterprise', title: 'Enterprise', price: 'Custom', desc: 'Full suite' },
  ].map((plan) => (
    <div key={plan.id} className="flex gap-[var(--scale-12)] items-start">
      <Radio id={plan.id} name="pricing" size="md" className="mt-1" />
      <div className="flex flex-col">
        <Label htmlFor={plan.id} size="md" className="font-semibold">
          {plan.title} - {plan.price}
        </Label>
        <SubText size="md">{plan.desc}</SubText>
      </div>
    </div>
  ))}
</div>

Migration Guide

Migrating to RadioGroup

Replace this deprecated component with RadioGroup for better accessibility and functionality:

Before (Radio - Deprecated)

import { Radio } from '@pmi/catalyst-radio';
import { Label } from '@pmi/catalyst-label';

<div>
  <div className="flex gap-[var(--scale-12)] items-center">
    <Radio id="option-1" name="group" value="1" />
    <Label htmlFor="option-1">Option 1</Label>
  </div>
  <div className="flex gap-[var(--scale-12)] items-center">
    <Radio id="option-2" name="group" value="2" />
    <Label htmlFor="option-2">Option 2</Label>
  </div>
</div>;
import { RadioGroup } from '@pmi/catalyst-radio-group';

<RadioGroup defaultValue="1">
  <RadioGroup.Item value="1">
    <RadioGroup.Indicator />
    <RadioGroup.Label>Option 1</RadioGroup.Label>
  </RadioGroup.Item>
  <RadioGroup.Item value="2">
    <RadioGroup.Indicator />
    <RadioGroup.Label>Option 2</RadioGroup.Label>
  </RadioGroup.Item>
</RadioGroup>;

Troubleshooting

Radio Not Changing State

Ensure proper name attribute and state management:

// ✓ Correct - same name for radio group
<Radio name="options" value="1" />
<Radio name="options" value="2" />

// ✗ Incorrect - different names prevent grouping
<Radio name="option1" value="1" />
<Radio name="option2" value="2" />

Label Not Associated

Use id and htmlFor for proper association:

// ✓ Correct - proper association
<Radio id="my-radio" name="group" />
<Label htmlFor="my-radio">Label</Label>

// ✗ Incorrect - no association
<Radio name="group" />
<Label>Label</Label>

Indicator Not Showing

Verify checked state and styling:

// ✓ Correct - controlled checked state
<Radio checked={isChecked} onChange={handleChange} />

// ✓ Correct - uncontrolled with defaultChecked
<Radio defaultChecked />

Deprecation Notice

Component Deprecated

This component is deprecated and will be removed in a future major version.

Migration Path:

  • Use RadioGroup for new implementations
  • RadioGroup provides better accessibility with automatic ARIA attributes
  • Built-in label management and state handling
  • Improved keyboard navigation
  • Easier form integration

Timeline:

  • Current: Deprecated but functional
  • Support: Maintained for bug fixes only
  • Removal: Planned for next major version

Please migrate to RadioGroup at your earliest convenience.

  • RadioGroup - Recommended replacement with better accessibility
  • Checkbox - For multiple selections
  • Switch - For binary on/off states
  • Label - For accessible form labels
  • SubText - For additional context text

External Resources

On this page