| name | better-forms |
| description | Complete 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. |
| version | 2.1.0 |
Repo notice (Asymmetric-al/core): This repository is Base UI only.
Shared primitives come from @base-ui/react via the shadcn base-maia
style in packages/ui. Ignore any Radix UI guidance below — never add
radix-ui/@radix-ui/* imports or dependencies; composition uses Base
UI's render prop, not asChild. See docs/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.
<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 be text-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 Profile
Tip: 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" and aria-label="Show password".
Field Sizing as Affordance
Concept: The width of the input suggests the expected data length.
- Zip Code:
w-20 or w-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.
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);
}}
/>;
<PatternFormat
format="#### #### #### ####"
customInput={Input}
onValueChange={(values) => form.setValue("cardNumber", values.value)}
/>;
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)[]>([]);
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]);
focusInput = (
{
clampedIndex = .(, .(index, length - ));
inputRefs.[clampedIndex]?.();
},
[length],
);
= () => {
(!.(value)) ;
newValues = [...values];
newValues[index] = value.(-);
(newValues);
(value && index < length - ) {
(index + );
}
code = newValues.();
(code. === length) {
(code);
}
};
= () => {
(e.) {
:
(!values[index] && index > ) {
(index - );
}
;
:
e.();
(index - );
;
:
e.();
(index + );
;
}
};
= () => {
e.();
pastedData = e.
.()
.(, )
.(, length);
(pastedData) {
newValues = [...values];
pastedData.().( {
newValues[i] = char;
});
(newValues);
(pastedData. - );
(pastedData. === length) {
(pastedData);
}
}
};
(
);
}
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?";
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
}
function EditProfileForm() {
const form = useForm<ProfileData>();
const { isDirty } = useFormState({ control: form. });
(isDirty);
;
}
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));
}
function useStepFromURL() {
const [step, setStep] = useState(() => {
if (typeof window === "undefined") return MIN_WIZARD_STEP;
const params = new URLSearchParams(..);
((params.() ?? (), ));
});
goToStep = ( {
clampedStep = (newStep);
(clampedStep);
url = (..);
url..(, (clampedStep));
..({}, , url);
}, []);
{ step, goToStep };
}
() {
headingRef = useRef<>();
( {
headingRef.?.();
}, [step]);
headingRef;
}
{
: ;
: ;
: ;
?: ;
: ;
: ;
}
stepSchemas = {
: z.({ : z.().(), : z.().() }),
: z.({ : z.().(), : z.().() }),
: z.({ : z.().(), : z.().() }),
};
() {
{ step, goToStep } = ();
headingRef = (step);
totalSteps = ;
canUseStorage =
!== &&
. !== ;
form = useForm<>({
: (stepSchemas[step keyof stepSchemas]),
: ,
});
( {
(!canUseStorage) ;
saved = .();
(saved) {
form.(.(saved));
}
}, [canUseStorage, form]);
( {
(!canUseStorage) ;
subscription = form.( {
.(, .(data));
});
subscription.();
}, [canUseStorage, form]);
= () => {
isValid = form.();
(isValid && step < totalSteps) {
(step + );
}
};
= () => {
(step > ) (step - );
};
= () => {
.(, data);
(canUseStorage) {
.();
}
};
= () => (
);
= () => (
);
= () => (
);
(
);
}
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[];
}
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) {
result.errors.forEach((error) => {
form.setError(error. keyof T & , {
: ,
: error.,
});
});
firstErrorField = result.[]?.;
(firstErrorField) {
form.(firstErrorField keyof T & );
}
;
}
};
}
() {
form.(path , { : , 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";
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],
);
( debouncedValidate.(), [debouncedValidate]);
{ : debouncedValidate, isValidating, error };
}
() {
{
register,
setError,
clearErrors,
: { errors },
} = ();
[isChecking, setIsChecking] = ();
checkUsername = (: ): < | > => {
(!value || value. < ) ;
{
response = (
,
);
(!response.) {
;
}
{ available } = ( response.()) { ?: };
( available !== ) {
;
}
available ? : ;
} {
;
}
};
{ validate, isValidating, error } = (checkUsername);
showChecking = isChecking || isValidating;
( {
(!isValidating) {
();
}
}, [isValidating]);
( {
(isValidating) ;
(error) {
(, { : , : error });
} {
();
}
}, [error, isValidating, setError, clearErrors]);
{ : rhfOnChange, ...rest } = (, {
: {
value = e..;
(value && value. >= ) {
();
(value);
} {
();
();
}
},
: (value) => {
result = (value);
result ?? ;
},
});
(
);
}
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) {
setOptimisticData(data);
setSubmitState("submitting");
startTransition(async () => {
try {
await updateProfile(data);
setSubmitState("success");
setTimeout(() => setSubmitState("idle"), 2000);
} catch (error) {
setOptimisticData();
();
}
});
}
(
);
}
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;
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[] = [];
: [] = [];
.(files).( {
(maxSize && file. > maxSize) {
oversizedFiles.(file.);
;
}
validFiles.(file);
});
(oversizedFiles.) {
(
oversizedFiles. ===
?
: ,
);
}
(validFiles.) {
(validFiles);
}
},
[maxSize, onUpload],
);
handleDrop = (
{
e.();
();
(e..);
},
[handleFiles],
);
= () => {
(e. === || e. === ) {
e.();
inputRef.?.();
}
};
(
);
}
Accessible Combobox (Searchable Select)
Concept: Native <select> is limited. Use a proper combobox pattern for search/filter.
import { useState, useRef, useId, KeyboardEvent } from "react";
interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder,
}: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const filteredOptions = options.filter(
opt..().(query.()),
);
selectedOption = options.( opt. === value);
= () => {
(option.);
();
();
inputRef.?.();
};
= () => {
(e.) {
:
e.();
(!isOpen) {
();
} {
(
.(prev + , filteredOptions. - ),
);
}
;
:
e.();
( .(prev - , ));
;
:
e.();
(activeIndex >= && filteredOptions[activeIndex]) {
(filteredOptions[activeIndex]);
}
;
:
();
();
;
}
};
(
);
}
Date Picker Strategy
Concept: Choose the right approach based on use case and accessibility needs.
<input
type="date"
min="2024-01-01"
max="2025-12-31"
className="px-3 py-2 border rounded-md"
/>
function BirthdatePicker({ value, onChange }: DatePickerProps) {
const [month, day, year] = value ? value.split("-") : ["", "", ""];
return (
<fieldset>
<legend className="text-sm font-medium mb-2">Date of Birth</legend>
<div className="flex gap-2">
<select
aria-label="Month"
value={month}
onChange={(e) => onChange(`${e.target.value}-${day}-${year}`)}
>
<option value="">Month</option>
{months.map((m) => < = =>{m.label})}
Day
{Array.from({ length: 31 }, (_, i) => (
{i + 1}
))}
Year
{years.map((y) => {y})}
);
}
5. Accessibility Deep Dive
Reduced Motion Support
Concept: Respect user preferences for reduced animations.
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}
function ErrorMessage({ message }: { message: string }) {
const prefersReducedMotion = usePrefersReducedMotion();
return (
<
=
=
" ",
! && "", //
)}
>
{message}
);
}
. = {
: {
: {
: {
: {
: { : },
: { : },
: { : },
},
},
: {
: ,
},
},
},
};
Forced Colors (High Contrast Mode)
Concept: Windows High Contrast mode removes background colors. Borders become critical.
<input
className={cn(
"border rounded-md",
error && "border-destructive",
"forced-colors:border-[CanvasText]",
error && "forced-colors:border-[Mark]"
)}
/>
<CheckIcon
className="text-success forced-colors:text-[Highlight]"
aria-hidden="true"
/>
{error && (
<span className="flex items-center gap-1 text-destructive">
<AlertIcon className="h-4 w-4 forced-colors:text-[Mark]" aria-hidden="true" />
<span>{error}</span> {/* Text is always readable */}
</span>
)}
Live Regions for Global Feedback
Concept: Announce form success/error to screen readers using aria-live regions.
import { createContext, useContext, useState, useCallback } from "react";
interface Announcement {
message: string;
type: "polite" | "assertive";
}
const AnnouncerContext = createContext<{
announce: (message: string, type?: "polite" | "assertive") => void;
} | null>(null);
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
const announce = useCallback(
(message: string, type: "polite" | "assertive" = "polite") => {
setAnnouncement(null);
requestAnimationFrame(() => {
setAnnouncement({ message, type });
});
},
[],
);
return (
);
}
() {
context = ();
(!context)
();
context.;
}
() {
announce = ();
() {
{
(data);
(
,
,
);
} (error) {
(
,
,
);
}
}
}
{ toast } ;
toast.(, {
: ,
});
toast.(, {
: ,
});
6. Testing & Documentation
Unit Testing with React Testing Library
Concept: Test user interactions, not implementation details.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";
describe("ContactForm", () => {
it("shows validation errors on submit with empty fields", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(
/email is required/i,
);
});
it("submits successfully with valid data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(), );
user.(screen.(), );
user.(screen.(, { : }));
( {
(onSubmit).({
: ,
: ,
});
});
});
(, () => {
user = userEvent.();
slowSubmit = vi.( ( (r, )));
();
user.(screen.(), );
user.(screen.(), );
submitButton = screen.(, { : });
user.(submitButton);
(submitButton).();
(submitButton).();
});
(, () => {
user = userEvent.();
failingSubmit = vi.().( ());
();
user.(screen.(), );
user.(screen.(), );
user.(screen.(, { : }));
( screen.()).(
,
);
});
(, () => {
user = userEvent.();
();
user.();
(screen.()).();
user.();
(screen.()).();
user.();
(screen.(, { : })).();
});
});
Storybook Documentation
Concept: Document all form component states for design system consistency.
import type { Meta, StoryObj } from "@storybook/react";
import { SmartInput } from "./SmartInput";
const meta: Meta<typeof SmartInput> = {
title: "Forms/SmartInput",
component: SmartInput,
parameters: {
docs: {
description: {
component:
"Accessible input component with built-in label, description, error handling, and password toggle.",
},
},
},
argTypes: {
type: {
control: "select",
options: ["text", "email", "password", "tel", "url"],
},
error: { control: "text" },
description: { control: "text" },
isOptional: { control: "boolean" },
disabled: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj< >;
: = {
: {
: ,
: ,
},
};
: = {
: {
: ,
: ,
: ,
},
};
: = {
: {
: ,
: ,
: ,
},
};
: = {
: {
: ,
: ,
: ,
},
};
: = {
: {
: ,
: ,
: ,
},
};
: = {
: {
: ,
: ,
: ,
},
};
: = {
: (
),
};
: = {
: (
),
};
: = {
: (
),
: {
: {
: {
: [
{ : , : },
{ : , : },
],
},
},
},
};
7. Accessibility & Validation (Modern Stack)
Integration with React Hook Form & Zod
Don't rely on browser defaults alone. Connect library state to ARIA attributes.
<input
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>;
{
errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
);
}
Mobile Optimization
- Input Modes: Critical for triggering the right keyboard on iOS/Android.
- Numbers (codes):
inputMode="numeric" pattern="[0-9]*"
- Email:
inputMode="email"
- Search:
inputMode="search" (adds "Go" button)
- Touch Targets: Min
44px height (h-11 in Tailwind default config usually works well).
8. Component Implementation Recipe
Here is a shadcn/ui style Field component that implements these principles automatically.
import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
interface SmartInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
ref?: React.Ref<HTMLInputElement>;
label: string;
error?: string;
description?: string;
isOptional?: boolean;
widthClass?: string;
}
export const SmartInput = ({
ref,
label,
error,
description,
isOptional,
widthClass = "w-full",
className,
type = "text",
...props
}: SmartInputProps) => {
const id = useId();
const descriptionId = `${id}-desc`;
const errorId = `${id}-error`;
const [showPassword, setShowPassword] = ();
isPassword = === ;
inputType = isPassword ? (showPassword ? : ) : ;
(
);
};
Checklist for Review
Layout & UX
Accessibility & Code
Performance & Backend
Testing & Documentation