Skip to content
All Taste components

Forms

Inline validation

A summary of what to fix at the top of a form, linked to each field’s own message.

error summary · field error · field valid

We’ll send invoices here.
Enter all 6 digits, like 560038.
14 of 15 characters — one is missing.
"use client";
import { useRef, useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { cn } from "@/lib/utils";

type Errors = Partial<Record<"pin" | "gstin", string>>;

function validate(data: FormData): Errors {
  const e: Errors = {};
  const pin = String(data.get("pin") ?? "");
  const gstin = String(data.get("gstin") ?? "");
  if (!/^[0-9]{6}$/.test(pin)) e.pin = "Enter all 6 digits, like 560038.";
  if (gstin && gstin.length !== 15) e.gstin = gstin.length + " of 15 characters — check your GST certificate.";
  return e;
}

const LABELS = { pin: "PIN code needs 6 digits", gstin: "GSTIN should be 15 characters" };

export function ValidatedForm() {
  const [errors, setErrors] = useState<Errors>({});
  const summaryRef = useRef<HTMLDivElement>(null);
  const keys = Object.keys(errors) as (keyof Errors)[];

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const next = validate(new FormData(e.currentTarget));
    setErrors(next);
    if (Object.keys(next).length) requestAnimationFrame(() => summaryRef.current?.focus());
  }

  return (
    <form noValidate onSubmit={onSubmit} className="grid max-w-sm gap-4">
      {keys.length > 0 && (
        <div ref={summaryRef} tabIndex={-1} role="alert" className="flex items-start gap-2.5 rounded bg-danger-soft px-3.5 py-3 text-[13.5px] text-danger-fg outline-none">
          <AlertTriangle className="mt-px size-[18px] shrink-0" aria-hidden />
          <div>
            <p className="font-medium">Fix {keys.length} {keys.length === 1 ? "thing" : "things"} to continue</p>
            <ul className="mt-1 list-disc pl-[18px]">
              {keys.map((k) => <li key={k}><a href={"#" + k} className="underline underline-offset-[3px]">{LABELS[k]}</a></li>)}
            </ul>
          </div>
        </div>
      )}

      <Field name="email" label="Email" type="email" ok="We’ll send invoices here." />
      <Field name="pin" label="PIN code" inputMode="numeric" error={errors.pin} />
      <Field name="gstin" label="GSTIN" error={errors.gstin} />

      <button type="submit" className="h-9 rounded bg-primary px-3.5 text-sm font-medium text-primary-fg hover:bg-primary-hover">Continue</button>
    </form>
  );
}

function Field({ name, label, error, ok, ...props }:
  React.InputHTMLAttributes<HTMLInputElement> & { name: string; label: string; error?: string; ok?: string }) {
  const note = error ?? ok;
  return (
    <div className="grid gap-1.5">
      <label htmlFor={name} className="text-[13px] font-medium text-fg">{label}</label>
      <input
        id={name}
        name={name}
        aria-invalid={error ? true : undefined}
        aria-describedby={note ? name + "-msg" : undefined}
        className={cn(
          "h-9 w-full rounded border border-border-strong bg-bg px-3 text-sm text-fg outline-none transition",
          "focus:border-primary focus:ring-[3px] focus:ring-primary/20",
          "aria-[invalid=true]:border-danger aria-[invalid=true]:ring-[3px] aria-[invalid=true]:ring-danger/15",
        )}
        {...props}
      />
      {note && (
        <p id={name + "-msg"} className={cn("flex items-start gap-1.5 text-xs", error ? "text-danger" : "text-success-fg")}>
          {error ? <AlertTriangle className="mt-px size-3.5 shrink-0" aria-hidden /> : <Check className="mt-px size-3.5 shrink-0" aria-hidden />}
          {note}
        </p>
      )}
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.