Skip to main content

better-forms

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.

Source facts

Repository
Asymmetric-al/core
Last source activity
October 1, 2026 at 01:53
Detected SKILL.md language
English
Stars
373
Forks
7

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub