Field Valid Check
A small mark strokes itself in at the right edge of a field the moment its value becomes acceptable.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Field Valid Check
*
* The quiet half of validation: the moment a field's value becomes
* acceptable, a tick strokes itself in at the right edge and a soft
* ring settles over the border. Nothing is announced, nothing shouts.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `label`, `isValid`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FieldValidCheckProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. */
label?: string;
/** Placeholder for the empty field. */
placeholder?: string;
/** Drive the field from outside. Omit and it holds its own value. */
value?: string;
/** Starting value when the field holds its own. */
defaultValue?: string;
/** Decide acceptability. Defaults to a simple address shape. */
isValid?: (value: string) => boolean;
/** Fires whenever acceptability flips. */
onValidChange?: (valid: boolean) => void;
};
type VariantConfig = {
/** Spring behind the mark appearing. */
spring: { type: "spring"; stiffness: number; damping: number };
/** How small the mark starts. */
scaleFrom: number;
/** How long the tick takes to stroke itself in. */
drawDuration: number;
/** How long the border ring takes to settle on. */
ringDuration: number;
};
// The mark is 16px across, so its spring can be quick and still land
// softly — every ratio here is at or above 0.8. Variants differ in how
// far the mark travels into place, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// No travel to speak of: the mark simply resolves.
subtle: {
spring: { type: "spring", stiffness: 620, damping: 44 },
scaleFrom: 0.85,
drawDuration: 0.18,
ringDuration: 0.24,
},
default: {
spring: { type: "spring", stiffness: 520, damping: 38 },
scaleFrom: 0.62,
drawDuration: 0.22,
ringDuration: 0.28,
},
// A longer approach — for one-off fields where the pass is the news.
playful: {
spring: { type: "spring", stiffness: 440, damping: 36 },
scaleFrom: 0.4,
drawDuration: 0.26,
ringDuration: 0.32,
},
};
const VALID = "#10B981";
const FIELD_HEIGHT = 42;
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_SHAPE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
export default function FieldValidCheck({
variant = "default",
label = "Billing email",
placeholder = "name@company.com",
value,
defaultValue = "",
isValid,
onValidChange,
}: FieldValidCheckProps) {
const [held, setHeld] = useState(defaultValue);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const controlled = value !== undefined;
const current = controlled ? value : held;
const valid = isValid ? isValid(current) : DEFAULT_SHAPE.test(current.trim());
// The callback lives in a ref so an inline arrow from the parent can't
// re-fire the effect on every render of the form around it.
const onValidChangeRef = useRef(onValidChange);
useEffect(() => {
onValidChangeRef.current = onValidChange;
}, [onValidChange]);
useEffect(() => {
onValidChangeRef.current?.(valid);
}, [valid]);
return (
<div style={{ width: 300 }}>
<label
style={{
display: "block",
fontSize: 11.5,
opacity: 0.55,
marginBottom: 5,
}}
>
{label}
</label>
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
height: FIELD_HEIGHT,
borderRadius: 10,
background: tone(4),
border: `1px solid ${tone(14)}`,
}}
>
{/* The border "changes color" by fading a ring over it rather than
tweening the real border: the change stays on the compositor
and lands on the exact hue instead of drifting through gray. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: valid ? 1 : 0 }}
transition={{ duration: cfg.ringDuration, ease: "easeOut" }}
style={{
position: "absolute",
inset: -1,
borderRadius: 10,
border: `1px solid ${VALID}`,
pointerEvents: "none",
}}
/>
<input
type="email"
inputMode="email"
value={current}
readOnly={controlled}
onChange={(event) => setHeld(event.target.value)}
placeholder={placeholder}
aria-invalid={current.length > 0 && !valid}
style={{
flex: 1,
minWidth: 0,
height: "100%",
// The slot on the right is reserved whether or not the mark
// is showing, so arriving at a valid value never re-flows the
// text the user just typed.
padding: "0 34px 0 12px",
fontSize: 13.5,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: 0,
borderRadius: 10,
outline: "none",
}}
/>
<span
aria-hidden
style={{
position: "absolute",
right: 11,
width: 16,
height: 16,
display: "grid",
placeItems: "center",
pointerEvents: "none",
}}
>
<AnimatePresence initial={false}>
{valid && (
<motion.svg
key="mark"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
// Icons may scale; the text beside them may not. This is
// an icon, so it is allowed to arrive with a little size.
initial={{ opacity: 0, scale: reduceMotion ? 1 : cfg.scaleFrom }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : 0.8,
transition: { duration: 0.12, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.16, ease: "easeOut" }
: { ...cfg.spring, opacity: { duration: 0.14, ease: "easeOut" } }
}
>
<circle
cx="8"
cy="8"
r="8"
fill={`color-mix(in srgb, ${VALID} 16%, transparent)`}
/>
<motion.path
d="M4.4 8.3 6.9 10.8 11.7 5.6"
stroke={VALID}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.drawDuration, ease: "easeOut", delay: 0.03 }
}
/>
</motion.svg>
)}
</AnimatePresence>
</span>
</div>
{/* One polite announcement, so the pass is not silent for anyone
who cannot see the mark. */}
<span
role="status"
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
}}
>
{valid ? `${label} accepted` : ""}
</span>
</div>
);
}About this pattern
Validation usually only speaks up when something is wrong, which leaves the user guessing on the way in. This is the other half: the keystroke that completes a value is answered by a mark resolving at the right edge and a soft ring settling over the border. The slot for the mark is reserved whether or not it is showing, so arriving at an acceptable value never re-flows the text that was just typed, and the border changes color by fading a ring over it rather than tweening the real border — the change stays on the compositor and lands on the exact hue instead of drifting through gray. The mark is an icon, so it may arrive with a little size; nothing typographic moves at all.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Card fields mark themselves complete at the right edge while you type.
Related patterns
- Copy ConfirmationA copy button trades its icon for a drawn tick and its label for "Copied", then quietly changes back.
- Rating Stars FillStars light left to right under the pointer, each settling a fraction after the one before it.
- Changes Saved PillA pill drifts up from the toolbar to confirm an autosave, then dissolves.