Validation Summary List
A rejected submit answers with the whole list of what stopped it, each entry arriving a beat apart.
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, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Validation Summary List
*
* A submit that could not go through, answered with the whole list of
* what stopped it. The panel opens above the form on a short eased
* height — never a spring, which would drag the words past their resting
* line — and each entry arrives a beat after the one before, from the
* panel's own left edge. Every entry is a control that takes you to the
* field it is about.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `problems`, `open`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ValidationProblem = {
/** Which field it is about. */
field: string;
/** What has to change, in a few words. */
detail: string;
};
export type ValidationSummaryListProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your submit handler. Left undefined, the panel opens
* itself after `delayMs` so the file runs as-is. */
open?: boolean;
/** Only consulted while `open` is undefined. */
delayMs?: number;
/** Heading above the list. Written as a count of what to look at. */
title?: string;
/** What stopped the submit, in the order the fields appear. */
problems?: ValidationProblem[];
/** Accent for the rule, the marker and the field names. */
accent?: string;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires with the field name when an entry is chosen. */
onSelect?: (field: string) => void;
};
type VariantConfig = {
/** Seconds the panel takes to open. */
openSeconds: number;
/** px an entry travels in from the panel's left edge. */
slideX: number;
/** Gap between one entry starting and the next. */
stagger: number;
/** Fade length for a single entry. */
fadeSeconds: number;
};
// Nothing here springs. Bad news should be readable the instant it
// lands, and a summary that overshoots makes a failed submit feel like
// an accident rather than an answer. Variants change pace and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost immediate — for a form that is re-submitted often.
subtle: {
openSeconds: 0.2,
slideX: 5,
stagger: 0.04,
fadeSeconds: 0.16,
},
// The all-purpose setting: the list reads as a list being written.
default: {
openSeconds: 0.26,
slideX: 10,
stagger: 0.07,
fadeSeconds: 0.22,
},
// A longer beat between entries, for a long checkout form where the
// count itself is the news.
playful: {
openSeconds: 0.32,
slideX: 15,
stagger: 0.1,
fadeSeconds: 0.26,
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light page and on a dark one.
* Used for the hover wash; the panel's own surfaces come from the
* accent, because the error state is what they mean. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#E5484D";
const SAMPLE_PROBLEMS: ValidationProblem[] = [
{ field: "Billing email", detail: "Not a complete address" },
{ field: "Card expiry", detail: "This date has passed" },
{ field: "Postal code", detail: "Required for card verification" },
];
export default function ValidationSummaryList({
variant = "default",
open,
delayMs = 700,
title = "3 fields need a look",
problems = SAMPLE_PROBLEMS,
accent = ACCENT,
width = 320,
onSelect,
}: ValidationSummaryListProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfOpen, setSelfOpen] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `open`, this timer stays out of the way.
useEffect(() => {
if (open !== undefined) return;
const timer = setTimeout(() => setSelfOpen(true), delayMs);
return () => clearTimeout(timer);
}, [open, delayMs]);
const isOpen = open ?? selfOpen;
const slideX = reduceMotion ? 0 : cfg.slideX;
const stagger = reduceMotion ? cfg.stagger * 0.4 : cfg.stagger;
return (
<div style={{ width }}>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="summary"
// Height is the one property here that genuinely changes: the
// panel takes room the form has to give up. Short and eased,
// so the words never travel past their line and back.
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: reduceMotion ? 0 : cfg.openSeconds, ease: "easeOut" },
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<div
role="alert"
style={{
display: "flex",
gap: 11,
padding: "12px 13px 13px",
borderRadius: 12,
background: `color-mix(in srgb, ${accent} 8%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 26%, transparent)`,
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
marginTop: 1,
display: "inline-flex",
color: accent,
}}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.4" stroke="currentColor" strokeWidth="1.4" />
<path
d="M8 4.7v3.8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="8" cy="11.1" r="0.85" fill="currentColor" />
</svg>
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 12.5, fontWeight: 650 }}>{title}</div>
<ul
style={{
listStyle: "none",
margin: "9px 0 0",
padding: 0,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{problems.map((problem, index) => (
<motion.li
key={problem.field}
initial={{ opacity: 0, x: -slideX }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
// A beat apart, so the list reads as a list rather
// than a wall arriving all at once.
delay: cfg.openSeconds * 0.5 + index * stagger,
}}
>
<button
type="button"
onClick={() => onSelect?.(problem.field)}
onPointerEnter={() => setHovered(index)}
onPointerLeave={() => setHovered(null)}
onFocus={() => setHovered(index)}
onBlur={() => setHovered(null)}
style={{
display: "flex",
alignItems: "baseline",
gap: 6,
width: "100%",
padding: "3px 5px",
marginLeft: -5,
borderRadius: 6,
border: 0,
// A neutral mixed from the inherited text color,
// so the row reads on a light page and a dark
// one; a CSS transition rather than an animated
// value, since color-mix() cannot be interpolated.
background: hovered === index ? tone(9) : "transparent",
transition: "background-color 160ms ease-out",
color: "inherit",
font: "inherit",
fontSize: 11.5,
textAlign: "left",
cursor: "pointer",
}}
>
<span style={{ fontWeight: 650, color: accent }}>
{problem.field}
</span>
<span style={{ opacity: 0.62 }}>{problem.detail}</span>
<motion.span
aria-hidden
animate={{
x: hovered === index && !reduceMotion ? 2 : 0,
opacity: hovered === index ? 0.75 : 0.4,
}}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
marginLeft: "auto",
display: "inline-flex",
}}
>
<svg width="10" height="10" viewBox="0 0 12 12" fill="none">
<path
d="M4.3 2.4 7.9 6l-3.6 3.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</button>
</motion.li>
))}
</ul>
</div>
</div>
<div style={{ height: 12 }} />
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
The counterpart to marking fields one by one: a long form that comes back rejected needs one place that says how many things are wrong and where they are. The panel opens above the form on a short eased height — never a spring, because words dragged past their resting line and back are words you have to re-read — and takes room the fields give up rather than floating over them. Entries then arrive from the panel's own left edge, a beat apart, so the list reads as a list being written rather than a wall landing at once. Each entry is a control that takes focus to the field it names, and the panel carries role=alert so the count is spoken as soon as it exists.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
A blocked save gathers everything it objects to into one banner above the form.
Related patterns
- Inline Error RevealA field marks itself invalid: the error ring fades on and the message expands into place below.
- Offline Banner DropA connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
- Password Strength BarA segmented meter fills bar by bar and shifts hue as a password improves.