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 RadioFieldPropsextends 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>}<RadioGroupname={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>);}
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
'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% discountconst 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'><RadioFieldlens={lens.focus('plan')}options={PLANS}label='Select Plan'description='Choose the plan that fits your needs'/>{plan && plan !== 'free' && (<RadioFieldlens={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
'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'><RadioFieldlens={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
'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'><RadioFieldlens={lens.focus('paymentMethod')}options={['card', 'paypal', 'bank']}label='Payment Method'description='Select how you want to pay'/>{paymentMethod === 'card' && (<InputFieldlens={lens.focus('cardNumber')}label='Card Number'placeholder='1234 5678 9012 3456'description='Enter your credit card number'/>)}{paymentMethod === 'paypal' && (<InputFieldlens={lens.focus('paypalEmail')}type='email'label='PayPal Email'placeholder='your@email.com'description='Email associated with your PayPal account'/>)}{paymentMethod === 'bank' && (<InputFieldlens={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