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 https://shuip.plvo.dev/r/rhf-radio-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-radio-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-radio-field.json
'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>
);
}
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 { Form } from '@/components/ui/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 {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <RadioField lens={lens.focus('plan')} options={['free', 'pro', 'enterprise']} label='Plan' />
  </form>
</Form>

The <Form> wrapper is required — it provides shadcn's FormProvider which FormLabel and FormMessage use internally.

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 { Form } from '@/components/ui/form';
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']).default('monthly'),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfRadioFieldConditionalPricingExample() {
const form = useForm<Values>({
defaultValues: {
plan: '',
billingCycle: 'monthly',
},
resolver: zodResolver(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: Values) {
try {
alert(JSON.stringify(values, null, 2));
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<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>
</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 { Form } from '@/components/ui/form';
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 Values = z.infer<typeof zodSchema>;
export default function RadioFieldExample() {
const form = useForm<Values>({
defaultValues: {
selection: 'Yes',
},
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Selection: ${values.selection}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<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>
</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 { Form } from '@/components/ui/form';
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().optional(),
paypalEmail: z.string().optional(),
accountNumber: z.string().optional(),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfRadioFieldPaymentMethodExample() {
const form = useForm<Values>({
defaultValues: {
paymentMethod: undefined,
cardNumber: '',
paypalEmail: '',
accountNumber: '',
},
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
const paymentMethod = form.watch('paymentMethod');
async function onSubmit(values: Values) {
try {
alert(JSON.stringify(values, null, 2));
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<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>
</Form>
);
}

Props

Prop

Type

On this page