
Form React
- 65 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-react is a Claude Code skill that provides production React form patterns using React Hook Form (default) or TanStack Form integrated with Zod.
About
form-react is a Claude Code skill with production React form patterns built on React Hook Form plus Zod, with TanStack Form as an alternative. It shows schema-driven forms, a reusable FormField component, FormProvider for nested sections, and blur-based validation timing. A developer loads it when building forms in a React application. It includes a comparison table for choosing between the two libraries.
- React Hook Form + Zod as the default form stack
- TanStack Form covered for cross-framework and heavy async validation
- Reusable FormField and FormProvider patterns with reward-early-punish-late timing
Form React by the numbers
- 65 all-time installs (skills.sh)
- Ranked #1,179 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
form-react capabilities & compatibility
- Capabilities
- react forms · form validation · form accessibility
- Use cases
- frontend · ui design
What form-react says it does
Production React form patterns. Default stack: **React Hook Form + Zod**.
mode: 'onBlur', // First validation on blur (punish late)
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-reactAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 65 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Build React forms using React Hook Form or TanStack Form with Zod schemas and correct validation timing.
Who is it for?
Developers building forms in React apps who want a Zod-typed, performant setup.
Skip if: Vue or framework-free forms, which have their own skills.
When should I use this skill?
Building forms in a React application.
What you get
Typed React forms with Zod schemas, reusable fields, and reward-early-punish-late validation.
- React form components
- Reusable FormField component
- Zod-integrated form setup
By the numbers
- React Hook Form listed at 12KB bundle size
- TanStack Form listed at ~15KB
Files
Form React
Production React form patterns. Default stack: React Hook Form + Zod.
Quick Start
npm install react-hook-form @hookform/resolvers zodimport { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Define schema
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters')
});
type FormData = z.infer<typeof schema>;
// 2. Use form
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur' // Reward early, punish late
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('email')} type="email" autoComplete="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" autoComplete="current-password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign in</button>
</form>
);
}When to Use Which
| Criteria | React Hook Form | TanStack Form |
|---|---|---|
| Performance | ✅ Best (uncontrolled) | Good (controlled) |
| Bundle size | 12KB | ~15KB |
| TypeScript | Good | ✅ Excellent |
| Cross-framework | ❌ React only | ✅ Multi-framework |
| React Native | Requires workarounds | ✅ Native support |
| Built-in async validation | Manual | ✅ Built-in debouncing |
| Ecosystem | ✅ Mature (4+ years) | Growing |
Default: React Hook Form — Better performance for most React web apps.
Use TanStack Form when:
- Building cross-framework component libraries
- Need strict controlled component behavior
- Heavy async validation (username checks)
- React Native applications
React Hook Form Patterns
Basic Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginFormData } from './schemas';
export function LoginForm({ onSubmit }: { onSubmit: (data: LoginFormData) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, touchedFields }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onBlur', // First validation on blur (punish late)
reValidateMode: 'onChange' // Re-validate on change (real-time correction)
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="form-field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={!!errors.email}
{...register('email')}
/>
{touchedFields.email && errors.email && (
<span role="alert">{errors.email.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
{...register('password')}
/>
{touchedFields.password && errors.password && (
<span role="alert">{errors.password.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}Reusable Form Field Component
// FormField.tsx
import { useFormContext } from 'react-hook-form';
import { ReactNode } from 'react';
interface FormFieldProps {
name: string;
label: string;
type?: string;
autoComplete?: string;
hint?: string;
required?: boolean;
children?: ReactNode;
}
export function FormField({
name,
label,
type = 'text',
autoComplete,
hint,
required,
children
}: FormFieldProps) {
const {
register,
formState: { errors, touchedFields }
} = useFormContext();
const error = errors[name];
const touched = touchedFields[name];
const showError = touched && error;
const showValid = touched && !error;
return (
<div className={`form-field ${showError ? 'error' : ''} ${showValid ? 'valid' : ''}`}>
<label htmlFor={name}>
{label}
{required && <span aria-hidden="true">*</span>}
</label>
{hint && <span className="hint">{hint}</span>}
{children || (
<input
id={name}
type={type}
autoComplete={autoComplete}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
{...register(name)}
/>
)}
{showError && (
<span id={`${name}-error`} role="alert" className="error-message">
{error.message as string}
</span>
)}
</div>
);
}Using FormProvider for Nested Components
// Form wrapper
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function CheckoutForm() {
const methods = useForm({
resolver: zodResolver(checkoutSchema),
mode: 'onBlur'
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<ContactSection />
<ShippingSection />
<PaymentSection />
<button type="submit">Place Order</button>
</form>
</FormProvider>
);
}
// Nested section component
function ContactSection() {
return (
<fieldset>
<legend>Contact Information</legend>
<FormField name="email" label="Email" type="email" autoComplete="email" required />
<FormField name="phone" label="Phone" type="tel" autoComplete="tel" />
</fieldset>
);
}Watching Values (Real-time)
import { useForm, useWatch } from 'react-hook-form';
function RegistrationForm() {
const { register, control } = useForm();
// Watch password for strength meter
const password = useWatch({ control, name: 'password', defaultValue: '' });
return (
<form>
<input type="password" {...register('password')} />
<PasswordStrength password={password} />
</form>
);
}Conditional Fields
import { useFormContext, useWatch } from 'react-hook-form';
function ConditionalField({ watchField, condition, children }) {
const { control } = useFormContext();
const value = useWatch({ control, name: watchField });
if (!condition(value)) return null;
return <>{children}</>;
}
// Usage
<ConditionalField watchField="hasCompany" condition={(val) => val === true}>
<FormField name="companyName" label="Company Name" />
</ConditionalField>Async Validation (Username Check)
import { useForm } from 'react-hook-form';
function RegistrationForm() {
const { register, setError, clearErrors } = useForm();
const checkUsername = async (username: string) => {
if (username.length < 3) return;
const response = await fetch(`/api/check-username?u=${username}`);
const { available } = await response.json();
if (!available) {
setError('username', { type: 'manual', message: 'Username is taken' });
} else {
clearErrors('username');
}
};
return (
<input
{...register('username')}
onBlur={(e) => checkUsername(e.target.value)}
/>
);
}Form Reset
function EditProfileForm({ defaultValues }) {
const { reset, handleSubmit } = useForm({ defaultValues });
// Reset to new values
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
// Reset to initial values
const handleCancel = () => reset();
return (
<form>
{/* fields */}
<button type="button" onClick={handleCancel}>Cancel</button>
<button type="submit">Save</button>
</form>
);
}Array Fields (Dynamic)
import { useFieldArray, useForm } from 'react-hook-form';
function TeamMembersForm() {
const { control, register } = useForm({
defaultValues: {
members: [{ name: '', email: '' }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'members'
});
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`members.${index}.name`)} placeholder="Name" />
<input {...register(`members.${index}.email`)} placeholder="Email" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', email: '' })}>
Add Member
</button>
</form>
);
}TanStack Form Patterns
Basic Form
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { loginSchema } from './schemas';
function LoginForm() {
const form = useForm({
defaultValues: {
email: '',
password: ''
},
onSubmit: async ({ value }) => {
await login(value);
},
validatorAdapter: zodValidator()
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="email"
validators={{ onBlur: loginSchema.shape.email }}
>
{(field) => (
<div className="form-field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span role="alert">{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
<form.Field
name="password"
validators={{ onBlur: loginSchema.shape.password }}
>
{(field) => (
<div className="form-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span role="alert">{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
)}
</form.Subscribe>
</form>
);
}Async Validation with Debouncing
<form.Field
name="username"
validators={{
onBlur: loginSchema.shape.username,
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const response = await fetch(`/api/check-username?u=${value}`);
const { available } = await response.json();
return available ? undefined : 'Username is taken';
}
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isValidating && <span>Checking...</span>}
{field.state.meta.errors[0] && <span>{field.state.meta.errors[0]}</span>}
</div>
)}
</form.Field>Linked Fields (Password Confirmation)
<form.Field
name="confirmPassword"
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password');
return value !== password ? 'Passwords do not match' : undefined;
}
}}
>
{(field) => (
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>Common Patterns
Form with Server Errors
function LoginForm() {
const [serverError, setServerError] = useState<string | null>(null);
const { handleSubmit, setError } = useForm<LoginFormData>({
resolver: zodResolver(loginSchema)
});
const onSubmit = async (data: LoginFormData) => {
try {
setServerError(null);
await login(data);
} catch (error) {
if (error.field) {
// Field-specific error
setError(error.field, { message: error.message });
} else {
// General error
setServerError(error.message);
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{serverError && (
<div role="alert" className="form-error">
{serverError}
</div>
)}
{/* fields */}
</form>
);
}Loading State
function ContactForm() {
const { handleSubmit, formState: { isSubmitting } } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)} aria-busy={isSubmitting}>
{/* fields */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner aria-hidden="true" />
<span className="sr-only">Sending message...</span>
Sending...
</>
) : (
'Send Message'
)}
</button>
</form>
);
}Focus First Error
import { useForm } from 'react-hook-form';
import { useRef, useEffect } from 'react';
function MyForm() {
const formRef = useRef<HTMLFormElement>(null);
const { handleSubmit, formState: { errors, isSubmitSuccessful } } = useForm();
// Focus first error after failed submit
useEffect(() => {
if (Object.keys(errors).length > 0) {
const firstError = formRef.current?.querySelector('[aria-invalid="true"]');
(firstError as HTMLElement)?.focus();
}
}, [errors]);
return <form ref={formRef}>{/* fields */}</form>;
}File Structure
form-react/
├── SKILL.md
├── references/
│ ├── rhf-patterns.md # React Hook Form deep-dive
│ ├── tanstack-patterns.md # TanStack Form deep-dive
│ └── migration-guide.md # Formik → RHF migration
└── scripts/
├── rhf-form-builder.tsx # RHF form patterns
├── tanstack-form-builder.tsx # TanStack patterns
├── form-field.tsx # Reusable field component
├── use-form-field.ts # Custom hook
└── schemas/ # Shared with form-validation
├── auth.ts
├── profile.ts
└── payment.tsIntegration with Other Skills
// Combine: form-react + form-validation + form-accessibility + form-security
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema } from 'form-validation/schemas/auth';
import { FormField } from 'form-accessibility/aria-form-wrapper';
import { AUTOCOMPLETE } from 'form-security/autocomplete-config';
function LoginForm({ onSubmit }) {
const methods = useForm({
resolver: zodResolver(loginSchema),
mode: 'onBlur'
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormField
label="Email"
name="email"
error={methods.formState.errors.email?.message}
touched={methods.formState.touchedFields.email}
required
>
<input
type="email"
autoComplete={AUTOCOMPLETE.email}
{...methods.register('email')}
/>
</FormField>
{/* ... */}
</form>
</FormProvider>
);
}Reference
references/rhf-patterns.md— Complete React Hook Form patternsreferences/tanstack-patterns.md— TanStack Form patternsreferences/migration-guide.md— Migrating from Formik
{
"name": "form-react",
"description": "Production React form patterns using React Hook Form and Zod for validation, error handling, and accessible form development.",
"tags": [
"forms",
"react",
"typescript",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"form-ux-patterns"
],
"last_reviewed_at": "2026-06-06",
"review_score": 47,
"relevance_tier": "A"
}
/**
* React Form Field Component
*
* Reusable form field with automatic ARIA bindings,
* validation state display, and React Hook Form integration.
*
* @module form-field
*/
import React, {
forwardRef,
useId,
ReactNode,
InputHTMLAttributes,
TextareaHTMLAttributes,
SelectHTMLAttributes
} from 'react';
import { useFormContext, RegisterOptions, FieldError } from 'react-hook-form';
// =============================================================================
// TYPES
// =============================================================================
export interface FormFieldProps {
/** Field name (must match schema) */
name: string;
/** Field label */
label: string;
/** Input type */
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'number' | 'date' | 'time' | 'datetime-local';
/** Autocomplete value */
autoComplete?: string;
/** Hint text (separate from label) */
hint?: string;
/** Placeholder text */
placeholder?: string;
/** Required field */
required?: boolean;
/** Disabled field */
disabled?: boolean;
/** Read-only field */
readOnly?: boolean;
/** Additional validation options */
validation?: RegisterOptions;
/** Custom error message override */
errorMessage?: string;
/** Additional class names */
className?: string;
/** Hide label visually (still accessible) */
hideLabel?: boolean;
/** Render custom input */
children?: ReactNode;
/** Input props to pass through */
inputProps?: InputHTMLAttributes<HTMLInputElement>;
}
export interface TextareaFieldProps extends Omit<FormFieldProps, 'type' | 'inputProps'> {
/** Number of visible rows */
rows?: number;
/** Maximum character count */
maxLength?: number;
/** Show character count */
showCount?: boolean;
/** Textarea props */
textareaProps?: TextareaHTMLAttributes<HTMLTextAreaElement>;
}
export interface SelectFieldProps extends Omit<FormFieldProps, 'type' | 'inputProps'> {
/** Select options */
options: Array<{
value: string;
label: string;
disabled?: boolean;
}>;
/** Placeholder option text */
placeholderOption?: string;
/** Select props */
selectProps?: SelectHTMLAttributes<HTMLSelectElement>;
}
export interface CheckboxFieldProps {
/** Field name */
name: string;
/** Checkbox label */
label: ReactNode;
/** Required field */
required?: boolean;
/** Disabled field */
disabled?: boolean;
/** Additional validation */
validation?: RegisterOptions;
/** Custom error message */
errorMessage?: string;
/** Additional class names */
className?: string;
}
// =============================================================================
// FORM FIELD
// =============================================================================
/**
* Standard form field with label, input, and error display
*
* @example
* ```tsx
* <FormField
* name="email"
* label="Email"
* type="email"
* autoComplete="email"
* required
* hint="We'll never share your email"
* />
* ```
*/
export function FormField({
name,
label,
type = 'text',
autoComplete,
hint,
placeholder,
required,
disabled,
readOnly,
validation,
errorMessage,
className = '',
hideLabel = false,
children,
inputProps
}: FormFieldProps) {
const id = useId();
const fieldId = `field-${name}-${id}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const {
register,
formState: { errors, touchedFields }
} = useFormContext();
const error = errors[name] as FieldError | undefined;
const touched = touchedFields[name];
const showError = touched && !!error;
const showValid = touched && !error;
// Build aria-describedby
const describedBy = [
hint && hintId,
showError && errorId
].filter(Boolean).join(' ') || undefined;
// Field classes
const fieldClasses = [
'form-field',
showError && 'form-field--error',
showValid && 'form-field--valid',
disabled && 'form-field--disabled',
className
].filter(Boolean).join(' ');
return (
<div className={fieldClasses}>
<label
htmlFor={fieldId}
className={hideLabel ? 'sr-only' : 'form-field__label'}
>
{label}
{required && (
<>
<span className="form-field__required" aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</>
)}
</label>
{hint && (
<span id={hintId} className="form-field__hint">
{hint}
</span>
)}
<div className="form-field__input-wrapper">
{children || (
<input
id={fieldId}
type={type}
autoComplete={autoComplete}
placeholder={placeholder}
disabled={disabled}
readOnly={readOnly}
aria-invalid={showError}
aria-describedby={describedBy}
aria-required={required}
{...inputProps}
{...register(name, {
required: required && 'This field is required',
...validation
})}
/>
)}
{showValid && (
<span className="form-field__icon form-field__icon--valid" aria-hidden="true">
✓
</span>
)}
{showError && (
<span className="form-field__icon form-field__icon--error" aria-hidden="true">
!
</span>
)}
</div>
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{errorMessage || error?.message || 'Invalid value'}
</span>
)}
</div>
);
}
// =============================================================================
// TEXTAREA FIELD
// =============================================================================
/**
* Textarea form field with optional character count
*
* @example
* ```tsx
* <TextareaField
* name="bio"
* label="Bio"
* rows={4}
* maxLength={500}
* showCount
* hint="Tell us about yourself"
* />
* ```
*/
export function TextareaField({
name,
label,
hint,
placeholder,
required,
disabled,
readOnly,
validation,
errorMessage,
className = '',
hideLabel = false,
rows = 4,
maxLength,
showCount = false,
textareaProps
}: TextareaFieldProps) {
const id = useId();
const fieldId = `field-${name}-${id}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const {
register,
watch,
formState: { errors, touchedFields }
} = useFormContext();
const value = watch(name) || '';
const error = errors[name] as FieldError | undefined;
const touched = touchedFields[name];
const showError = touched && !!error;
const describedBy = [
hint && hintId,
showError && errorId
].filter(Boolean).join(' ') || undefined;
const fieldClasses = [
'form-field',
'form-field--textarea',
showError && 'form-field--error',
disabled && 'form-field--disabled',
className
].filter(Boolean).join(' ');
return (
<div className={fieldClasses}>
<label
htmlFor={fieldId}
className={hideLabel ? 'sr-only' : 'form-field__label'}
>
{label}
{required && (
<span className="form-field__required" aria-hidden="true">*</span>
)}
</label>
{hint && (
<span id={hintId} className="form-field__hint">
{hint}
</span>
)}
<textarea
id={fieldId}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
disabled={disabled}
readOnly={readOnly}
aria-invalid={showError}
aria-describedby={describedBy}
aria-required={required}
{...textareaProps}
{...register(name, {
required: required && 'This field is required',
maxLength: maxLength && {
value: maxLength,
message: `Maximum ${maxLength} characters`
},
...validation
})}
/>
{showCount && maxLength && (
<span className="form-field__count" aria-live="polite">
{value.length}/{maxLength}
</span>
)}
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{errorMessage || error?.message}
</span>
)}
</div>
);
}
// =============================================================================
// SELECT FIELD
// =============================================================================
/**
* Select dropdown form field
*
* @example
* ```tsx
* <SelectField
* name="country"
* label="Country"
* autoComplete="country"
* options={[
* { value: 'US', label: 'United States' },
* { value: 'CA', label: 'Canada' }
* ]}
* placeholderOption="Select a country"
* />
* ```
*/
export function SelectField({
name,
label,
autoComplete,
hint,
required,
disabled,
validation,
errorMessage,
className = '',
hideLabel = false,
options,
placeholderOption,
selectProps
}: SelectFieldProps) {
const id = useId();
const fieldId = `field-${name}-${id}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const {
register,
formState: { errors, touchedFields }
} = useFormContext();
const error = errors[name] as FieldError | undefined;
const touched = touchedFields[name];
const showError = touched && !!error;
const describedBy = [
hint && hintId,
showError && errorId
].filter(Boolean).join(' ') || undefined;
const fieldClasses = [
'form-field',
'form-field--select',
showError && 'form-field--error',
disabled && 'form-field--disabled',
className
].filter(Boolean).join(' ');
return (
<div className={fieldClasses}>
<label
htmlFor={fieldId}
className={hideLabel ? 'sr-only' : 'form-field__label'}
>
{label}
{required && (
<span className="form-field__required" aria-hidden="true">*</span>
)}
</label>
{hint && (
<span id={hintId} className="form-field__hint">
{hint}
</span>
)}
<div className="form-field__input-wrapper">
<select
id={fieldId}
autoComplete={autoComplete}
disabled={disabled}
aria-invalid={showError}
aria-describedby={describedBy}
aria-required={required}
{...selectProps}
{...register(name, {
required: required && 'Please select an option',
...validation
})}
>
{placeholderOption && (
<option value="" disabled>
{placeholderOption}
</option>
)}
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
</div>
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{errorMessage || error?.message}
</span>
)}
</div>
);
}
// =============================================================================
// CHECKBOX FIELD
// =============================================================================
/**
* Checkbox form field
*
* @example
* ```tsx
* <CheckboxField
* name="acceptTerms"
* label={<>I accept the <a href="/terms">Terms</a></>}
* required
* />
* ```
*/
export function CheckboxField({
name,
label,
required,
disabled,
validation,
errorMessage,
className = ''
}: CheckboxFieldProps) {
const id = useId();
const fieldId = `field-${name}-${id}`;
const errorId = `${fieldId}-error`;
const {
register,
formState: { errors, touchedFields }
} = useFormContext();
const error = errors[name] as FieldError | undefined;
const touched = touchedFields[name];
const showError = touched && !!error;
const fieldClasses = [
'form-field',
'form-field--checkbox',
showError && 'form-field--error',
disabled && 'form-field--disabled',
className
].filter(Boolean).join(' ');
return (
<div className={fieldClasses}>
<label htmlFor={fieldId} className="form-field__checkbox-label">
<input
id={fieldId}
type="checkbox"
disabled={disabled}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
{...register(name, {
required: required && 'This field is required',
...validation
})}
/>
<span className="form-field__checkbox-text">{label}</span>
</label>
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{errorMessage || error?.message}
</span>
)}
</div>
);
}
// =============================================================================
// PASSWORD FIELD
// =============================================================================
/**
* Password field with visibility toggle
*/
export const PasswordField = forwardRef<HTMLInputElement, FormFieldProps>(
function PasswordField(props, ref) {
const [visible, setVisible] = React.useState(false);
return (
<FormField
{...props}
type={visible ? 'text' : 'password'}
>
<div className="password-field__wrapper">
<input
ref={ref}
type={visible ? 'text' : 'password'}
className="password-field__input"
/>
<button
type="button"
className="password-field__toggle"
onClick={() => setVisible(!visible)}
aria-label={visible ? 'Hide password' : 'Show password'}
>
{visible ? '👁️' : '👁️🗨️'}
</button>
</div>
</FormField>
);
}
);
// =============================================================================
// CSS
// =============================================================================
export const formFieldCSS = `
/* Form Field */
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.form-field__label {
font-weight: 500;
font-size: 0.875rem;
color: #374151;
}
.form-field__required {
color: #dc2626;
margin-left: 0.25rem;
}
.form-field__hint {
font-size: 0.75rem;
color: #6b7280;
}
.form-field__input-wrapper {
position: relative;
}
.form-field input,
.form-field select,
.form-field textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-field--error input,
.form-field--error select,
.form-field--error textarea {
border-color: #dc2626;
}
.form-field--valid input,
.form-field--valid select,
.form-field--valid textarea {
border-color: #059669;
}
.form-field__icon {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
}
.form-field__icon--valid { color: #059669; }
.form-field__icon--error { color: #dc2626; }
.form-field__error {
font-size: 0.75rem;
color: #dc2626;
}
.form-field__count {
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
/* Checkbox */
.form-field--checkbox {
flex-direction: row;
align-items: flex-start;
}
.form-field__checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
cursor: pointer;
}
.form-field__checkbox-label input {
width: auto;
margin-top: 0.125rem;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;
Related skills
FAQ
What is the default library?
React Hook Form with Zod; TanStack Form is recommended for cross-framework libraries, heavy async validation, or React Native.
What validation timing does it use?
mode onBlur to punish late and reValidateMode onChange for real-time correction.