Skip to content

Form errors

Form errorsThe binding has a global error, from a class-level constraint or reject(...) in the controller. It shows above the fields (here first in the field group, which spaces it like a field); the field errors stay with their fields.
Thymeleaf source
<sl:field-group>
<sl:form-errors/>
<sl:field th:field="*{signupPassword}">
<sl:field-label>Password</sl:field-label>
<sl:input type="password" autocomplete="new-password"/>
</sl:field>
<sl:field th:field="*{signupRepeat}">
<sl:field-label>Repeat the password</sl:field-label>
<sl:input type="password" autocomplete="new-password"/>
</sl:field>
</sl:field-group>
Rendered HTML
<div class="field-group">
<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="form-errors">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="alert-title">There is a problem</div>
<div class="alert-description">The passwords do not match.</div>
</div>
<div class="field">
<label class="label field-label" for="signupPassword">Password</label>
<input class="input" type="password" autocomplete="new-password" id="signupPassword" name="signupPassword" value="">
</div>
<div class="field">
<label class="label field-label" for="signupRepeat">Repeat the password</label>
<input class="input" type="password" autocomplete="new-password" id="signupRepeat" name="signupRepeat" value="">
</div>
</div>

Some errors are about no single field: two passwords that do not match, a date range that ends before it starts, or a service that refused the request. Spring keeps them in the BindingResult as global errors. <sl:form-errors/> shows them as a destructive alert, and renders nothing while there are none:

<form th:object="${signup}" th:action="@{/signup}" method="post" novalidate>
<sl:field-group>
<sl:form-errors/>
<sl:field th:field="*{password}">...</sl:field>
<sl:field th:field="*{repeatPassword}">...</sl:field>
</sl:field-group>
</form>

With one global error, that renders:

<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="signup-errors">
<svg class="icon" aria-hidden="true" ...>...</svg>
<div class="alert-title">There is a problem</div>
<div class="alert-description">The passwords do not match.</div>
</div>

As the first thing in a <sl:field-group>, it gets the same space as a field. One message is shown as text, several as a list, each message once. By default field errors are not repeated: they stay with their fields. To list them too, see All errors.

Several errors
Thymeleaf source
<sl:form-errors id="order-errors"/>
Rendered HTML
<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="order-errors">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="alert-title">There is a problem</div>
<div class="alert-description">
<ul>
<li>The delivery date is before the order date.</li>
<li>Orders over 50 items need a purchase order number.</li>
</ul>
</div>
</div>

It must be inside the element with th:object: that is the form object whose errors it reads. Outside one, rendering fails with a message that says so.

Anything that calls reject(...) on the BindingResult instead of rejectValue(field, ...):

  • The controller, for a rule that needs a service or a second look at the form:

    @PostMapping("/signup")
    String signup(@Valid @ModelAttribute("signup") SignupForm signup, BindingResult bindingResult) {
    if (accounts.isBlocked(signup.getEmail())) {
    bindingResult.reject("signup.blocked", "We cannot create an account for this address.");
    }
    if (bindingResult.hasErrors()) {
    return "signup";
    }
    ...
    }

    The code (signup.blocked) is looked up in your messages.properties first; the second argument is the default.

  • A class-level constraint: a Bean Validation annotation on the form class, such as a @PasswordsMatch of your own. Its violation has no property path, so Spring adds it as a global error.

A failure the controller catches and does not want in the binding can go in as content of its own (below).

The title defaults to the message sl.form-errors.title, “There is a problem”. Define that key in your own messages.properties to reword or translate it for every form, or set title (or th:title="#{...}") on one. level makes the title a heading that screen reader users can jump to.

A title of its owntitle replaces the default title (the message sl.form-errors.title); level makes it a heading.
Thymeleaf source
<sl:form-errors id="transfer-errors" title="The transfer was not made" level="2"/>
Rendered HTML
<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="transfer-errors">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="alert-title" role="heading" aria-level="2">The transfer was not made</div>
<div class="alert-description">Your balance is too low for this amount.</div>
</div>

Content of your own replaces the messages and always shows, so wrap it in a th:if when it should only show sometimes.

Content of its ownContent of its own replaces the messages and always shows, for an error the controller does not put in the binding.
Thymeleaf source
<sl:form-errors id="payment-errors">
<p>The payment service did not answer. Your card was not charged.</p>
<p>Try again in a minute.</p>
</sl:form-errors>
Rendered HTML
<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="payment-errors">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="alert-title">There is a problem</div>
<div class="alert-description">
<p>The payment service did not answer. Your card was not charged.</p>
<p>Try again in a minute.</p>
</div>
</div>

<sl:form-errors> is not announced, and takes focus only when you ask for it with autofocus.

  • No role="alert". After a submit the whole page loads again. A live region that is there when the page loads is read out by some screen readers and ignored by others, and when it is read, it talks over the page itself. The field errors leave out role="alert" for the same reason. When focus moves to the problem, as below, the screen reader reads it there, and a live region would only say it a second time.
  • No autofocus by default. Moving focus on load skips the reader past the page’s heading, so it fits a form that is the page’s only task, or one that htmx swaps in, where the submit button that had the focus is gone. It is your call per form.

With autofocus, focus goes to the problem when the form comes back with errors:

<form th:object="${signup}" th:action="@{/signup}" method="post" novalidate>
<sl:form-errors autofocus/>
...
</form>
The form comes back with Focus goes to
a global error (or, with show="all", any error) the summary, which is read out; Tab continues into the form
field errors only the first control with an error, in the order of the form: its label, its invalid state and its error are read out through aria-describedby
no errors nowhere: the first, empty form keeps the focus where it was

Content of your own always shows, so with it the summary takes the focus. The summary decides before the controls below it render, so keep it above the fields, where it belongs anyway. It works on any control with a th:field in the form, inside a field or not. A control with a disabled attribute cannot take focus, so it is passed over (see disabled and read-only fields); a read-only one takes it.

For a form that reloads the page, also put the outcome in the page title, such as Error: Sign up: it is the first thing a screen reader reads after a page load.

htmx honours autofocus in the content it swaps in, after its own attempt to keep focus on the element that had it. The htmx guide shows a form that posts with htmx and falls back to a plain POST.

The summary’s id is the form object’s name followed by -errors: signup-errors for th:object="${signup}". Set id to use another one, for instance when two forms on a page bind objects of the same name. The id is there for a link (<a href="#signup-errors">) or a script. The form’s aria-describedby does not point at it: screen readers read a form’s description, if at all, when the user enters the form, which is not where anyone is after a submit.

show="all" lists the field errors too, after the global ones, in the order they were rejected. Each field still shows its own error next to its control. A message is listed once per field, so two fields that both say “must not be blank” both appear.

All errorsshow="all" lists the field errors too, after the global ones. Each field still shows its own error.
Thymeleaf source
<sl:field-group>
<sl:form-errors id="account-errors" show="all"/>
<sl:field th:field="*{accountName}">
<sl:field-label>Name</sl:field-label>
<sl:input autocomplete="name"/>
<sl:field-error/>
</sl:field>
<sl:field th:field="*{accountEmail}">
<sl:field-label>Email</sl:field-label>
<sl:input type="email" autocomplete="email"/>
<sl:field-error/>
</sl:field>
</sl:field-group>
Rendered HTML
<div class="field-group">
<div class="form-errors alert" data-variant="destructive" tabindex="-1" id="account-errors">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="alert-title">There is a problem</div>
<div class="alert-description">
<ul>
<li>This invitation has expired.</li>
<li>Enter your name.</li>
<li>Enter an email address like you@example.com.</li>
</ul>
</div>
</div>
<div class="field" data-invalid="true">
<label class="label field-label" for="accountName">Name</label>
<input class="input" type="text" aria-invalid="true" autocomplete="name" id="accountName" aria-describedby="accountName-error" name="accountName" value="">
<div class="field-error" id="accountName-error">Enter your name.</div>
</div>
<div class="field" data-invalid="true">
<label class="label field-label" for="accountEmail">Email</label>
<input class="input" type="email" aria-invalid="true" autocomplete="email" id="accountEmail" aria-describedby="accountEmail-error" name="accountEmail" value="wim@">
<div class="field-error" id="accountEmail-error">Enter an email address like you@example.com.</div>
</div>
</div>

The messages are listed as the binding has them, without the field’s label, so write them to make sense on their own: “Enter your name” rather than “must not be blank”. Spring looks up a message code per field before the constraint’s default, so your messages.properties can do that without touching the form class:

NotBlank.contactForm.name=Enter your name.
Email.contactForm.email=Enter an email address like you@example.com.

A title with the number of errors takes #fields from your own messages.properties; the component’s attributes are evaluated inside your th:object:

<sl:form-errors show="all" th:title="#{errors.summary.title(${#fields.errors().size()})}"/>
errors.summary.title=There were {0} errors with your submission

With every error in it, the summary is where a keyboard or screen reader user can take stock after a submit, so with autofocus it takes the focus whenever the form has errors.

The messages are not links to their controls (GOV.UK’s summary has those), at least for now:

  • The links need the controls’ ids, which the fields decide as they render, below the summary. Radio buttons and checkbox groups have numbered ids (replyBy1), so the field’s name is not always an id to link to.
  • Each field error is already next to its control, and read out with it through aria-describedby.
  • Without show="all", autofocus takes the user straight to the first field with an error, which is what a link from the summary would do.
AttributeValuesDefaultDescription
showglobal allglobalWhich errors to list: the global errors only, or all of them, the global ones first and then every field's (each field keeps its own error too). Field messages are listed as they are, without the field's label, so write them to make sense on their own.
titlestring—The title above the messages. Defaults to the message sl.form-errors.title ("There is a problem"); use th:title for a message of your own.
level1 2 3 4 5 6—Heading level of the title. With it the title is a heading (role="heading" and aria-level) that screen reader users can jump to; without it, plain text.
autofocusboolean—Move focus to the problem when the form comes back with errors: to the summary when it shows, otherwise to the first control with errors below it (in a field or with a th:field of its own). Nothing takes focus while the form has no errors. htmx honours it in swapped content too.

Every prop can also be set from an expression: th:show="${...}". Any other attribute passes through to the rendered element, and class is appended.