Radio Field

Radio button group component integrated with TanStack Form. 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
import type {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldOptions,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
ReactFormApi,
} from '@tanstack/react-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 RadioFieldOption {
label: string;
value: string;
}
export interface RadioFieldProps<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
> {
form: ReactFormApi<
TFormData,
undefined | FormValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
any
>;
name: TName;
options: RadioFieldOption[];
label?: string;
description?: string;
formProps?: Partial<
FieldOptions<
TFormData,
TName,
TData,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>
>
>;
fieldProps?: React.ComponentProps<typeof Field>;
props?: React.ComponentProps<typeof RadioGroup>;
}
export function RadioField<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
>({ form, name, options, label, description, formProps, fieldProps, props }: RadioFieldProps<TFormData, TName, TData>) {
return (
<form.Field name={name} {...formProps}>
{(field) => {
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 as string}
onValueChange={(value) => field.handleChange(value as TData)}
onBlur={field.handleBlur}
{...props}
>
{options.map(({ label, value }) => (
<div key={value} className='flex items-center space-x-3 space-y-0'>
<RadioGroupItem id={`${field.name}-${value}`} value={value} aria-invalid={!isValid} />
<Label htmlFor={`${field.name}-${value}`}>{label}</Label>
</div>
))}
</RadioGroup>
{!isValid && (
<FieldError className='text-xs text-left' errors={errors.map((error) => ({ message: error }))} />
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}}
</form.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 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

  • 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

Options structure

const options = [
    { label: 'Display Text 1', value: 'val1' },
    { label: 'Display Text 2', value: 'val2' },
]

<RadioField
    form={form}
    name='choice'
    options={options}
    label='Select one'
/>

Examples

Conditional Pricing

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
import { Card } from '@/components/ui/card';
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 },
];
export default function TsfRadioFieldConditionalPricingExample() {
const form = useForm({
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'
>
<RadioField
form={form}
name='plan'
options={PLANS.map((p) => ({ label: p.label, value: p.value }))}
label='Select Plan'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a plan' : undefined),
},
}}
/>
{plan && plan !== 'free' && (
<RadioField
form={form}
name='billingCycle'
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>
)}
<SubmitButton form={form}>Continue</SubmitButton>
</form>
);
}

Default

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { RadioField } from '@/components/ui/shuip/tanstack-form/radio-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfRadioFieldExample() {
const form = useForm({
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'
>
<RadioField
form={form}
name='plan'
options={[
{ label: 'Free', value: 'free' },
{ label: 'Pro', value: 'pro' },
{ label: 'Enterprise', value: 'enterprise' },
]}
label='Subscription Plan'
description='Choose your subscription plan'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a plan' : undefined),
},
}}
/>
<SubmitButton form={form} />
</form>
);
}

Payment Method

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
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';
export default function TsfRadioFieldPaymentMethodExample() {
const form = useForm({
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'
>
<RadioField
form={form}
name='paymentMethod'
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'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a payment method' : undefined),
},
}}
/>
{paymentMethod === 'card' && (
<InputField
form={form}
name='cardNumber'
label='Card Number'
props={{ placeholder: '1234 5678 9012 3456' }}
formProps={{
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;
},
},
}}
/>
)}
{paymentMethod === 'paypal' && (
<InputField
form={form}
name='paypalEmail'
label='PayPal Email'
props={{ type: 'email', placeholder: 'your@email.com' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';
return undefined;
},
},
}}
/>
)}
{paymentMethod === 'bank' && (
<InputField
form={form}
name='accountNumber'
label='Account Number'
props={{ placeholder: 'Enter your account number' }}
formProps={{
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;
},
},
}}
/>
)}
<SubmitButton form={form}>Process Payment</SubmitButton>
</form>
);
}

Props

Prop

Type

On this page