better-forms

SkillMonitoring & ops

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.

Available today. Use it from your connected AI after setup.

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/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.

// 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.

<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.

// 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