Input Field

Text input component integrated with TanStack Form for type-safe form management. Supports tooltips and InputGroup integration for addons and buttons.

npx shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
import type {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldOptions,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
ReactFormApi,
} from '@tanstack/react-form';
import { InfoIcon } from 'lucide-react';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface InputFieldProps<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends 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;
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<'input'>;
tooltip?: React.ReactNode;
}
export function InputField<TFormData, TName extends DeepKeys<TFormData>, TData extends DeepValue<TFormData, TName>>({
form,
name,
label,
description,
formProps,
fieldProps,
props,
tooltip,
}: InputFieldProps<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>}
<InputGroup>
<InputGroupInput
type='text'
name={field.name}
value={field.state.value as string}
onChange={(e) => field.handleChange(e.target.value as TData)}
onBlur={field.handleBlur}
aria-invalid={!isValid}
{...props}
/>
{tooltip && (
<InputGroupAddon align='inline-end'>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton aria-label='Info' size='icon-xs'>
<InfoIcon />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</InputGroupAddon>
)}
</InputGroup>
{!isValid && (
<FieldError className='text-xs text-left' errors={errors.map((error) => ({ message: error }))} />
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}}
</form.Field>
);
}
Loading...

InputField is a text input component that encapsulates TanStack Form's field management with shadcn/ui's design system. It handles all the boilerplate of connecting form state to an input element: wiring event handlers, displaying errors, managing touched states, and rendering consistent UI.

This component is useful when you want to quickly add form inputs without manually setting up form.Field render props, input bindings, and error display logic for every field.

Built-in features

  • Type-safe field names: Uses DeepKeys<TFormData> for autocomplete and compile-time validation
  • Nested path support: Access deeply nested values like user.profile.email
  • Tooltip integration: Optional InfoIcon button with tooltip content via tooltip prop
  • InputGroup ready: Supports shadcn InputGroup for seamless addon integration
  • Async validation: Built-in debouncing and loading states for API validation
  • Full type inference: Field value types automatically inferred from form schema

Less boilerplate

TanStack Form's standard approach uses render props to access field state:

<form.Field
  name="email"
  validators={{
    onChange: ({ value }) => !value.includes('@') ? 'Invalid email' : undefined
  }}
>
  {(field) => (
    <>
      <label htmlFor={field.name}>Email</label>
      <input
        id={field.name}
        name={field.name}
        type="email"
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
        onBlur={field.handleBlur}
      />
      {!field.state.meta.isValid && (
        <span>{field.state.meta.errors.join(', ')}</span>
      )}
    </>
  )}
</form.Field>

With InputField, this reduces to a single declarative component:

<InputField
    form={form}
    name='email'
    label='Email'
    props={{ type: 'email' }}
    formProps={{
      validators: {
        onChange: ({ value }) => !value.includes('@') ? 'Invalid email' : undefined
      }
    }}
/>

Common use cases

const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      age: 0,
    },
    onSubmit: async ({ value }) => {
      await saveData(value)
    },
})

// Basic text input
<InputField
    form={form}
    name='name'
    label='Name'
    description='Your full name'
/>

// Email with validation
<InputField
    form={form}
    name='email'
    label='Email'
    props={{ type: 'email' }}
    formProps={{
      validators: {
        onChange: ({ value }) =>
          !value.includes('@') ? 'Invalid email' : undefined
      }
    }}
/>

// With tooltip
<InputField
    form={form}
    name='username'
    label='Username'
    tooltip='Username must be unique and 3-20 characters'
    formProps={{
      validators: {
        onChange: ({ value }) =>
          value.length < 3 ? 'Too short' : undefined
      }
    }}
/>

// Number input
<InputField
    form={form}
    name='age'
    label='Age'
    props={{ type: 'number', min: 0, max: 120 }}
/>

Examples

Async Validation

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
// Simulate API call to check username availability
async function checkUsernameAvailability(username: string): Promise<boolean> {
await new Promise((resolve) => setTimeout(resolve, 1000));
const takenUsernames = ['admin', 'user', 'test', 'demo'];
return !takenUsernames.includes(username.toLowerCase());
}
export default function TsfInputFieldAsyncValidationExample() {
const form = useForm({
defaultValues: {
username: '',
email: '',
},
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'
>
<InputField
form={form}
name='username'
label='Username'
description='Username will be checked for availability'
props={{ placeholder: 'Enter username' }}
formProps={{
validators: {
// Sync validation: runs immediately
onChange: ({ value }) => {
if (!value) return 'Username is required';
if (value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only letters, numbers, and underscores allowed';
return undefined;
},
// Async validation: checks availability via API
onChangeAsync: async ({ value }) => {
if (!value || value.length < 3) return undefined;
const available = await checkUsernameAvailability(value);
return available ? undefined : 'Username is already taken';
},
// Debounce async validation to avoid excessive API calls
onChangeAsyncDebounceMs: 500,
},
}}
/>
<InputField
form={form}
name='email'
label='Email'
description='Email will be validated on blur'
props={{ type: 'email', placeholder: 'your@email.com' }}
formProps={{
validators: {
onBlur: ({ value }) => {
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';
return undefined;
},
},
}}
/>
<SubmitButton form={form}>Create Account</SubmitButton>
</form>
);
}

Default

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfInputFieldExample() {
const form = useForm({
defaultValues: {
name: '',
email: '',
},
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'
>
<InputField
form={form}
name='name'
label='Name'
description='Your full name'
formProps={{
validators: {
onChange: ({ value }) => (value.length < 3 ? 'Name must be at least 3 characters' : undefined),
},
}}
/>
<InputField
form={form}
name='email'
label='Email'
props={{ type: 'email' }}
formProps={{
validators: {
onChange: ({ value }) => (!value.includes('@') ? 'Invalid email address' : undefined),
},
}}
/>
<SubmitButton form={form} props={{ variant: 'outline' }}>
Register
</SubmitButton>
</form>
);
}

Nested Path

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfInputFieldNestedPathExample() {
const form = useForm({
defaultValues: {
user: {
email: '',
profile: {
firstName: '',
lastName: '',
bio: '',
},
address: {
street: '',
city: '',
zipCode: '',
},
},
},
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-6'
>
<div className='space-y-4'>
<h3 className='text-lg font-semibold'>Account</h3>
<InputField
form={form}
name='user.email'
label='Email Address'
props={{ type: 'email' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';
return undefined;
},
},
}}
/>
</div>
<div className='space-y-4'>
<h3 className='text-lg font-semibold'>Profile</h3>
<div className='grid grid-cols-2 gap-4'>
<InputField
form={form}
name='user.profile.firstName'
label='First Name'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'First name is required' : undefined),
},
}}
/>
<InputField
form={form}
name='user.profile.lastName'
label='Last Name'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Last name is required' : undefined),
},
}}
/>
</div>
<InputField
form={form}
name='user.profile.bio'
label='Bio'
description='Tell us about yourself'
props={{ placeholder: 'Software developer from...' }}
/>
</div>
<div className='space-y-4'>
<h3 className='text-lg font-semibold'>Address</h3>
<InputField
form={form}
name='user.address.street'
label='Street Address'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Street address is required' : undefined),
},
}}
/>
<div className='grid grid-cols-2 gap-4'>
<InputField form={form} name='user.address.city' label='City' />
<InputField
form={form}
name='user.address.zipCode'
label='ZIP Code'
props={{ placeholder: '12345' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return undefined;
if (!/^\d{5}(-\d{4})?$/.test(value)) return 'Invalid ZIP code format';
return undefined;
},
},
}}
/>
</div>
</div>
<SubmitButton form={form}>Save Profile</SubmitButton>
</form>
);
}

Tooltip

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfInputFieldTooltipExample() {
const form = useForm({
defaultValues: {
apiKey: '',
webhookUrl: '',
secretKey: '',
},
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'
>
<InputField
form={form}
name='apiKey'
label='API Key'
description='Your application API key'
tooltip={
<>
<p className='font-semibold mb-1'>Where to find your API key:</p>
<ol className='list-decimal list-inside space-y-1 text-sm'>
<li>Go to Settings → API</li>
<li>Click "Generate New Key"</li>
<li>Copy the key (it will only be shown once)</li>
</ol>
</>
}
props={{ placeholder: 'sk_live_...' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'API key is required';
if (!value.startsWith('sk_')) return 'API key must start with sk_';
if (value.length < 20) return 'API key is too short';
return undefined;
},
},
}}
/>
<InputField
form={form}
name='webhookUrl'
label='Webhook URL'
description='Endpoint to receive webhook events'
tooltip='This URL must be publicly accessible and accept POST requests. We recommend using HTTPS for security.'
props={{ type: 'url', placeholder: 'https://api.example.com/webhooks' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Webhook URL is required';
if (!value.startsWith('https://')) return 'Webhook URL must use HTTPS';
try {
new URL(value);
return undefined;
} catch {
return 'Invalid URL format';
}
},
},
}}
/>
<InputField
form={form}
name='secretKey'
label='Webhook Secret'
description='Used to verify webhook signatures'
tooltip='Keep this secret safe. It will be used to sign webhook payloads so you can verify their authenticity.'
props={{ placeholder: 'whsec_...' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Webhook secret is required';
if (value.length < 16) return 'Secret must be at least 16 characters';
return undefined;
},
},
}}
/>
<SubmitButton form={form}>Save Configuration</SubmitButton>
</form>
);
}

Props

Prop

Type

On this page