Radio Field
Radio button group component integrated with TanStack Form via React context. Options are defined as Array<{label, value}>.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-radio-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-radio-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-radio-field.json
'use client';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { Label } from '@/components/ui/label';import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';export interface RadioFieldOption {label: string;value: string;}export interface RadioFieldProps {options: RadioFieldOption[];label?: string;description?: string;fieldProps?: React.ComponentProps<typeof Field>;props?: React.ComponentProps<typeof RadioGroup>;}export function RadioField({ options, label, description, fieldProps, props }: RadioFieldProps) {const field = useFieldContext<string>();const { isValid, errors } = field.state.meta;return (<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>{label && <FieldLabel>{label}</FieldLabel>}<RadioGroupname={field.name}value={field.state.value}onValueChange={(value) => field.handleChange(value)}onBlur={field.handleBlur}{...props}>{options.map((option) => (<div key={option.value} className='flex items-center space-x-3 space-y-0'><RadioGroupItem id={`${field.name}-${option.value}`} value={option.value} aria-invalid={!isValid} /><Label htmlFor={`${field.name}-${option.value}`}>{option.label}</Label></div>))}</RadioGroup>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
Radio groups let users select a single option from a list. RadioField uses Radix UI's RadioGroup with automatic label associations and proper ARIA attributes. Unlike SelectField, radio buttons show all options upfront — ideal for 2-5 choices where comparison is important.
The component reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField>. The options prop takes an array of {label, value} objects, making it easy to map from data structures. Each radio button gets a unique ID combining the field name and value, ensuring proper accessibility.
Built-in features
- Context-bound field state via
useFieldContext— no prop drilling - Visible all-at-once for easy comparison
- Array-based options with label/value structure
- Automatic label linking with unique IDs per option
- Single-selection enforcement via RadioGroup
Setup
Field components are bound via React context. In your project, create lib/form.ts once:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { RadioField } from '@/components/ui/shuip/tanstack-form/radio-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { RadioField },
formComponents: { SubmitButton },
});See the form-context item for details.
Options structure
import { useAppForm } from '@/lib/form';
const options = [
{ label: 'Display Text 1', value: 'val1' },
{ label: 'Display Text 2', value: 'val2' },
];
const form = useAppForm({
defaultValues: { choice: '' },
onSubmit: async ({ value }) => {
await saveData(value);
},
});
<form.AppField
name='choice'
children={(field) => (
<field.RadioField options={options} label='Select one' />
)}
/>Examples
Conditional Pricing
'use client';import { createFormHook, useStore } from '@tanstack/react-form';import { Card } from '@/components/ui/card';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { RadioField } from '@/components/ui/shuip/tanstack-form/radio-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const PLANS = [{ label: 'Free - $0/month', value: 'free', price: 0 },{ label: 'Pro - $29/month', value: 'pro', price: 29 },{ label: 'Enterprise - $99/month', value: 'enterprise', price: 99 },];const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { RadioField },formComponents: { SubmitButton },});export default function TsfRadioFieldConditionalPricingExample() {const form = useAppForm({defaultValues: {plan: '',billingCycle: 'monthly',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});const plan = useStore(form.store, (state) => state.values.plan);const billingCycle = useStore(form.store, (state) => state.values.billingCycle);const selectedPlan = PLANS.find((p) => p.value === plan);const monthlyPrice = selectedPlan?.price || 0;const annualPrice = monthlyPrice * 12 * 0.8; // 20% discountconst displayPrice = billingCycle === 'annual' ? annualPrice / 12 : monthlyPrice;return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='plan'validators={{onChange: ({ value }) => (!value ? 'Please select a plan' : undefined),}}children={(field) => (<field.RadioField options={PLANS.map((p) => ({ label: p.label, value: p.value }))} label='Select Plan' />)}/>{plan && plan !== 'free' && (<form.AppFieldname='billingCycle'children={(field) => (<field.RadioFieldoptions={[{ label: 'Monthly', value: 'monthly' },{ label: 'Annual (Save 20%)', value: 'annual' },]}label='Billing Cycle'/>)}/>)}{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>)}<form.AppForm><form.SubmitButton>Continue</form.SubmitButton></form.AppForm></form>);}
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { RadioField } from '@/components/ui/shuip/tanstack-form/radio-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { RadioField },formComponents: { SubmitButton },});export default function TsfRadioFieldExample() {const form = useAppForm({defaultValues: {plan: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='plan'validators={{onChange: ({ value }) => (!value ? 'Please select a plan' : undefined),}}children={(field) => (<field.RadioFieldoptions={[{ label: 'Free', value: 'free' },{ label: 'Pro', value: 'pro' },{ label: 'Enterprise', value: 'enterprise' },]}label='Subscription Plan'description='Choose your subscription plan'/>)}/><form.AppForm><form.SubmitButton /></form.AppForm></form>);}
Payment Method
'use client';import { createFormHook, useStore } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';import { RadioField } from '@/components/ui/shuip/tanstack-form/radio-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InputField, RadioField },formComponents: { SubmitButton },});export default function TsfRadioFieldPaymentMethodExample() {const form = useAppForm({defaultValues: {paymentMethod: '',cardNumber: '',paypalEmail: '',accountNumber: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});const paymentMethod = useStore(form.store, (state) => state.values.paymentMethod);return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='paymentMethod'validators={{onChange: ({ value }) => (!value ? 'Please select a payment method' : undefined),}}children={(field) => (<field.RadioFieldoptions={[{ label: 'Credit Card', value: 'card' },{ label: 'PayPal', value: 'paypal' },{ label: 'Bank Transfer', value: 'bank' },]}label='Payment Method'description='Select how you want to pay'/>)}/>{paymentMethod === 'card' && (<form.AppFieldname='cardNumber'validators={{onChange: ({ value }) => {if (!value) return 'Card number is required';if (!/^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/.test(value)) return 'Invalid card number format';return undefined;},}}children={(field) => <field.InputField label='Card Number' props={{ placeholder: '1234 5678 9012 3456' }} />}/>)}{paymentMethod === 'paypal' && (<form.AppFieldname='paypalEmail'validators={{onChange: ({ value }) => {if (!value) return 'Email is required';if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';return undefined;},}}children={(field) => (<field.InputField label='PayPal Email' props={{ type: 'email', placeholder: 'your@email.com' }} />)}/>)}{paymentMethod === 'bank' && (<form.AppFieldname='accountNumber'validators={{onChange: ({ value }) => {if (!value) return 'Account number is required';if (value.length < 8) return 'Account number must be at least 8 digits';return undefined;},}}children={(field) => (<field.InputField label='Account Number' props={{ placeholder: 'Enter your account number' }} />)}/>)}<form.AppForm><form.SubmitButton>Process Payment</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type