Inline per-field validation where the bottom border diffuses an error tint in from the exact offending character, like litmus paper — slow and pale while an async check is still pending, fast and decisive once it's definitely wrong, and it wicks back out the moment the field is fixed.
npx shadcn add https://design.helpmarq.com /r/validation-inline-wick.jsonregistry/core/validation-inline-wick/component.tsx"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
} from "react";
// ---------------------------------------------------------------------------
// LitmusWick — inline per-field validation where the field's bottom border
// behaves like litmus paper. A validator either resolves synchronously
// ("definitely wrong", fast decisive soak) or returns a Promise ("still
// checking", slow tentative soak that pulses while pending). Either way the
// error tint doesn't flash on as a flat colored border — it diffuses in from
// the exact x-position of the character that broke the pattern (measured via
// an off-screen mirror span) and, once fixed, wicks back out and converges
// on that same point. Two registered custom properties (--validation-inline-wick-left/
// -right, percentages) drive the stop positions of the wick div's own
// mask-image, so a CSS transition on those properties genuinely animates the
// diffusion front with real easing rather than a border-color crossfade —
// unregistered custom properties can't be transitioned at all, which is
// exactly why @property is load-bearing here, not decorative.
//
// The wick is pure decoration (aria-hidden): the real, assistive-tech-facing
// surface is a plain native <input> with aria-invalid and an aria-describedby
// message naming the position ("Character 4: space not allowed"), living in
// one aria-live=polite paragraph that also carries async "still checking" /
// resolution announcements. Keyboard flow is an untouched plain input — no
// key interception anywhere.
//
// Deliberately unlike approval-inline-diff: this is per-field and positional (the tint
// originates under the offending character and the field stays live/editable
// the whole time), not a form-level approve/deny gate that collapses once
// and never reopens.
// ---------------------------------------------------------------------------
export type LitmusOutcome =
| { valid: true }
| { valid: false; index: number; reason: string };
export type LitmusStatus = "idle" | "checking" | "valid" | "invalid";
export interface LitmusWickProps {
label: string;
name?: string;
id?: string;
placeholder?: string;
/** uncontrolled initial value */
defaultValue?: string;
/** controlled value; omit for uncontrolled */
value?: string;
onValueChange?: (value: string) => void;
/**
* Return synchronously for an instantly-known rule ("definitely wrong" —
* fast, decisive diffusion) or return a Promise for anything that has to
* ask elsewhere first (uniqueness, server-side rules — "still checking",
* slower, tentative, pulsing diffusion until it settles).
*/
validate?: (value: string) => LitmusOutcome | Promise<LitmusOutcome>;
/** ms after the last keystroke before validate runs. Default 260. */
debounceMs?: number;
required?: boolean;
disabled?: boolean;
autoComplete?: string;
className?: string;
}
// ease-out-expo-shaped — same curve approval-inline-diff's collapse and truncation-taper-fade's
// spring both use, kept consistent across the registry rather than inventing
// a new feel for the same "settle" grammar.
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
const CHECK_HALF_SPAN = 16; // % either side of origin while "still checking"
function isPromiseLike(v: unknown): v is Promise<LitmusOutcome> {
return (
!!v &&
(typeof v === "object" || typeof v === "function") &&
typeof (v as { then?: unknown }).then === "function"
);
}
// Measures where the offending character sits, in percent of the input's
// own box, using a hidden mirror span that copies the input's real computed
// font — the only reliable way to turn a character index into an x position
// without a canvas.
function measureOriginPercent(
input: HTMLInputElement,
mirror: HTMLSpanElement,
text: string,
index: number
): number {
const cs = getComputedStyle(input);
mirror.style.font = cs.font;
mirror.style.letterSpacing = cs.letterSpacing;
const clamped = Math.max(0, Math.min(index, text.length));
mirror.textContent = text.slice(0, clamped);
const prefixWidth = mirror.offsetWidth;
mirror.textContent = text.slice(0, clamped) + (text[clamped] ?? " ");
const throughWidth = mirror.offsetWidth;
const charWidth = Math.max(1, throughWidth - prefixWidth);
const paddingLeft = parseFloat(cs.paddingLeft) || 0;
const total = input.clientWidth || 1;
const centerPx = paddingLeft + prefixWidth + charWidth / 2;
return Math.max(0, Math.min(100, (centerPx / total) * 100));
}
export function LitmusWick({
label,
name,
id,
placeholder,
defaultValue = "",
value,
onValueChange,
validate,
debounceMs = 260,
required,
disabled,
autoComplete,
className = "",
}: LitmusWickProps) {
const uid = useId();
const inputId = id ?? `litmus-${uid}`;
const msgId = `litmus-msg-${uid}`;
const inputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLSpanElement>(null);
const requestIdRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isControlled = value !== undefined;
const [inner, setInner] = useState(defaultValue);
const current = isControlled ? (value as string) : inner;
const [status, setStatus] = useState<LitmusStatus>("idle");
const [message, setMessage] = useState("");
const [origin, setOrigin] = useState(50);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function settle(
id: number,
text: string,
outcome: LitmusOutcome,
wasAsync: boolean
) {
// A newer keystroke already superseded this in-flight check — a stale
// async result must never overwrite what the user is looking at now.
if (id !== requestIdRef.current) return;
if (outcome.valid) {
setStatus("valid");
// Only the async path gets an explicit "it's fine" announcement —
// that's the one case where the user was genuinely waiting on a
// result. A sync rule quietly clearing on every valid keystroke would
// just be aria-live noise while someone types normally.
setMessage(wasAsync ? "Looks good." : "");
return;
}
const input = inputRef.current;
const mirror = mirrorRef.current;
if (input && mirror) {
setOrigin(measureOriginPercent(input, mirror, text, outcome.index));
}
setStatus("invalid");
setMessage(`Character ${outcome.index + 1}: ${outcome.reason}`);
}
function runValidate(text: string) {
const id = ++requestIdRef.current;
if (!validate) return;
const result = validate(text);
if (isPromiseLike(result)) {
// Origin while pending: the end of what's currently typed — an async
// check (uniqueness, server rule) judges the value as a whole, not one
// character, so the tentative blot grows from the caret's neighborhood
// rather than claiming to already know where a fault is.
const input = inputRef.current;
const mirror = mirrorRef.current;
if (input && mirror) {
setOrigin(measureOriginPercent(input, mirror, text, text.length));
}
setStatus("checking");
setMessage("Checking…");
result.then(
(o) => settle(id, text, o, true),
() =>
settle(
id,
text,
{ valid: false, index: Math.max(0, text.length - 1), reason: "check failed" },
true
)
);
} else {
settle(id, text, result, false);
}
}
function handleChange(next: string) {
if (!isControlled) setInner(next);
onValueChange?.(next);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (next === "") {
requestIdRef.current++; // invalidates any in-flight async check
setStatus("idle");
setMessage("");
return;
}
debounceRef.current = setTimeout(() => runValidate(next), debounceMs);
}
const left =
status === "invalid"
? 0
: status === "checking"
? Math.max(0, origin - CHECK_HALF_SPAN)
: origin;
const right =
status === "invalid"
? 100
: status === "checking"
? Math.min(100, origin + CHECK_HALF_SPAN)
: origin;
const wickStyle = {
"--validation-inline-wick-left": `${left}%`,
"--validation-inline-wick-right": `${right}%`,
backgroundColor: "var(--error, #ea001d)",
} as CSSProperties;
const messageStyle: CSSProperties | undefined =
status === "invalid" ? { color: "var(--error, #ea001d)" } : undefined;
return (
<div className={className}>
<style>{`
@property --validation-inline-wick-left {
syntax: '<percentage>';
inherits: false;
initial-value: 50%;
}
@property --validation-inline-wick-right {
syntax: '<percentage>';
inherits: false;
initial-value: 50%;
}
.ns-validation-inline-wick {
transition:
--validation-inline-wick-left 480ms ${EASE},
--validation-inline-wick-right 480ms ${EASE},
opacity 480ms ease;
opacity: 1;
mask-image: linear-gradient(
to right,
transparent 0%, transparent var(--validation-inline-wick-left),
black var(--validation-inline-wick-left), black var(--validation-inline-wick-right),
transparent var(--validation-inline-wick-right), transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%, transparent var(--validation-inline-wick-left),
black var(--validation-inline-wick-left), black var(--validation-inline-wick-right),
transparent var(--validation-inline-wick-right), transparent 100%
);
}
.ns-validation-inline-wick[data-phase="checking"] {
transition-duration: 1100ms;
opacity: .6;
animation: ns-litmus-pulse 900ms ease-in-out infinite;
}
.ns-validation-inline-wick[data-phase="invalid"] {
transition-duration: 420ms;
opacity: 1;
}
@keyframes ns-litmus-pulse {
0%, 100% { opacity: .4; }
50% { opacity: .72; }
}
@media (prefers-reduced-motion: reduce) {
.ns-validation-inline-wick {
transition: none;
}
.ns-validation-inline-wick[data-phase="checking"] {
animation: none;
opacity: .6;
}
}
`}</style>
<label
htmlFor={inputId}
className="block font-mono text-[11px] uppercase tracking-wide text-muted"
>
{label}
</label>
<div className="group relative mt-1.5">
<input
ref={inputRef}
id={inputId}
name={name}
type="text"
placeholder={placeholder}
required={required}
disabled={disabled}
autoComplete={autoComplete}
value={current}
onChange={(e) => handleChange(e.target.value)}
aria-invalid={status === "invalid"}
aria-describedby={msgId}
className="w-full bg-transparent px-0 py-2 text-sm text-foreground outline-none placeholder:text-muted disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
/>
{/* baseline border — the field's resting bottom edge */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-border transition-colors group-hover:bg-foreground/25"
/>
{/* the wick — decorative only, masked to the diffused span */}
<div
aria-hidden
data-phase={status}
className="ns-validation-inline-wick pointer-events-none absolute inset-x-0 bottom-0 h-[2px]"
style={wickStyle}
/>
{/* off-screen mirror: measures the x-position of a given character
index against the input's real computed font. Never read by AT
(empty at rest, visibility hidden, not part of layout flow). */}
<span
ref={mirrorRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0 -z-10 whitespace-pre"
style={{ visibility: "hidden" }}
/>
</div>
<p
id={msgId}
aria-live="polite"
className="mt-1.5 min-h-[1em] font-mono text-xs text-muted"
style={messageStyle}
>
{message}
</p>
</div>
);
}
a text field whose error state must point at the specific character that broke a rule, and must read differently while an async check is still pending versus once it's definitively wrong — pick input-focus-membrane instead for a general-purpose organic focus ring that only distinguishes idle/focus/error/valid-submit, not a per-character fault position or a pending state.
A form field primitive whose bottom border is the litmus paper. Render a real, fully native <input type=text> — plain keyboard flow, untouched selection/autofill, standard controlled/uncontrolled value handling — stacked with two absolutely positioned 2px-tall div overlays at its bottom edge: a permanent baseline strip painted in the border token (the field's resting look, brightening slightly to a foreground-tinted hover state) and, on top of it, a decorative aria-hidden 'wick' div painted solid in the error token whose visibility is entirely controlled by its own mask-image. Two CSS custom properties, --validation-inline-wick-left and --validation-inline-wick-right (percentages), are registered via @property with syntax '<percentage>' specifically so they are animatable — an unregistered custom property cannot be transitioned by the browser at all, it just snaps, which is why @property is load-bearing here rather than decorative. The wick div's mask-image is a linear-gradient with hard stops built from those two properties (transparent, then black between left and right, then transparent again), so a CSS transition on the two properties alone drives real eased motion of the diffusion front with zero JS animation loop. A consumer supplies a validate(value) function that either returns a LitmusOutcome synchronously ('definitely wrong', known instantly — e.g. a regex or character-class rule) or returns a Promise<LitmusOutcome> ('still checking' — e.g. an async uniqueness or server-side check); a request-id ref guards against a stale async result landing after a newer keystroke superseded it. On an invalid outcome, an off-screen mirror <span> that copies the input's real computed font (font shorthand, letter-spacing) measures the pixel width of the value up to the offending character index, converts that to a percent of the input's own box accounting for its left padding, and that becomes the diffusion's origin — both --validation-inline-wick-left and --validation-inline-wick-right start collapsed at that single point and animate outward from it, never from the field's edge or center, so the stain visibly originates under the specific character that failed. Three read states, told apart by speed and extent, not just color: checking pulls the two edges out to a modest +-16% band around the origin over 1100ms, at 60% opacity with a slow breathing pulse animation layered on top — tentative, still-working, deliberately not committing to full coverage; invalid snaps the edges to the full 0%-100% width over a comparably fast 420ms at full opacity, no pulse — decisive, this is the daily-driver replacement for an instant red border flash, so it is still an eased diffusion, just a quick one, never a hard cut; valid (or the field emptied) collapses both edges back to wherever the origin last was, over 480ms, so the stain visibly recedes and converges on the exact point it grew from rather than shrinking from the field's outer edges. The wick carries zero semantic weight — aria-hidden throughout. The real, assistive-tech-facing state lives entirely on the plain input: aria-invalid mirrors whether the current outcome is invalid, and aria-describedby always points at one paragraph that both names the fault by position in plain language ('Character 4: space not allowed', 1-indexed for a human reader) and is itself aria-live=polite, so an async result — valid or invalid — is announced the moment it resolves; a sync rule resolving valid on an ordinary correct keystroke deliberately stays silent rather than spamming 'looks good' on every character, while an async check that does resolve valid announces 'Looks good.' once, because that is a result the user was genuinely waiting on. Differs from approval-inline-diff on purpose: approval-inline-diff is a form-level, one-shot, irreversible approve/deny gate over a whole payload; validation-inline-wick is per-field, positional, and fully reversible — the same field keeps validating, soaking and healing, for as long as the user keeps typing in it, with no terminal state at all. prefers-reduced-motion sets --validation-inline-wick-left/-right with transition:none so every state (checking's partial band, invalid's full soak, valid's collapse) still lands at its correct final extent instantly, with no animated diffusion and no pulse keyframe, so the field stays fully legible and usable either way. No canvas, no SVG — pure DOM and CSS, colors drawn only from --border, --foreground (hover only, low alpha), --accent (the input's own focus-visible outline, interaction-only) and the semantic error token (var(--error, #ea001d), matching this registry's existing convention for status color — stepper-needle, toast-gravity-stack, input-focus-membrane, sparkline-automaton all resolve the same token the same way since it isn't in globals.css yet), never a gradient wash between border and error — the visible color at any point is always one or the other, solid, never blended.