better-forms
SkillMonitoring & opsComplete guide for building accessible, high-UX forms in modern stacks (React/Next.js, Tailwind, Zod). Includes specific patterns for clickable areas, range sliders, output-inspired design, and WCAG compliance.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the better-forms skill
What this skill tells your AI
The instructions your AI receives, as published by asymmetric-al/core in .agents/skills/better-forms/SKILL.md and read by ahel’s review.
Repo notice (Asymmetric-al/core): This repository is Base UI only. Shared primitives come from
@base-ui/reactvia the shadcnbase-maiastyle inpackages/ui. Ignore any Radix UI guidance below — never addradix-ui/@radix-ui/*imports or dependencies; composition uses Base UI'srenderprop, notasChild. Seedocs/ai/rules/frontend.md.
Better Forms Guide
A collection of specific UX patterns, accessibility standards, and implementation techniques for modern web forms. This guide bridges the gap between raw HTML/CSS tips and component-based architectures (React, Tailwind, Headless UI).
1. High-Impact UX Patterns (The "Why" & "How")
Avoid "Dead Zones" in Lists
Concept: Small gaps between clickable list items create frustration. Implementation (Tailwind): Use a pseudo-element to expand the hit area without affecting layout.
// Do this for list items or radio groups
<div className="relative group">
<input type="radio" className="..." />
<label className="... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']">
Option Label
</label>
</div>
Range Sliders > Min/Max Inputs
Concept: "From $10 to $1000" text inputs are tedious. Implementation: Use a dual-thumb slider component (like Radix UI / Shadcn Slider) for ranges.
- Why: Cognitive load reduction and immediate visual feedback.
- A11y: Ensure the slider supports arrow key navigation.
"Output-Inspired" Design
Concept: The form inputs should visually resemble the final result card/page.
- Hierarchy: If the output title is
text-2xl font-bold, the input for it should betext-2xl font-bold. - Placement: If the image goes on the left in the listing, the upload button goes on the left in the form.
- Empty States: Preview what the empty card looks like while filling it.
Descriptive Action Buttons
Concept: Never use "Submit" or "Send". The button should complete the sentence "I want to..."
- Avoid:
Submit - Prefer:
Create Account,Publish Listing,Update ProfileTip: Update button text dynamically based on form state (e.g., "Saving..." vs "Save Changes").
"Optional" Label > Asterisks
Concept: Red asterisks (*) are aggressive and ambiguous (sometimes meaning "error"). Implementation: Mark required fields by default (no indicator) and explicitly label optional ones.
<Label>
Phone Number{" "}
<span className="text-muted-foreground text-sm font-normal">(Optional)</span>
</Label>
Show/Hide Password
Concept: Masking passwords by default prevents error correction. Implementation: Always include a toggle button inside the input wrapper.
- A11y: The toggle button must have
type="button"andaria-label="Show password".
Field Sizing as Affordance
Concept: The width of the input suggests the expected data length.
- Zip Code:
w-20orw-24(not full width). - CVV: Small width.
- Street Address: Full width.
2. Advanced UX Patterns
Input Masking & Formatting
Concept: Auto-format data as the user types to reduce errors and cognitive load.
// Phone number formatting with react-number-format
import { PatternFormat } from "react-number-format";
<PatternFormat
format="(###) ###-####"
mask="_"
allowEmptyFormatting
customInput={Input} // Your styled input component
onValueChange={(values) => {
// values.value = "1234567890" (raw)
// values.formattedValue = "(123) 456-7890"
form.setValue("phone", values.value);
}}
/>;
// Credit card with automatic spacing
<PatternFormat
format="#### #### #### ####"
customInput={Input}
onValueChange={(values) => form.setValue("cardNumber", values.value)}
/>;
// Currency input
import { NumericFormat } from "react-number-format";
<NumericFormat
thousandSeparator=","
prefix="$"
decimalScale={2}
fixedDecimalScale
customInput={Input}
onValueChange={(values) => form.setValue("amount", values.floatValue)}
/>;
Key Principle: Store raw values, display formatted values. Never validate formatted strings.
OTP / 2FA Code Inputs
Concept: 6-digit verification codes need special handling for paste, auto-focus, and keyboard navigation.
import {
useEffect,
useRef,
useState,
useCallback,
ClipboardEvent,
KeyboardEvent,
} from "react";
interface OTPInputProps {
length?: number;
onComplete: (code: string) => void;
}
export function OTPInput({ length = 6, onComplete }: OTPInputProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// Keep state aligned with dynamic length changes.
useEffect(() => {
setValues((prev) => {
if (prev.length === length) return prev;
const next = prev.slice(0, length);
while (next.length < length) next.push("");
return next;
});
inputRefs.current = inputRefs.current.slice(0, length);
}, [length]);
const focusInput = useCallback(
(index: number) => {
const clampedIndex = Math.max(0, Math.min(index, length - 1));
inputRefs.current[clampedIndex]?.focus();
},
[length],
);
const handleChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return; // Only digits
const newValues = [...values];
newValues[index] = value.slice(-1); // Take last digit only
setValues(newValues);
if (value && index < length - 1) {
focusInput(index + 1);
}
const code = newValues.join("");
if (code.length === length) {
onComplete(code);
}
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Backspace":
if (!values[index] && index > 0) {
focusInput(index - 1);
}
break;
case "ArrowLeft":
e.preventDefault();
focusInput(index - 1);
break;
case "ArrowRight":
e.preventDefault();
focusInput(index + 1);
break;
}
};
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length);
if (pastedData) {
const newValues = [...values];
pastedData.split("").forEach((char, i) => {
newValues[i] = char;
});
setValues(newValues);
focusInput(pastedData.length - 1);
if (pastedData.length === length) {
onComplete(pastedData);
}
}
};
return (
<div className="flex gap-2" role="group" aria-label="Verification code">
{values.map((value, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={value}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
className="h-12 w-12 text-center text-lg font-semibold border rounded-md
focus:ring-2 focus:ring-ring focus:border-transparent"
aria-label={`Digit ${index + 1} of ${length}`}
/>
))}
</div>
);
}
Unsaved Changes Protection
Concept: Prevent accidental data loss when navigating away from a dirty form.
Note (React 19): Don't confuse useFormState from react-hook-form with React DOM's useFormState, which was renamed to useActionState in React 19.
Warning: Monkey-patching router.push is fragile and may break across Next.js versions. There is no stable API for intercepting App Router navigation. The beforeunload approach is the only reliable part. Consider using onBeforePopState (Pages Router) or a route change event listener if your framework supports it.
import { useEffect } from "react";
import { useFormState } from "react-hook-form";
export function useUnsavedChangesWarning(isDirty: boolean, message?: string) {
const warningMessage =
message ?? "You have unsaved changes. Are you sure you want to leave?";
// Browser back/refresh — this is the reliable approach
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
// For in-app navigation, consider a confirmation modal triggered
// from your navigation components rather than monkey-patching the router.
}
// Usage with React Hook Form
function EditProfileForm() {
const form = useForm<ProfileData>();
const { isDirty } = useFormState({ control: form.control });
useUnsavedChangesWarning(isDirty);
return <form>...</form>;
}
Multi-Step Forms (Wizards)
Concept: Break complex forms into digestible steps with proper state persistence and focus management.
import { useState, useEffect, useRef, useCallback } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const MIN_WIZARD_STEP = 1;
const MAX_WIZARD_STEP = 3;
function clampStep(step: number) {
if (!Number.isFinite(step)) return MIN_WIZARD_STEP;
return Math.min(MAX_WIZARD_STEP, Math.max(MIN_WIZARD_STEP, step));
}
// Persist state to URL for refresh resilience
// Uses lazy state init to read from URL on first render (SSR-safe)
function useStepFromURL() {
const [step, setStep] = useState(() => {
if (typeof window === "undefined") return MIN_WIZARD_STEP;
const params = new URLSearchParams(window.location.search);
return clampStep(parseInt(params.get("step") ?? String(MIN_WIZARD_STEP), 10));
});
const goToStep = useCallback((newStep: number) => {
const clampedStep = clampStep(newStep);
setStep(clampedStep);
const url = new URL(window.location.href);
url.searchParams.set("step", String(clampedStep));
window.history.pushState({}, "", url);
}, []);
return { step, goToStep };
}
// Focus management on step change
function useStepFocus(step: number) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// Focus the step heading for screen reader announcement
headingRef.current?.focus();
}, [step]);
return headingRef;
}
// Example multi-step form
interface WizardFormData {
// Step 1
firstName: string;
lastName: string;
// Step 2
email: string;
phone?: string;
// Step 3
address: string;
city: string;
}
const stepSchemas = {
1: z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
2: z.object({ email: z.string().email(), phone: z.string().optional() }),
3: z.object({ address: z.string().min(1), city: z.string().min(1) }),
};
export function WizardForm() {
const { step, goToStep } = useStepFromURL();
const headingRef = useStepFocus(step);
const totalSteps = MAX_WIZARD_STEP;
const canUseStorage =
typeof window !== "undefined" &&
typeof window.localStorage !== "undefined";
const form = useForm<WizardFormData>({
resolver: zodResolver(stepSchemas[step as keyof typeof stepSchemas]),
mode: "onBlur",
});
// Persist draft to localStorage
useEffect(() => {
if (!canUseStorage) return;
const saved = localStorage.getItem("wizard-draft");
if (saved) {
form.reset(JSON.parse(saved));
}
}, [canUseStorage, form]);
useEffect(() => {
if (!canUseStorage) return;
const subscription = form.watch((data) => {
localStorage.setItem("wizard-draft", JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [canUseStorage, form]);
const handleNext = async () => {
const isValid = await form.trigger();
if (isValid && step < totalSteps) {
goToStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) goToStep(step - 1);
};
const onSubmit = (data: WizardFormData) => {
console.log("Wizard submit", data);
if (canUseStorage) {
localStorage.removeItem("wizard-draft");
}
};
const StepOne = () => (
<div className="grid gap-4">
<label>
First name
<input {...form.register("firstName")} />
</label>
<label>
Last name
<input {...form.register("lastName")} />
</label>
</div>
);
const StepTwo = () => (
<div className="grid gap-4">
<label>
Email
<input type="email" {...form.register("email")} />
</label>
<label>
Phone (optional)
<input type="tel" {...form.register("phone")} />
</label>
</div>
);
const StepThree = () => (
<div className="grid gap-4">
<label>
Address
<input {...form.register("address")} />
</label>
<label>
City
<input {...form.register("city")} />
</label>
</div>
);
return (
<FormProvider {...form}>
{/* Progress indicator */}
<div
role="progressbar"
aria-valuenow={step}
aria-valuemin={1}
aria-valuemax={totalSteps}
>
Step {step} of {totalSteps}
</div>
{/* Step heading - focused on navigation */}
<h2 ref={headingRef} tabIndex={-1} className="outline-none">
{step === 1 && "Personal Information"}
{step === 2 && "Contact Details"}
{step === 3 && "Address"}
</h2>
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <StepOne />}
{step === 2 && <StepTwo />}
{step === 3 && <StepThree />}
<div className="flex gap-4 mt-6">
{step > 1 && (
<button type="button" onClick={handleBack}>
Back
</button>
)}
{step < totalSteps ? (
<button type="button" onClick={handleNext}>
Continue
</button>
) : (
<button type="submit">Complete Registration</button>
)}
</div>
</form>
</FormProvider>
);
}
3. Backend Integration Patterns
Server-Side Error Mapping
Concept: Map API validation errors back to specific form fields.
import { useForm, UseFormReturn } from "react-hook-form";
interface APIError {
field: string;
message: string;
}
interface APIResponse {
success: boolean;
errors?: APIError[];
}
// Usage: pass form instance and call inside component
function useServerErrorHandler<T extends Record<string, unknown>>(
form: UseFormReturn<T>,
) {
return async (data: T) => {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(data),
});
const result: APIResponse = await response.json();
if (!result.success && result.errors) {
// Map server errors to form fields
result.errors.forEach((error) => {
form.setError(error.field as keyof T & string, {
type: "server",
message: error.message,
});
});
// Focus the first errored field
const firstErrorField = result.errors[0]?.field;
if (firstErrorField) {
form.setFocus(firstErrorField as keyof T & string);
}
return;
}
// Success handling
};
}
// For nested errors (e.g., "address.city")
function mapNestedError(form: UseFormReturn, path: string, message: string) {
form.setError(path as any, { type: "server", message });
}
Debounced Async Validation
Concept: Validate expensive fields (username availability) without API overload.
import { useEffect, useMemo, useRef, useState } from "react";
import debounce from "lodash.debounce";
// Custom hook for async field validation
// Uses ref for validateFn to keep debounce stable and avoid timer resets.
// Cancels in-flight debounced calls on unmount to prevent memory leaks.
function useAsyncValidation<T>(
validateFn: (value: T) => Promise<string | null>,
delay = 500,
) {
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFnRef = useRef(validateFn);
validateFnRef.current = validateFn;
const debouncedValidate = useMemo(
() =>
debounce(async (value: T) => {
setIsValidating(true);
try {
const result = await validateFnRef.current(value);
setError(result);
} finally {
setIsValidating(false);
}
}, delay),
[delay],
);
// Cleanup debounce timer on unmount
useEffect(() => () => debouncedValidate.cancel(), [debouncedValidate]);
return { validate: debouncedValidate, isValidating, error };
}
// Usage with React Hook Form
// Validation runs in the onChange handler (not via effects) to avoid
// race conditions and unnecessary re-renders.
function UsernameField() {
const {
register,
setError,
clearErrors,
formState: { errors },
} = useFormContext();
const [isChecking, setIsChecking] = useState(false);
const checkUsername = async (value: string): Promise<string | null> => {
if (!value || value.length < 3) return null;
try {
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(value)}`,
);
if (!response.ok) {
return "Unable to validate username right now";
}
const { available } = (await response.json()) as { available?: boolean };
if (typeof available !== "boolean") {
return "Unable to validate username right now";
}
return available ? null : "This username is already taken";
} catch {
return "Unable to validate username right now";
}
};
const { validate, isValidating, error } = useAsyncValidation(checkUsername);
// Derive combined checking state
const showChecking = isChecking || isValidating;
// Clear the optimistic checking flag when async validation settles.
useEffect(() => {
if (!isValidating) {
setIsChecking(false);
}
}, [isValidating]);
// Reflect async availability result into RHF field error state.
useEffect(() => {
if (isValidating) return;
if (error) {
setError("username", { type: "validate", message: error });
} else {
clearErrors("username");
}
}, [error, isValidating, setError, clearErrors]);
const { onChange: rhfOnChange, ...rest } = register("username", {
onChange: (e) => {
const value = e.target.value;
if (value && value.length >= 3) {
setIsChecking(true);
validate(value);
} else {
setIsChecking(false);
clearErrors("username");
}
},
// Also validate via RHF's built-in async validate for submit-time
validate: async (value) => {
const result = await checkUsername(value);
return result ?? true;
},
});
return (
<div>
<input onChange={rhfOnChange} {...rest} />
{showChecking && (
<span className="text-muted-foreground">Checking...</span>
)}
{errors.username?.message && (
<span className="text-destructive">
{String(errors.username.message)}
</span>
)}
</div>
);
}
Optimistic Updates
Concept: Show immediate feedback while the request is in flight.
import { useTransition, useState } from "react";
type SubmitState = "idle" | "submitting" | "success" | "error";
function ProfileForm() {
const [isPending, startTransition] = useTransition();
const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [optimisticData, setOptimisticData] = useState<ProfileData | null>(
null,
);
async function handleSubmit(data: ProfileData) {
// Immediately show optimistic update
setOptimisticData(data);
setSubmitState("submitting");
startTransition(async () => {
try {
await updateProfile(data);
setSubmitState("success");
// Clear success state after delay
setTimeout(() => setSubmitState("idle"), 2000);
} catch (error) {
// Revert optimistic update
setOptimisticData(null);
setSubmitState("error");
}
});
}
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Show optimistic preview */}
{optimisticData && (
<div className="opacity-70">Preview: {optimisticData.name}</div>
)}
<button type="submit" disabled={isPending}>
{submitState === "submitting" && "Saving..."}
{submitState === "success" && "Saved!"}
{submitState === "error" && "Try Again"}
{submitState === "idle" && "Save Changes"}
</button>
</form>
);
}
4. Complex Component Patterns
Accessible File Upload (Drag & Drop)
Concept: Drag-and-drop zones are often inaccessible. Ensure keyboard and screen reader support.
import { useCallback, useId, useState, useRef } from "react";
interface FileUploadProps {
accept?: string;
maxSize?: number; // bytes
onUpload: (files: File[]) => void;
}
export function AccessibleFileUpload({
accept,
maxSize,
onUpload,
}: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneId = useId();
const errorId = `${dropzoneId}-error`;
const handleFiles = useCallback(
(files: FileList | null) => {
setError(null);
if (!files?.length) return;
const validFiles: File[] = [];
const oversizedFiles: string[] = [];
Array.from(files).forEach((file) => {
if (maxSize && file.size > maxSize) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (oversizedFiles.length) {
setError(
oversizedFiles.length === 1
? `${oversizedFiles[0]} exceeds maximum size`
: `${oversizedFiles.length} files exceed maximum size: ${oversizedFiles.join(", ")}`,
);
}
if (validFiles.length) {
onUpload(validFiles);
}
},
[maxSize, onUpload],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
handleFiles(e.dataTransfer.files);
},
[handleFiles],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
};
return (
<div>
{/* Hidden but accessible file input */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(e) => handleFiles(e.target.files)}
className="sr-only"
id={dropzoneId}
aria-describedby={error ? errorId : undefined}
/>
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 391
- Forks
- 7
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
better-forms- Source
- github.com/asymmetric-al/core