Feedback

Inline Error Summary

A top-of-form error summary where every row scrolls to and focuses the field it names — persistent live region, self-focusing panel, and an onNavigate hook for fields inside collapsed sections.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronRight, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

export interface InlineErrorSummaryItem {
  /** `id` of the control this error belongs to — the row links to `#fieldId` and focuses it. */
  fieldId: string
  /** Field name the way the user knows it ("Work email"), not the schema key. */
  label: string
  /** What is wrong, ideally with the fix ("Use at least 8 characters."). */
  message: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/inline-error-summary.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "InlineErrorSummary" component — the "3 errors need
your attention" block at the top of a long form, where every row jumps to and focuses the
field it names (WCAG 3.3.1). Dependencies: lucide-react icons and a cn() class merger.

Contract
- export const InlineErrorSummary = forwardRef<HTMLDivElement, InlineErrorSummaryProps>,
  spreading the remaining HTMLAttributes onto the root <div> (spread LAST, so a consumer can
  override role — see the live-region note below).
- interface InlineErrorSummaryItem { fieldId: string; label: string; message: string }
  fieldId is the DOM id of the control; label is the field name the user sees ("Work email",
  not "workEmail"); message says what is wrong and ideally how to fix it.
- Props: errors: InlineErrorSummaryItem[] (required, in form order), title?: ReactNode
  (default "There's a problem with this form"), onNavigate?: (item, index) => boolean | void,
  variant?: "block" | "inline" ("block"), maxVisible?: number (undefined = show all),
  takeFocus?: boolean (true), focusKey?: React.Key, className.
- The component owns no validation and no form state. It renders the array it is handed.

Behavior — the live region is PERSISTENT
- The root is always rendered, even with zero errors, and always carries role="status".
  A live region that is inserted into the DOM in the same frame as its content is not
  announced by several screen readers — the region has to pre-exist the text that goes in it.
  With no errors the root gets `sr-only` (not `hidden`, not conditional rendering): it stays
  in the accessibility tree and stops costing any layout, so a flex/grid parent with `gap`
  does not leave a hole where the summary will appear.
- role="status" (polite), NOT role="alert": this block also takes focus on submit, and an
  assertive region would interrupt whatever is being read only for the focus move to repeat
  the same sentence a moment later. Polite queues instead of fighting. If your summary is
  filled in WITHOUT moving focus (a background/async validation result landing while the user
  is somewhere else), pass role="alert" through — the spread props override it.

Behavior — the summary takes focus itself
- The root is the focus target: tabIndex={-1} plus aria-labelledby pointing at the title.
  After a rejected submit the keyboard focus is still on the submit button at the bottom of
  the page; moving it to the summary is the only thing that actually tells a keyboard or
  screen reader user that something appeared above them.
- When to move focus, tracked with refs holding the previous values (never setState in an
  effect):
    focusKey given  -> focus whenever focusKey CHANGES and there are errors. Bump it on every
                       rejected submit (react-hook-form's submitCount is exactly this) so
                       pressing submit twice with the identical errors focuses again — an
                       unchanged error array produces no re-render to hang focus off.
    focusKey absent -> focus only on the transition from "no errors" to "some errors", so a
                       summary that is already on screen never steals focus while typing.
  Both previous-value refs are seeded with the first render's values, so mounting with errors
  already present does not grab focus.
- takeFocus={false} turns this off for always-on-screen summaries; the forwarded ref still
  lets the consumer call .focus() at any moment.
- Style the focused state with `focus:` and not `focus-visible:`. The element can only be
  focused programmatically, so there are no stray rings from tabbing — and after a mouse
  click on submit, :focus-visible would not match and the user would see nothing move.

Behavior — a row navigates to its field
- Each row is a real <a href={"#" + fieldId}> inside <ul role="list"> / <li>, with <li> as a
  DIRECT child of the <ul> (an unroled wrapper in between makes screen readers announce an
  empty list). A real anchor is keyboard-reachable for free, shows up in the screen reader's
  link list and in the browser's in-page find.
- onClick: preventDefault (native fragment navigation aligns the field to the TOP edge and
  ignores prefers-reduced-motion), then:
    1. call onNavigate?.(item, index). This is the escape hatch for a field that is not in the
       DOM: expand the collapsed section, open the accordion, switch the wizard step here.
       Returning false means "I own the jump" and the component does nothing further.
    2. try to reveal the field synchronously (focus must be able to run synchronously; an
       effect-based focus can strand itself when the state it waits on does not change).
    3. if the field is not in the document, retry ONCE on the next animation frame — by then
       React has committed the expansion that onNavigate triggered. Cancel the pending frame
       on unmount.
    4. still missing -> no-op. Never fake a jump.
- Revealing a field = scrollIntoView({ block: "center", behavior: reduced-motion ? "auto" :
  "smooth" }), then focus. Read prefers-reduced-motion with matchMedia inside the handler,
  never during render.
- fieldId frequently points at a fieldset / radiogroup / wrapper div rather than a control:
  scroll THAT element (so its legend stays visible) but hand focus to the first enabled
  control inside it; if there is nothing focusable, set tabindex="-1" on it so focus lands
  anyway. Focus with { preventScroll: true } — the centered scroll was already done and the
  browser would otherwise re-align to the nearest edge.
- Check isConnected before focusing: between the query and the call the node can be gone
  (a step unmounted, an async re-render), and focusing a detached node silently drops focus
  to <body>.
- An item with a blank fieldId renders as plain text instead of an <a href="#"> that goes
  nowhere.

Behavior — counts and overflow
- Always render a one-sentence summary with correct plurals: "1 error needs your attention" /
  "3 errors need your attention". It is the sentence a screen reader user hears first, and the
  number always describes the FULL array, not the visible slice.
- maxVisible clamps with Math.max(1, Math.trunc(n)) and treats non-finite values as "show
  all": maxVisible={0} must not swallow the whole list. Hidden rows get a trailing line,
  "6 more errors are not listed here.", also correctly singular/plural.

Rendering & styling
- block variant: rounded border + bg-destructive/10 + p-4 + text-sm — the classic panel above
  the first field. inline variant: no border, no tint, no padding, text-xs — for a card that
  already has its own chrome, e.g. sitting just above the submit button.
- Semantic tokens only. Everything in the panel is text-destructive at FULL opacity: on
  bg-destructive/10 that measures 4.65:1 in the light theme, so stacking any /70 or /60
  modifier on the text (3.45:1 / 2.94:1) drops it below AA. Border and background may carry
  opacity; text may not.
- Overflow discipline: the root and every flex child that holds text gets min-w-0 plus
  break-words, so a 90-character field label or a URL wraps instead of pushing the panel
  wider than its container (a flex item's default min-width:auto is the content's min-content
  size). Use break-words, not break-all — the latter collapses the column to one character.
- The row chevron nudges right on hover with `transition-[translate]`, because Tailwind v4
  writes translate-x-* to the `translate` property and `transition-transform` would never
  animate it; `motion-reduce:transition-none` switches it off.
- cn() merges the consumer className onto the root; the icon is aria-hidden.

Customization levers
- Politeness: swap role="status" for role="alert" (spread props win) when the summary fills in
  without taking focus. Keep the container mounted either way.
- Focus policy: takeFocus={false} + the forwarded ref for full manual control; or focus the
  first invalid FIELD instead of the summary if your form is short enough that the summary is
  never off-screen (you lose the "here is everything that is wrong" overview).
- Row anatomy: label + em dash + message is one span — swap for two lines, drop the chevron,
  or prefix a number ("1.") to match a numbered form.
- Density: block is gap-3/p-4/text-sm and inline is gap-1.5/text-xs; a third "banner" variant
  is just another entry in that lookup.
- Overflow: turn the "6 more errors" line into a button that lifts maxVisible for the rest of
  the submit, if your forms routinely fail with dozens of fields.
- Scroll framing: block "center" suits a page with a sticky header; use "nearest" when the
  form lives in its own scroll container and you do not want it to jump.

Concepts

  • Error summary as a skip list — the block is not decoration: it is the only affordance that lets a keyboard or screen reader user get from "the form was rejected" to "the third field in a collapsed section" in one action, which is what WCAG 3.3.1 is asking for on any form longer than a screen.
  • Persistent live region — the role="status" container is mounted from the first render (sr-only while empty) and only its contents change. A live region inserted in the same frame as its text is missed by several screen readers, and display: none would drop it out of the accessibility tree entirely.
  • Polite over assertivestatus queues behind whatever is being spoken; alert interrupts. Because the block also pulls focus, an assertive region would barge in and then be immediately repeated by the focus announcement. Consumers who fill the summary without moving focus can override role through the spread props.
  • Focus handoff — after a rejected submit the focus is still on the submit button at the bottom of the page. Moving it into the summary (tabIndex={-1}) is what makes the new content discoverable; from there Tab walks the rows and Enter hands focus to the field itself.
  • focusKey for repeat submissions — an identical error array produces no state transition, so "focus when errors appear" never fires on the second identical submit. A key that changes per attempt (react-hook-form's submitCount) restores the expected behaviour.
  • onNavigate as an escape hatch — the summary cannot know that a field lives inside a collapsed accordion or a different wizard step. onNavigate runs first so the consumer can reveal it, and the built-in jump retries once on the next frame, after that state change has painted.
  • Reveal, then focus — the element named by fieldId is scrolled to the middle of the viewport, but focus goes to the first real control inside it, so pointing fieldId at a fieldset keeps its legend on screen while the caret still lands in an input.
  • Truthful counts — the sentence counts the whole array while maxVisible only limits what is listed, and the trailing "N more errors are not listed here" says so out loud rather than silently truncating.

On This Page