Skip to content

ErrorSummary

MD · 1950B gzip budget

The focusable list of what went wrong, at the top of a form — usable with any form library.

Category
Forms & input
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/error-summary.json

Copies the source into your project. Pulls in 3 shared registry items: cn, use-locale, locale.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useEffect, useId, useRef } from "react";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";

export interface FormError {
  /** The `name` of the field this refers to. */
  field: string;
  message: string;
}

export interface ErrorSummaryProps {
  errors: FormError[];
  /**
   * Heading text, given the number of errors.
   *
   * A function rather than a template string with `{count}` in it: a template
   * cannot be pluralised, and "There are 1 problems with this form" is the
   * exact failure this library exists to stop shipping. Matches the
   * `errorSummary` label on `ResilientForm`.
   */
  title?: (count: number) => string;
  /** Called with a field name when its entry is activated. */
  onNavigate?: (field: string) => void;
  className?: string;
}

/**
 * The focusable list of what went wrong, at the top of a form.
 *
 * This is the pattern WCAG 3.3.1 is really asking for, and the one keyboard
 * and screen reader users navigate by: on a failed submit, focus moves here,
 * the errors are read as a list, and each entry jumps to the field it names.
 * Colouring the offending inputs red satisfies nobody who cannot see them, and
 * scattering messages down a long form makes a user hunt.
 *
 * Extracted from `ResilientForm` so it can be used with any form library —
 * react-hook-form, a server action's returned state, or plain state.
 */
/**
 * The English default, pluralised through `Intl.PluralRules` rather than a
 * hardcoded `count === 1` ternary — English has two plural categories, but the
 * same call is correct in locales with three, four, or six, so a translator
 * replacing this function does not also have to replace its logic.
 */
function defaultTitle(count: number): string {
  const category = new Intl.PluralRules("en-US").select(count);
  const noun = category === "one" ? "problem" : "problems";
  const verb = category === "one" ? "is" : "are";

  return `There ${verb} ${count} ${noun} with this form`;
}

export function ErrorSummary({
  errors,
  title = defaultTitle,
  onNavigate,
  className,
}: ErrorSummaryProps) {
  const id = useId();
  const { direction } = useLocale();
  const container = useRef<HTMLDivElement>(null);

  // A stable identity for "which errors are showing", so focus moves when the
  // error set genuinely changes rather than on every re-render.
  const signature = errors.map((error) => error.field).join("|");

  useEffect(() => {
    if (signature) container.current?.focus();
  }, [signature]);

  if (errors.length === 0) return null;

  return (
    <div
      ref={container}
      tabIndex={-1}
      role="alert"
      aria-labelledby={id}
      dir={direction}
      className={cn(
        "rounded-lg border border-red-300 bg-red-50 p-4 text-start outline-none",
        "focus-visible:ring-2 focus-visible:ring-red-600",
        "dark:border-red-900 dark:bg-red-950/50",
        className,
      )}
    >
      <h2 id={id} className="text-sm font-semibold text-red-900 dark:text-red-100">
        {title(errors.length)}
      </h2>

      <ul className="mt-2 flex list-disc flex-col gap-1 ps-5 text-sm">
        {errors.map((error) => (
          <li key={error.field}>
            <a
              href={`#${error.field}`}
              onClick={(event) => {
                if (!onNavigate) return;
                event.preventDefault();
                onNavigate(error.field);
              }}
              className="text-red-900 underline underline-offset-2 dark:text-red-100"
            >
              {error.message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation