- 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.
```tsx
// 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 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.
```tsx
<Label>
Phone Number{" "}
<span className="text-muted-foreground text-sm font-normal">(Optional)</span>
</Label>
```
### Show/Hide Password <!-- pragma: allowlist secret -->
**Concept**: Masking passwords by default prevents error correction. <!-- pragma: allowlist secret -->
**Implementation**: Always include a toggle button inside the input wrapper.
- **A11y**: The toggle button must have `type="button"` and `aria-label="Show password"`. <!-- pragma: allowlist secret -->
### 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.
```tsx
// 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.
```tsx
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.
```tsx
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.
```tsx
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 =
View on GitHub