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>}
<RadioGroup
name={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 && (
<FieldError
className='text-xs text-left'
errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
/>
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

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

Loading...
'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% discount
const displayPrice = billingCycle === 'annual' ? annualPrice / 12 : monthlyPrice;
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='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.AppField
name='billingCycle'
children={(field) => (
<field.RadioField
options={[
{ 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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='plan'
validators={{
onChange: ({ value }) => (!value ? 'Please select a plan' : undefined),
}}
children={(field) => (
<field.RadioField
options={[
{ 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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='paymentMethod'
validators={{
onChange: ({ value }) => (!value ? 'Please select a payment method' : undefined),
}}
children={(field) => (
<field.RadioField
options={[
{ 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.AppField
name='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.AppField
name='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.AppField
name='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

On this page