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
Fix 2 things to continue
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.