## Inline Validation — Input Error message that does not shove the form. Docs: https://www.interior.dev/docs/inline-validation Reference: https://www.interior.dev/reference/inline-validation License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/inline-validation.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { InlineValidation } from "@/components/interior/inline-validation"; const checkEmail = (v: string) => { if (v.trim() === "") return "A work email is required."; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "That is not a complete email address."; return null; }; export function InviteForm({ onInvite }: { onInvite: (email: string) => void }) { const [email, setEmail] = useState(""); return (
{ e.preventDefault(); if (checkEmail(email)) return; onInvite(email); }} > ); } ``` ### Props - `label` (`string`) Visible label, wired to the input with htmlFor. - `value` (`string`) Controlled value. The field owns no text of its own. - `onChange` (`(value: string) => void`) Receives the raw input value on every keystroke. - `validate` (`(value: string) => string | null`) Pure check. Return the message to show, or null when the value is acceptable. - `hint` (`string`) — default: `undefined` Resting help text. Shares one grid cell with the error and crossfades out when the error takes over. - `debounce` (`number`) — default: `400` Milliseconds a still-wrong value waits before the message updates. Clearing is never debounced. - `reserveLines` (`number`) — default: `1` Lines of message space reserved up front. Longer messages clamp instead of growing the field. - `type` (`"text" | "email" | "password" | "tel" | "url" | "search"`) — default: `"text"` Native input type. - `required` (`boolean`) — default: `false` Sets required and aria-required. The message still comes from validate. - `disabled` (`boolean`) — default: `false` Disables the input and dims the whole field. - `id` (`string`) — default: `undefined` Overrides the generated input id. Hint and error ids are always derived from useId, so they are stable across server and client. - `className` (`string`) — default: `""` Appended last to the field wrapper. - `useInlineValidation` (`(opts: { value: string; validate: Validator; debounce?: number }) => UseInlineValidationReturn`) The behaviour without the chrome: status, error, message, touched, commit, reset and fieldProps for your own markup. ### Behavior notes - The message slot is measured and reserved before anything is wrong, so an error arriving never pushes the next field, the footer or the submit button down the page. - Validation waits for the first blur; a field you have not finished with is never told it is wrong halfway through the first word. - After that first blur a value that becomes correct clears the message immediately, while a value that is still wrong waits out the debounce, so the text under the input cannot flicker once per keystroke. - Hint and error occupy the same grid cell and only opacity and three pixels of travel move between them, so swapping one for the other cannot change the row's width or height. - A long message clamps inside its reserved lines rather than animating to an unbounded height, so no validator can make the form taller than it declared it would be. - The announcement lives in one polite region carrying only the settled message, so a screen reader hears the error once instead of on every keypress, and under prefers-reduced-motion the message and the status glyph arrive at full opacity with no travel. ### Source (`components/interior/inline-validation.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const INSTANT = { duration: 0 } as const; const LINE = 16; export type ValidationStatus = "idle" | "pending" | "valid" | "invalid"; export type Validator = (value: string) => string | null; export type UseInlineValidationOptions = { value: string; validate: Validator; debounce?: number; }; export type UseInlineValidationReturn = { status: ValidationStatus; error: string | null; message: string; touched: boolean; commit: () => void; reset: () => void; fieldProps: { onBlur: () => void; "aria-invalid": boolean; }; }; type Settled = { status: ValidationStatus; error: string | null; message: string; }; const CLEAN: Settled = { status: "idle", error: null, message: "" }; export function useInlineValidation({ value, validate, debounce = 400, }: UseInlineValidationOptions): UseInlineValidationReturn { const [touched, setTouched] = useState(false); const [settled, setSettled] = useState(CLEAN); const check = useRef(validate); const latest = useRef(value); useEffect(() => { check.current = validate; latest.current = value; }); useEffect(() => { if (!touched) return; const next = check.current(value); const resolved: ValidationStatus = value.length > 0 ? "valid" : "idle"; if (next === null) { setSettled((prev) => prev.status === resolved && prev.error === null ? prev : { status: resolved, error: null, message: prev.message }, ); return; } setSettled((prev) => prev.status === "invalid" ? prev : { status: "pending", error: null, message: prev.message }, ); const t = setTimeout(() => { setSettled((prev) => prev.error === next ? prev : { status: "invalid", error: next, message: next }, ); }, debounce); return () => clearTimeout(t); }, [value, touched, debounce]); const commit = useCallback(() => { setTouched(true); const v = latest.current; const next = check.current(v); setSettled((prev) => next === null ? { status: v.length > 0 ? "valid" : "idle", error: null, message: prev.message } : { status: "invalid", error: next, message: next }, ); }, []); const reset = useCallback(() => { setTouched(false); setSettled(CLEAN); }, []); return { status: settled.status, error: settled.error, message: settled.message, touched, commit, reset, fieldProps: { onBlur: commit, "aria-invalid": settled.status === "invalid" }, }; } export type InlineValidationProps = { label: string; value: string; onChange: (value: string) => void; validate: Validator; hint?: string; id?: string; name?: string; type?: "text" | "email" | "password" | "tel" | "url" | "search"; placeholder?: string; autoComplete?: string; inputMode?: React.ComponentProps<"input">["inputMode"]; debounce?: number; reserveLines?: number; disabled?: boolean; required?: boolean; className?: string; }; export function InlineValidation({ label, value, onChange, validate, hint, id, name, type = "text", placeholder, autoComplete, inputMode, debounce = 400, reserveLines = 1, disabled = false, required = false, className = "", }: InlineValidationProps) { const reduced = useReducedMotion(); const fade = reduced ? INSTANT : CROSSFADE; const auto = useId(); const fieldId = id ?? `${auto}-field`; const hintId = `${auto}-hint`; const errorId = `${auto}-error`; const { status, error, message, fieldProps } = useInlineValidation({ value, validate, debounce, }); const invalid = status === "invalid"; const valid = status === "valid"; const described = [hint ? hintId : null, invalid ? errorId : null] .filter(Boolean) .join(" "); const clamp = { display: "-webkit-box" as const, WebkitBoxOrient: "vertical" as const, WebkitLineClamp: reserveLines, overflow: "hidden" as const, }; return (
onChange(e.target.value)} {...fieldProps} className={`h-10 w-full rounded-[10px] border-2 pl-3 pr-9 text-[13px] text-stone-700 outline-none transition-[background-color,border-color,box-shadow] duration-150 placeholder:text-stone-400 focus-visible:outline-none disabled:opacity-50 dark:text-stone-200 dark:placeholder:text-stone-500 ${ invalid ? "border-red-500 bg-white dark:border-red-400 dark:bg-[#1D1D1A]" : "border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] focus:border-[#4568FF] focus:bg-white focus:shadow-none dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)] dark:focus:border-[#93B0FF] dark:focus:bg-[#252522]" }`} />
{hint ? ( {hint} ) : null} {error ?? message} {hint ? ( {hint} ) : null} {error ?? ""}
); } ```