Radio Field

Radio button group component integrated with React Hook Form via typed lens binding from @hookform/lenses. Renders options automatically with consistent layout and accessibility.

npx shadcn@latest add @shuip/rhf-radio-field
pnpm dlx shadcn@latest add @shuip/rhf-radio-field
bun x shadcn@latest add @shuip/rhf-radio-field
'use client';

import type { Lens } from '@hookform/lenses';
import type * as React from 'react';
import { useController } from 'react-hook-form';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';

export interface RadioFieldProps
  extends Omit<React.ComponentProps<typeof RadioGroup>, 'value' | 'defaultValue' | 'onValueChange'> {
  lens: Lens<string>;
  options: string[];
  label?: string;
  description?: string;
}

export function RadioField({ lens, options, label, description, ...props }: RadioFieldProps) {
  const { field, fieldState } = useController(lens.interop());

  return (
    <Field className='gap-2' data-invalid={fieldState.invalid}>
      {label && <FieldLabel>{label}</FieldLabel>}
      <RadioGroup
        name={field.name}
        value={field.value ?? ''}
        onValueChange={field.onChange}
        onBlur={field.onBlur}
        className='flex flex-col space-y-1'
        aria-invalid={fieldState.invalid}
        {...props}
      >
        {options.map((value) => (
          <div key={value} className='flex items-center space-x-3 space-y-0'>
            <RadioGroupItem id={`${field.name}-${value}`} value={value} aria-invalid={fieldState.invalid} />
            <Label htmlFor={`${field.name}-${value}`} className='font-normal'>
              {value}
            </Label>
          </div>
        ))}
      </RadioGroup>
      {fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
      {description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
    </Field>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

RadioField is a radio button group component that encapsulates React Hook Form's field management with shadcn/ui's design system. It handles all the boilerplate of connecting form state to a radio group: rendering each option, wiring event handlers, displaying errors, and providing accessible labels.

The field binds to the form via a typed lens from @hookform/lenses — no call-site generic, just lens.focus('fieldName') with full autocomplete from your form's value type.

Built-in features

  • Typed lens binding: lens.focus('plan') autocompletes from your form's value type — no <MyForm> generic at the call site
  • Automatic option rendering: pass an array of strings and get fully functional radio buttons
  • Consistent layout: pre-configured spacing and alignment for radio groups
  • Accessibility support: proper ARIA attributes, label associations, and keyboard navigation
  • Zod validation: native integration with react-hook-form and Zod via resolver

Setup

Field components bind via @hookform/lenses. Create a lens once per form:

import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { RadioField } from '@/components/ui/shuip/react-hook-form/radio-field';

const form = useForm<MyForm>({ defaultValues: { plan: 'free' } });
const lens = useLens({ control: form.control });

<form onSubmit={form.handleSubmit(onSubmit)}>
  <RadioField lens={lens.focus('plan')} options={['free', 'pro', 'enterprise']} label='Plan' />
</form>

Less boilerplate

React Hook Form's standard approach uses render props to access field state:

<FormField
  control={form.control}
  name='plan'
  render={({ field }) => (
    <FormItem className='space-y-3'>
      <FormLabel>Select a plan</FormLabel>
      <FormControl>
        <RadioGroup
          value={field.value ?? ''}
          onValueChange={field.onChange}
          className='flex flex-col space-y-1'
        >
          <FormItem className='flex items-center space-x-3 space-y-0'>
            <FormControl>
              <RadioGroupItem value='free' />
            </FormControl>
            <FormLabel className='font-normal'>free</FormLabel>
          </FormItem>
          <FormItem className='flex items-center space-x-3 space-y-0'>
            <FormControl>
              <RadioGroupItem value='pro' />
            </FormControl>
            <FormLabel className='font-normal'>pro</FormLabel>
          </FormItem>
          <FormItem className='flex items-center space-x-3 space-y-0'>
            <FormControl>
              <RadioGroupItem value='enterprise' />
            </FormControl>
            <FormLabel className='font-normal'>enterprise</FormLabel>
          </FormItem>
        </RadioGroup>
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

With RadioField, this reduces to a single declarative component:

<RadioField
  lens={lens.focus('plan')}
  options={['free', 'pro', 'enterprise']}
  label='Select a plan'
/>

Examples

Conditional Pricing

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Card } from '@/components/ui/card';
import { RadioField } from '@/components/ui/shuip/react-hook-form/radio-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';

const PLANS = ['free', 'pro', 'enterprise'];
const PLAN_PRICES = { free: 0, pro: 29, enterprise: 99 };

const zodSchema = z.object({
  plan: z.enum(PLANS, {
    error: 'Please select a plan',
  }),
  billingCycle: z.enum(['monthly', 'annual']),
});

type FormValues = { plan: string; billingCycle: string };
type SubmitValues = z.infer<typeof zodSchema>;

export default function RhfRadioFieldConditionalPricingExample() {
  const form = useForm<FormValues, unknown, SubmitValues>({
    defaultValues: {
      plan: '',
      billingCycle: 'monthly',
    },
    resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
  });
  const lens = useLens({ control: form.control });

  const plan = form.watch('plan') as keyof typeof PLAN_PRICES;
  const billingCycle = form.watch('billingCycle');

  const monthlyPrice = PLAN_PRICES[plan] || 0;
  const annualPrice = monthlyPrice * 12 * 0.8; // 20% discount
  const displayPrice = billingCycle === 'annual' ? annualPrice / 12 : monthlyPrice;

  async function onSubmit(values: SubmitValues) {
    try {
      alert(JSON.stringify(values, null, 2));
    } catch (error) {
      console.error(error);
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <RadioField
        lens={lens.focus('plan')}
        options={PLANS}
        label='Select Plan'
        description='Choose the plan that fits your needs'
      />

      {plan && plan !== 'free' && (
        <RadioField
          lens={lens.focus('billingCycle')}
          options={['monthly', 'annual']}
          label='Billing Cycle'
          description='Annual billing includes a 20% discount'
        />
      )}

      {plan && (
        <Card className='p-4'>
          <div className='space-y-2'>
            <div className='flex justify-between'>
              <span className='font-semibold'>Plan:</span>
              <span className='capitalize'>{plan}</span>
            </div>
            {plan !== 'free' && (
              <>
                <div className='flex justify-between'>
                  <span className='font-semibold'>Billing:</span>
                  <span className='capitalize'>{billingCycle}</span>
                </div>
                <div className='flex justify-between text-lg font-bold'>
                  <span>Total:</span>
                  <span>${displayPrice.toFixed(2)}/month</span>
                </div>
                {billingCycle === 'annual' && (
                  <p className='text-sm text-muted-foreground'>Billed annually at ${annualPrice.toFixed(2)}</p>
                )}
              </>
            )}
          </div>
        </Card>
      )}

      <SubmitButton>Continue</SubmitButton>
    </form>
  );
}

Default

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { RadioField } from '@/components/ui/shuip/react-hook-form/radio-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';

const zodSchema = z.object({
  selection: z.enum(['Yes', 'No', 'Maybe', 'Not sure']),
});

type FormValues = { selection: string };
type SubmitValues = z.infer<typeof zodSchema>;

export default function RadioFieldExample() {
  const form = useForm<FormValues, unknown, SubmitValues>({
    defaultValues: {
      selection: 'Yes',
    },
    resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
  });
  const lens = useLens({ control: form.control });

  async function onSubmit(values: SubmitValues) {
    try {
      alert(`Selection: ${values.selection}`);
    } catch (error) {
      console.error(error);
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <RadioField
        lens={lens.focus('selection')}
        options={['Yes', 'No', 'Maybe', 'Not sure']}
        label='Are you sure?'
        description='This is a description'
      />
      <SubmitButton>Check</SubmitButton>
    </form>
  );
}

Payment Method

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';
import { RadioField } from '@/components/ui/shuip/react-hook-form/radio-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';

const zodSchema = z.object({
  paymentMethod: z.enum(['card', 'paypal', 'bank'], {
    error: 'Please select a payment method',
  }),
  cardNumber: z.string(),
  paypalEmail: z.string(),
  accountNumber: z.string(),
});

type FormValues = { paymentMethod: string; cardNumber: string; paypalEmail: string; accountNumber: string };
type SubmitValues = z.infer<typeof zodSchema>;

export default function RhfRadioFieldPaymentMethodExample() {
  const form = useForm<FormValues, unknown, SubmitValues>({
    defaultValues: {
      paymentMethod: '',
      cardNumber: '',
      paypalEmail: '',
      accountNumber: '',
    },
    resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
  });
  const lens = useLens({ control: form.control });

  const paymentMethod = form.watch('paymentMethod');

  async function onSubmit(values: SubmitValues) {
    try {
      alert(JSON.stringify(values, null, 2));
    } catch (error) {
      console.error(error);
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <RadioField
        lens={lens.focus('paymentMethod')}
        options={['card', 'paypal', 'bank']}
        label='Payment Method'
        description='Select how you want to pay'
      />

      {paymentMethod === 'card' && (
        <InputField
          lens={lens.focus('cardNumber')}
          label='Card Number'
          placeholder='1234 5678 9012 3456'
          description='Enter your credit card number'
        />
      )}

      {paymentMethod === 'paypal' && (
        <InputField
          lens={lens.focus('paypalEmail')}
          type='email'
          label='PayPal Email'
          placeholder='your@email.com'
          description='Email associated with your PayPal account'
        />
      )}

      {paymentMethod === 'bank' && (
        <InputField
          lens={lens.focus('accountNumber')}
          label='Account Number'
          placeholder='Enter your account number'
          description='Your bank account number for direct transfer'
        />
      )}

      <SubmitButton>Process Payment</SubmitButton>
    </form>
  );
}

Props

Prop

Type

On this page