Stop rewriting form logic. Build a consistent form component library that handles validation, errors, and accessibility.
Step 1: Install Dependencies
pnpm add react-hook-form @hookform/resolvers zod
Step 2: Form Field Wrapper
// components/form/FormField.tsx
import { type ReactNode } from "react";
interface FormFieldProps {
label: string;
error?: string;
description?: string;
required?: boolean;
children: ReactNode;
}
export function FormField({
label,
error,
description,
required,
children,
}: FormFieldProps) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
{required && <span className="ml-0.5 text-red-500">*</span>}
</label>
{description && (
<p className="text-xs text-gray-500">{description}</p>
)}
{children}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
Step 3: Text Input Component
// components/form/TextInput.tsx
import { forwardRef, type InputHTMLAttributes } from "react";
import { FormField } from "./FormField";
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
description?: string;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ label, error, description, required, className, ...props }, ref) => {
return (
<FormField
label={label}
error={error}
description={description}
required={required}
>
<input
ref={ref}
aria-invalid={!!error}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
error
? "border-red-300 focus:ring-red-500"
: "border-gray-300 dark:border-gray-600"
} ${className}`}
{...props}
/>
</FormField>
);
}
);
TextInput.displayName = "TextInput";
Step 4: Textarea Component
// components/form/TextArea.tsx
import { forwardRef, type TextareaHTMLAttributes } from "react";
import { FormField } from "./FormField";
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
error?: string;
description?: string;
}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ label, error, description, required, className, ...props }, ref) => {
return (
<FormField
label={label}
error={error}
description={description}
required={required}
>
<textarea
ref={ref}
aria-invalid={!!error}
rows={4}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
error ? "border-red-300" : "border-gray-300 dark:border-gray-600"
} ${className}`}
{...props}
/>
</FormField>
);
}
);
TextArea.displayName = "TextArea";
Step 5: Select Component
// components/form/Select.tsx
import { forwardRef, type SelectHTMLAttributes } from "react";
import { FormField } from "./FormField";
interface Option {
value: string;
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string;
options: Option[];
error?: string;
description?: string;
placeholder?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{ label, options, error, description, required, placeholder, ...props },
ref
) => {
return (
<FormField
label={label}
error={error}
description={description}
required={required}
>
<select
ref={ref}
aria-invalid={!!error}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 ${
error ? "border-red-300" : "border-gray-300"
}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
);
}
);
Select.displayName = "Select";
Step 6: Checkbox and Radio Components
// components/form/Checkbox.tsx
import { forwardRef, type InputHTMLAttributes } from "react";
interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
description?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ label, description, ...props }, ref) => {
return (
<label className="flex items-start gap-3">
<input
ref={ref}
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
{...props}
/>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{label}
</span>
{description && (
<p className="text-xs text-gray-500">{description}</p>
)}
</div>
</label>
);
}
);
Checkbox.displayName = "Checkbox";
// components/form/RadioGroup.tsx
interface RadioOption {
value: string;
label: string;
description?: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: RadioOption[];
value?: string;
onChange?: (value: string) => void;
error?: string;
}
export function RadioGroup({
label,
name,
options,
value,
onChange,
error,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</legend>
<div className="space-y-2">
{options.map((option) => (
<label
key={option.value}
className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<input
type="radio"
name={name}
value={option.value}
checked={value === option.value}
onChange={() => onChange?.(option.value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium">{option.label}</span>
{option.description && (
<p className="text-xs text-gray-500">{option.description}</p>
)}
</div>
</label>
))}
</div>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</fieldset>
);
}
Step 7: Complete Form Example
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { TextInput } from "@/components/form/TextInput";
import { TextArea } from "@/components/form/TextArea";
import { Select } from "@/components/form/Select";
import { Checkbox } from "@/components/form/Checkbox";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
company: z.string().optional(),
service: z.string().min(1, "Please select a service"),
message: z.string().min(10, "Message must be at least 10 characters"),
newsletter: z.boolean().optional(),
});
type FormValues = z.infer<typeof schema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
async function onSubmit(data: FormValues) {
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(data),
});
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<TextInput
label="Name"
required
error={errors.name?.message}
{...register("name")}
/>
<TextInput
label="Email"
type="email"
required
error={errors.email?.message}
{...register("email")}
/>
</div>
<TextInput
label="Company"
error={errors.company?.message}
{...register("company")}
/>
<Select
label="Service"
required
placeholder="Select a service..."
options={[
{ value: "web-design", label: "Web Design" },
{ value: "web-development", label: "Web Development" },
{ value: "mobile-app", label: "Mobile App Development" },
{ value: "seo", label: "SEO & Marketing" },
]}
error={errors.service?.message}
{...register("service")}
/>
<TextArea
label="Message"
required
error={errors.message?.message}
{...register("message")}
/>
<Checkbox
label="Subscribe to newsletter"
description="Get weekly tips on web design and development."
{...register("newsletter")}
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
);
}
Need Custom Forms Built?
We build web applications with complex forms, validation, and data collection workflows. Contact us to discuss your project.