All patterns

Validation Summary List

A rejected submit answers with the whole list of what stopped it, each entry arriving a beat apart.

feedbackcalmminimalautomatic · finite · intermediate · ~0.5s
Variant

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.

272 lines · react + motion only
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.

Long form submitCheckout rejectionSettings save blockedMulti-section application

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    A blocked save gathers everything it objects to into one banner above the form.

Related patterns