Inline Error Reveal
A field marks itself invalid: the error ring fades on and the message expands into place below.
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 { useId, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Inline Error Reveal
*
* A field that marks itself invalid: the error ring fades onto the
* input while the message expands into place underneath.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the field reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `message`.
* Submit the form to trigger validation.
* Requires the automatic JSX runtime (default since React 17).
*/
export type InlineErrorRevealProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. */
label?: string;
/** Starting value — invalid by default so the pattern is one click away. */
defaultValue?: string;
/** Text revealed underneath the field. */
message?: string;
/** Fires on submit with the validation outcome. */
onSubmit?: (valid: boolean) => void;
};
type VariantConfig = {
ringDuration: number;
heightDuration: number;
messageLift: number;
messageDelay: number;
};
// Quality rule: nothing here springs. A spring on height overshoots, and
// an overshooting height drags the message text past its resting line and
// back — the exact wobble the bar forbids. Short ease-out tweens land
// once. Variants differ in how far the message lifts and how long the
// reveal takes, which is all the character an error state should have.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly instant, no lift. For long forms where several fields can
// fail at once and staggered movement would read as chaos.
subtle: {
ringDuration: 0.07,
heightDuration: 0.1,
messageLift: 0,
messageDelay: 0.02,
},
// The ring lands first, the message follows a beat later. All-purpose.
default: {
ringDuration: 0.16,
heightDuration: 0.19,
messageLift: 3,
messageDelay: 0.05,
},
// A longer beat and a touch more lift — for a single hero field
// (checkout email, promo code) where the failure deserves a moment.
playful: {
ringDuration: 0.21,
heightDuration: 0.26,
messageLift: 8,
messageDelay: 0.1,
},
};
const ERROR = "#F0646F";
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. The error red stays literal: it is a
* state color, not a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Deliberately loose: shape-checking only, real delivery is server-side. */
const looksLikeEmail = (candidate: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(candidate.trim());
export default function InlineErrorReveal({
variant = "default",
label = "Work email",
defaultValue = "maya@northwind",
message = "Enter a valid work email address.",
onSubmit,
}: InlineErrorRevealProps) {
const fieldId = useId();
const messageId = useId();
const [value, setValue] = useState(defaultValue);
const [invalid, setInvalid] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: the ring and the message still fade in, they just
// arrive at full height instantly. The error is never conveyed by
// movement alone, so nothing is lost.
const heightDuration = reduceMotion ? 0 : cfg.heightDuration;
const messageLift = reduceMotion ? 0 : cfg.messageLift;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const valid = looksLikeEmail(value);
setInvalid(!valid);
onSubmit?.(valid);
};
return (
<form
noValidate
onSubmit={handleSubmit}
style={{
width: 320,
padding: 18,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
}}
>
<label
htmlFor={fieldId}
style={{ display: "block", fontSize: 12.5, fontWeight: 600, opacity: 0.72 }}
>
{label}
</label>
<div style={{ position: "relative", marginTop: 7 }}>
<input
id={fieldId}
type="email"
value={value}
aria-invalid={invalid}
aria-describedby={invalid ? messageId : undefined}
onChange={(event) => {
const next = event.target.value;
setValue(next);
// Once a field has been marked invalid, re-check every
// keystroke: the error has to leave the moment it stops being
// true, not at the next submit.
if (invalid && looksLikeEmail(next)) setInvalid(false);
}}
style={{
width: "100%",
boxSizing: "border-box",
padding: "10px 12px",
fontSize: 14,
fontFamily: "inherit",
borderRadius: 10,
// Inputs inherit neither color nor font from their form, so
// both are set explicitly — `inherit` keeps the field, and its
// caret, in the host's text color.
background: tone(7),
color: "inherit",
border: `1px solid ${tone(16)}`,
outline: "none",
}}
/>
{/* The border "changes color" by fading a ring in on top of it.
Tweening the input's own borderColor repaints the field every
frame and drags the hue through muddy in-between values; an
overlaid ring is a compositor-only opacity change that lands on
the exact error color. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: invalid ? 1 : 0 }}
transition={{ duration: cfg.ringDuration, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 10,
border: `1px solid ${ERROR}`,
boxShadow: `0 0 0 3px rgba(240,100,111,0.15)`,
pointerEvents: "none",
}}
/>
</div>
<AnimatePresence initial={false}>
{invalid && (
<motion.div
key="message"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: heightDuration, ease: "easeOut" },
opacity: { duration: heightDuration * 0.8, ease: "easeOut" },
}}
// The height animation is the one layout property this library
// animates: the message has to push the button down, and a
// fixed reserved gap would leave a hole under every field.
style={{ overflow: "hidden" }}
>
<motion.p
id={messageId}
role="alert"
initial={{ y: messageLift }}
animate={{ y: 0 }}
transition={{
duration: heightDuration,
ease: "easeOut",
delay: reduceMotion ? 0 : cfg.messageDelay,
}}
style={{
display: "flex",
alignItems: "flex-start",
gap: 6,
margin: 0,
paddingTop: 8,
fontSize: 12.5,
lineHeight: 1.4,
color: ERROR,
}}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
style={{ flexShrink: 0, marginTop: 2 }}
>
<circle cx="8" cy="8" r="6.4" stroke="currentColor" strokeWidth="1.4" />
<path d="M8 5.1v3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="11" r="0.85" fill="currentColor" />
</svg>
{message}
</motion.p>
</motion.div>
)}
</AnimatePresence>
<button
type="submit"
style={{
width: "100%",
marginTop: 14,
padding: "10px 14px",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: 0,
// Brand accent, not a surface: it is the same in both themes,
// and white stays the legible label on it.
background: ACCENT,
color: "#ffffff",
cursor: "pointer",
}}
>
Continue
</button>
</form>
);
}About this pattern
Validation feedback that stays next to the thing that failed. On submit the field gains an error ring and the explanation expands underneath, pushing the rest of the form down by exactly its own height. Two decisions carry the pattern: the border 'changes color' by fading a ring over it rather than tweening the real border, which keeps the change on the compositor and lands on the exact error hue; and the height reveal is a short ease-out tween rather than a spring, because a spring would overshoot and drag the message text past its resting line. An error is bad news — it should appear calmly and be readable the instant it lands.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Field-level validation: the input gains an error ring and the reason opens beneath it.
Related patterns
- Validation Summary ListA rejected submit answers with the whole list of what stopped it, each entry arriving a beat apart.
- Error Retry NudgeA failed action answers with one short damped nudge and becomes its own retry.
- Banner DismissAn announcement strip fades its message, then collapses its own height so the page closes the gap.