Skip to content

Lists every problem on submit, links to each field, and thins out as you fix.

Formsmotion@number-flow/react

01Preview

Billing details

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @number-flow/react

03Usage

import { ErrorSummary } from "@/components/ui/error-summary";

const [submits, setSubmits] = useState(0);

<form noValidate onSubmit={(e) => { e.preventDefault(); setSubmits((n) => n + 1); }}>
  <ErrorSummary
    submitCount={submits}
    gap={16}
    errors={[
      { id: "vat", label: "VAT number", message: "Use a country code and 8–12 digits" },
      { id: "email", label: "Billing email", message: "Enter a full address" },
    ]}
  />
  {/* fields with id="vat" and id="email" */}
</form>

04Source

"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Alert, ArrowRight, CircleCheck } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";

export type ErrorSummaryItem = {
  /** The control's id, or its name when it has no id. Used to focus it. */
  id: string;
  /** The field's label, as the user saw it. */
  label: React.ReactNode;
  /** What's wrong and how to fix it. */
  message: React.ReactNode;
};

export type ErrorSummaryProps = Omit<React.ComponentProps<"div">, "title" | "onSelect"> & {
  /** The current problems, in the order the fields appear. An empty list closes the summary. */
  errors: ErrorSummaryItem[];
  /** Increment on every submit. Each new count with errors moves focus to the summary. */
  submitCount?: number;
  /** Heading. Receives the count; defaults to “Fix 3 problems to continue”. */
  title?: (count: number) => React.ReactNode;
  /** Shown briefly after the last problem is fixed, before the summary closes. Pass null to close at once. */
  resolvedTitle?: React.ReactNode;
  /** Space kept below the summary while it is open, animated with it so the form never jumps. */
  gap?: number;
  /** Called when a link is followed, after the field has focus. */
  onSelect?: (item: ErrorSummaryItem) => void;
};

/** Moves to a field by id or name: scrolls its label into view, then focuses the control. */
export function focusField(id: string, smooth = true) {
  const el =
    document.getElementById(id) ??
    (document.getElementsByName(id)[0] as HTMLElement | undefined) ??
    null;
  if (!el) return null;
  const anchor = (el as HTMLInputElement).labels?.[0] ?? el;
  anchor.scrollIntoView({ block: "center", behavior: smooth ? "smooth" : "auto" });
  el.focus({ preventScroll: true });
  if (el instanceof HTMLInputElement && /^(text|email|search|tel|url|password)$/.test(el.type)) el.select();
  return el;
}

const defaultTitle = (n: number) => (
  <>
    Fix <NumberFlow value={n} className="tabular" /> {n === 1 ? "problem" : "problems"} to continue
  </>
);

export function ErrorSummary({
  errors,
  submitCount = 0,
  title = defaultTitle,
  resolvedTitle = "Everything’s fixed",
  gap = 0,
  onSelect,
  className,
  ...rest
}: ErrorSummaryProps) {
  const reduce = useReducedMotion();
  const titleId = useId();
  const card = useRef<HTMLDivElement>(null);
  const count = errors.length;

  // "Resolved" is the short moment after the last fix: the same card says so, then closes.
  const [prevCount, setPrevCount] = useState(count);
  const [resolved, setResolved] = useState(false);
  // Rows stagger in only when the list opens, never when one is added or removed later.
  const [opening, setOpening] = useState(count > 0);
  if (prevCount !== count) {
    setPrevCount(count);
    setResolved(prevCount > 0 && count === 0 && resolvedTitle != null);
    setOpening(prevCount === 0 && count > 0);
  }
  useEffect(() => {
    if (!resolved) return;
    const t = window.setTimeout(() => setResolved(false), 1800);
    return () => window.clearTimeout(t);
  }, [resolved]);

  // A submit with errors takes focus, so screen readers read the list and keyboard users start from it.
  const hasErrors = count > 0;
  useEffect(() => {
    if (submitCount > 0 && hasErrors) card.current?.focus({ preventScroll: false });
  }, [submitCount, hasErrors]);

  const open = count > 0 || resolved;
  const tone = count > 0 ? "danger" : "success";

  return (
    <AnimatePresence initial={false}>
      {open && (
        <motion.div
          key="summary"
          // Bleeds 3px past its box so the focus halo is never clipped by the height animation.
          className="-mx-[3px] -mt-[3px] -mb-[3px] overflow-hidden"
          initial={{ height: 0, opacity: reduce ? 0 : 1 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0.12 : 0.22, ease: ease.inOut } }}
          transition={{ duration: reduce ? 0.15 : 0.32, ease: ease.out }}
        >
          <div style={{ paddingBottom: gap + 3 }} className={cn("px-[3px] pt-[3px]", className)} {...rest}>
            <motion.div
              ref={card}
              tabIndex={-1}
              role="region"
              aria-labelledby={titleId}
              data-tone={tone}
              initial={reduce ? false : { y: -6, scale: 0.985 }}
              animate={{ y: 0, scale: 1 }}
              transition={reduce ? { duration: 0 } : spring.soft}
              className={cn(
                "rounded-xl border p-1.5 outline-none transition-[background-color,border-color,box-shadow] duration-300 ease-out",
                "data-[tone=danger]:border-danger/25 data-[tone=danger]:bg-danger-soft data-[tone=danger]:focus:ring-3 data-[tone=danger]:focus:ring-danger/15",
                "data-[tone=success]:border-success/25 data-[tone=success]:bg-success-soft",
              )}
            >
              <div className={cn("flex items-center gap-2 px-2 pt-1.5 transition-[padding] duration-200", count > 0 ? "pb-1" : "pb-1.5")}>
                <span className="relative grid size-4 shrink-0 place-items-center">
                  <AnimatePresence initial={false}>
                    <motion.span
                      key={tone}
                      className={cn("absolute inset-0 grid place-items-center", tone === "danger" ? "text-danger" : "text-success")}
                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -30 }}
                      animate={{ opacity: 1, scale: 1, rotate: 0 }}
                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: 0.12 } }}
                      transition={reduce ? { duration: 0.15 } : spring.pop}
                    >
                      {tone === "danger" ? <Alert /> : <CircleCheck />}
                    </motion.span>
                  </AnimatePresence>
                </span>
                <h2 id={titleId} className="min-w-0 text-[13px] font-medium tracking-[-0.01em] text-fg">
                  {count > 0 ? title(count) : resolvedTitle}
                </h2>
              </div>

              <ul className="flex flex-col">
                <AnimatePresence initial={false}>
                  {errors.map((item, i) => (
                    <motion.li
                      key={item.id}
                      layout={reduce ? false : "position"}
                      initial={{ opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={{ opacity: 0, height: 0, transition: { duration: reduce ? 0.1 : 0.2, ease: ease.inOut } }}
                      transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out, delay: opening && !reduce ? 0.06 + Math.min(i, 7) * 0.025 : 0 }}
                      className="overflow-hidden"
                    >
                      <a
                        href={`#${item.id}`}
                        onClick={(e) => {
                          e.preventDefault();
                          if (focusField(item.id, !reduce)) onSelect?.(item);
                        }}
                        className={cn(
                          "group/row relative flex items-start gap-2 rounded-lg px-2 py-1.5 outline-none",
                          "transition-[background-color,scale] duration-150 ease-out hover:bg-fg/[0.045] active:scale-[0.99] active:duration-75",
                          "focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid",
                        )}
                      >
                        {/* Aligns row text with the title's text, past the icon. */}
                        <span aria-hidden className="w-4 shrink-0" />
                        <span className="flex min-w-0 flex-1 flex-col">
                          <span className="text-[12.5px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px] transition-[text-decoration-color] duration-150 group-hover/row:decoration-fg-2">
                            {item.label}
                          </span>
                          <span className="text-[12px] leading-[1.45] text-fg-2 [text-wrap:pretty]">{item.message}</span>
                        </span>
                        <ArrowRight
                          size={14}
                          className="mt-[3px] shrink-0 text-fg-3 transition-[translate,color] duration-200 ease-out-expo group-hover/row:translate-x-0.5 group-hover/row:text-fg-2 group-focus-visible/row:translate-x-0.5 group-focus-visible/row:text-fg-2"
                        />
                      </a>
                    </motion.li>
                  ))}
                </AnimatePresence>
              </ul>
            </motion.div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

05Props

ErrorSummary

PropTypeDefaultDescription
errors*{ id: string; label: ReactNode; message: ReactNode }[]Current problems in field order. id is the control’s id (or name). An empty list closes the summary.
submitCountnumber0Increment on each submit. Every new count with errors moves focus to the summary.
title(count: number) => ReactNode“Fix 3 problems to continue”Heading; the default rolls the count as it changes.
resolvedTitleReactNode | null"Everything’s fixed"Shown for 1.8s after the last fix before the summary closes. null closes at once.
gapnumber0Space below the summary while open, animated with its height so the form never jumps.
onSelect(item) => voidCalled after a link has moved focus to its field.
classNamestringClasses for the wrapper inside the height animation.

focusField

PropTypeDefaultDescription
id*stringScrolls the field’s label to the center, focuses the control and selects its text. Returns the element, or null.
smoothbooleantrueSmooth scroll; pass false under reduced motion.

06Notes

Behavior

  • Takes focus on each submit that has errors, so the list is read first and Tab starts at the first link.
  • Links scroll the field’s label into view (not just the input) and select the value, so the fix starts with typing.
  • As errors are fixed, their rows leave and the count rolls down; the last fix turns the card green with “Everything’s fixed”, then it closes.
  • Messages wrap rather than truncate: an error is something you have to read in full.

Motion

  • Opens from zero height in 320ms on the expo ease-out while the card settles 6px down on the soft spring; closes in 220ms.
  • Rows stagger 25ms apart only when the list first opens; later removals collapse their row in 200ms while the rest glide up by layout.
  • The count rolls with NumberFlow and the icon pops between alert and check. Reduced motion keeps fades and an instant count.

Accessibility

  • A labeled region with a real heading, focused programmatically on submit, with a visible halo.
  • Each problem is a real link to #id, so it works with keyboard, middle-click and screen reader link lists.
  • Errors are named by field label and say how to fix them, never by color alone.