Radio Field

Radio button group component integrated with TanStack Form via React context. Options are defined as Array<{label, value}>.

npx shadcn@latest add @shuip/tsf-radio-field
pnpm dlx shadcn@latest add @shuip/tsf-radio-field
bun x shadcn@latest add @shuip/tsf-radio-field
'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>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

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