---
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
---
# 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
```
### 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
```
### 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.
```tsx
// Phone number formatting with react-number-format
import { PatternFormat } from "react-number-format";
{
// values.value = "1234567890" (raw)
// values.formattedValue = "(123) 456-7890"
form.setValue("phone", values.value);
}}
/>;
// Credit card with automatic spacing
form.setValue("cardNumber", values.value)}
/>;
// Currency input
import { NumericFormat } from "react-number-format";
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 { 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(Array(length).fill(""));
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
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) => {
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 (