Field
Thymeleaf source
<sl:field th:field="*{username}"> <sl:field-label>Username</sl:field-label> <sl:input autocomplete="username"/> <sl:field-description>Letters, digits and dashes.</sl:field-description> <sl:field-error/></sl:field>Rendered HTML
<div class="field"> <label class="label field-label" for="username">Username</label> <input class="input" type="text" autocomplete="username" id="username" aria-describedby="username-description" name="username" value=""> <p class="field-description" id="username-description">Letters, digits and dashes.</p></div>One th:field for the whole field
Section titled “One th:field for the whole field”Put th:field on <sl:field>, not on the control. The field reads the binding and hands it to its parts, so you
write the field once and get:
- the control bound to the field:
name, value andid, asth:fieldon the control would give; <sl:field-label>with aforthat points at the control;<sl:field-description>with anid, and the control’saria-describedbypointing at it;<sl:field-error>showing the binding’s messages, and the control described by them while there are any;aria-invalidon the control anddata-invalidon the field when the binding has errors.
<form th:object="${signup}" th:action="@{/signup}" method="post" novalidate> <sl:field th:field="*{email}"> <sl:field-label>Email</sl:field-label> <sl:input type="email"/> <sl:field-description>We never share it.</sl:field-description> <sl:field-error/> </sl:field></form>With an error on email, that renders:
<div class="field" data-invalid="true"> <label class="label field-label" for="email">Email</label> <input class="input" type="email" aria-invalid="true" id="email" aria-describedby="email-description email-error" name="email" value="wim@"> <p class="field-description" id="email-description">We never share it.</p> <div class="field-error" id="email-error">must be a well-formed email address</div></div>The parts go in any order. A field holds one control: a second one keeps its own th:field and id.
Errors
Section titled “Errors”<sl:field-error> renders nothing while the field has no errors. One message is shown as text, several as a list,
each message once. Content of your own replaces the messages and always shows, so wrap it in a th:if when it should
only show with errors.
Thymeleaf source
<sl:field th:field="*{workEmail}"> <sl:field-label>Work email</sl:field-label> <sl:input type="email"/> <sl:field-description>Your company address.</sl:field-description> <sl:field-error/></sl:field>Rendered HTML
<div class="field" data-invalid="true"> <label class="label field-label" for="workEmail">Work email</label> <input class="input" type="email" aria-invalid="true" id="workEmail" aria-describedby="workEmail-description workEmail-error" name="workEmail" value="wim@"> <p class="field-description" id="workEmail-description">Your company address.</p> <div class="field-error" id="workEmail-error">must be a well-formed email address</div></div>Thymeleaf source
<sl:field th:field="*{newPassword}"> <sl:field-label>Password</sl:field-label> <sl:input type="password" autocomplete="new-password"/> <sl:field-error/></sl:field>Rendered HTML
<div class="field" data-invalid="true"> <label class="label field-label" for="newPassword">Password</label> <input class="input" type="password" aria-invalid="true" autocomplete="new-password" id="newPassword" aria-describedby="newPassword-error" name="newPassword" value=""> <div class="field-error" id="newPassword-error"> <ul> <li>must be at least 12 characters long</li> <li>must contain a digit</li> </ul> </div></div>The error has no role="alert". After a submit the whole page loads again, and a screen reader would read out every
error at once. The control is aria-invalid and described by its error instead, so the message is read when the user
reaches the control. When htmx swaps the whole form in, <sl:form-errors autofocus/> moves focus to the first
control with an error, which reads it out; see Forms with htmx. If you swap a single field in
while focus stays elsewhere, for example to validate it as the user leaves it, add role="alert" to its error
yourself.
Errors that belong to the whole form rather than one field, such as reject(...) in the controller, go in a
<sl:form-errors/> above the fields.
The control’s id is the field’s name, the id th:field gives a text input: email, or address.street for a nested
property. The description and error ids add -description and -error. Set for on the field to use another id:
<sl:field th:field="*{email}" for="work-email">An aria-describedby you put on the control is kept, and the field’s ids are added after it.
For a checkbox or switch, Spring numbers the id (terms1), so that several checkboxes bound to one list field get
different ids. In a field, the checkbox gets the field’s name instead, and the label points at it.
Other controls
Section titled “Other controls”A textarea and a native select work as the input does:
Thymeleaf source
<sl:field th:field="*{bio}"> <sl:field-label>Bio</sl:field-label> <sl:textarea rows="3"/> <sl:field-description>A few words for your profile page.</sl:field-description></sl:field>Rendered HTML
<div class="field"> <label class="label field-label" for="bio">Bio</label> <textarea class="textarea" rows="3" id="bio" aria-describedby="bio-description" name="bio"></textarea> <p class="field-description" id="bio-description">A few words for your profile page.</p></div>Thymeleaf source
<sl:field th:field="*{country}"> <sl:field-label>Country</sl:field-label> <sl:native-select> <option value="">Select a country</option> <option value="be">Belgium</option> <option value="nl">Netherlands</option> </sl:native-select> <sl:field-error/></sl:field>Rendered HTML
<div class="field" data-invalid="true"> <label class="label field-label" for="country">Country</label> <div class="native-select-wrapper"> <select class="native-select" aria-invalid="true" id="country" aria-describedby="country-error" name="country"> <option value="" selected>Select a country</option> <option value="be">Belgium</option> <option value="nl">Netherlands</option> </select> <svg xmlns="http://www.w3.org/2000/svg" class="icon native-select-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"> <path d="m6 9 6 6 6-6"></path> </svg> </div> <div class="field-error" id="country-error">must not be blank</div></div>Checkbox and switch
Section titled “Checkbox and switch”orientation="horizontal" puts the control first and the label beside it. <sl:field-content> stacks the label with
a description or an error.
Thymeleaf source
<sl:field th:field="*{productUpdates}" orientation="horizontal"> <sl:switch/> <sl:field-content> <sl:field-label>Product updates</sl:field-label> <sl:field-description>One email a month, no more.</sl:field-description> </sl:field-content></sl:field>Rendered HTML
<div class="field" data-orientation="horizontal"> <input type="checkbox" role="switch" class="switch" id="productUpdates" aria-describedby="productUpdates-description" name="productUpdates" value="true" checked> <input type="hidden" name="_productUpdates" value="on"> <div class="field-content"> <label class="label field-label" for="productUpdates">Product updates</label> <p class="field-description" id="productUpdates-description">One email a month, no more.</p> </div></div>Thymeleaf source
<sl:field th:field="*{acceptTerms}" orientation="horizontal"> <sl:checkbox/> <sl:field-content> <sl:field-label>I accept the terms and conditions</sl:field-label> <sl:field-error/> </sl:field-content></sl:field>Rendered HTML
<div class="field" data-orientation="horizontal" data-invalid="true"> <span class="checkbox-wrapper"> <input type="checkbox" class="checkbox" aria-invalid="true" id="acceptTerms" aria-describedby="acceptTerms-error" name="acceptTerms" value="true"> <input type="hidden" name="_acceptTerms" value="on"> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" 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"> <path d="M20 6 9 17l-5-5"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" 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"> <path d="M5 12h14"></path> </svg> </span> <div class="field-content"> <label class="label field-label" for="acceptTerms">I accept the terms and conditions</label> <div class="field-error" id="acceptTerms-error">must be accepted</div> </div></div>Radio buttons and checkbox groups
Section titled “Radio buttons and checkbox groups”A set of options that answers one question is a <sl:field-set>, with a <sl:field-legend> as its question.
th:field goes on the field set, and the options inside share it. A description and an error directly inside the field
set describe the whole group: the fieldset gets the aria-describedby, while each radio button is still marked
aria-invalid from the field.
Thymeleaf source
<sl:field-set th:field="*{billingPlan}"> <sl:field-legend variant="label">Plan</sl:field-legend> <sl:field-description>You can change it at any time.</sl:field-description> <sl:radio-group> <sl:label><sl:radio-group-item value="monthly"/>Monthly</sl:label> <sl:label><sl:radio-group-item value="yearly"/>Yearly</sl:label> </sl:radio-group> <sl:field-error/></sl:field-set>Rendered HTML
<fieldset class="field-set" data-invalid="true" aria-describedby="billingPlan-description billingPlan-error"> <legend class="field-legend" data-variant="label">Plan</legend> <p class="field-description" id="billingPlan-description">You can change it at any time.</p> <div class="radio-group" role="radiogroup"> <label class="label"> <input type="radio" class="radio-group-item" aria-invalid="true" value="monthly" id="billingPlan1" name="billingPlan"> Monthly </label> <label class="label"> <input type="radio" class="radio-group-item" aria-invalid="true" value="yearly" id="billingPlan2" name="billingPlan"> Yearly </label> </div> <div class="field-error" id="billingPlan-error">must not be blank</div></fieldset>For checkboxes, make each option a horizontal field without a th:field of its own. It takes the field set’s, and the
next of Spring’s numbered ids (toppings1, toppings2), which its label points at.
Thymeleaf source
<sl:field-set th:field="*{toppings}"> <sl:field-legend variant="label">Toppings</sl:field-legend> <sl:field orientation="horizontal"> <sl:checkbox value="cheese"/> <sl:field-label>Cheese</sl:field-label> </sl:field> <sl:field orientation="horizontal"> <sl:checkbox value="olives"/> <sl:field-label>Olives</sl:field-label> </sl:field> <sl:field orientation="horizontal"> <sl:checkbox value="basil"/> <sl:field-label>Basil</sl:field-label> </sl:field></sl:field-set>Rendered HTML
<fieldset class="field-set"> <legend class="field-legend" data-variant="label">Toppings</legend> <div class="field" data-orientation="horizontal"> <span class="checkbox-wrapper"> <input type="checkbox" class="checkbox" value="cheese" id="toppings1" name="toppings" checked> <input type="hidden" name="_toppings" value="on"> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" 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"> <path d="M20 6 9 17l-5-5"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" 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"> <path d="M5 12h14"></path> </svg> </span> <label class="label field-label" for="toppings1">Cheese</label> </div> <div class="field" data-orientation="horizontal"> <span class="checkbox-wrapper"> <input type="checkbox" class="checkbox" value="olives" id="toppings2" name="toppings"> <input type="hidden" name="_toppings" value="on"> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" 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"> <path d="M20 6 9 17l-5-5"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" 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"> <path d="M5 12h14"></path> </svg> </span> <label class="label field-label" for="toppings2">Olives</label> </div> <div class="field" data-orientation="horizontal"> <span class="checkbox-wrapper"> <input type="checkbox" class="checkbox" value="basil" id="toppings3" name="toppings"> <input type="hidden" name="_toppings" value="on"> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" 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"> <path d="M20 6 9 17l-5-5"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" 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"> <path d="M5 12h14"></path> </svg> </span> <label class="label field-label" for="toppings3">Basil</label> </div></fieldset>variant="label" on the legend gives it the size of a field label; the default is a section heading.
Layout
Section titled “Layout”<sl:field-group> stacks fields with even spacing. A field set without th:field groups related fields under a
heading.
Thymeleaf source
<sl:field-set> <sl:field-legend>Shipping address</sl:field-legend> <sl:field-description>Where we deliver your order.</sl:field-description> <sl:field-group> <sl:field th:field="*{street}"> <sl:field-label>Street</sl:field-label> <sl:input autocomplete="street-address"/> </sl:field> <sl:field th:field="*{city}"> <sl:field-label>City</sl:field-label> <sl:input autocomplete="address-level2"/> </sl:field> </sl:field-group></sl:field-set>Rendered HTML
<fieldset class="field-set"> <legend class="field-legend">Shipping address</legend> <p class="field-description">Where we deliver your order.</p> <div class="field-group"> <div class="field"> <label class="label field-label" for="street">Street</label> <input class="input" type="text" autocomplete="street-address" id="street" name="street" value=""> </div> <div class="field"> <label class="label field-label" for="city">City</label> <input class="input" type="text" autocomplete="address-level2" id="city" name="city" value=""> </div> </div></fieldset>orientation="responsive" puts the label and the control side by side when the enclosing field group is at least
28rem wide, and stacks them when it is narrower.
Thymeleaf source
<sl:field-group> <sl:field th:field="*{displayName}" orientation="responsive"> <sl:field-content> <sl:field-label>Display name</sl:field-label> <sl:field-description>Shown next to your comments.</sl:field-description> </sl:field-content> <sl:input/> </sl:field></sl:field-group>Rendered HTML
<div class="field-group"> <div class="field" data-orientation="responsive"> <div class="field-content"> <label class="label field-label" for="displayName">Display name</label> <p class="field-description" id="displayName-description">Shown next to your comments.</p> </div> <input class="input" type="text" id="displayName" aria-describedby="displayName-description" name="displayName" value=""> </div></div>Disabled and read-only
Section titled “Disabled and read-only”A field notices a disabled control inside it and fades its label: there is no data-disabled to set, as there is in
shadcn. The description keeps its colour, since it often says why the control is disabled.
Thymeleaf source
<sl:field-group> <sl:field th:field="*{companyName}"> <sl:field-label>Company name</sl:field-label> <sl:input disabled/> <sl:field-description>Only an administrator can change it.</sl:field-description> </sl:field> <sl:field th:field="*{twoFactor}" orientation="horizontal"> <sl:switch disabled/> <sl:field-content> <sl:field-label>Two-factor authentication</sl:field-label> <sl:field-description>Required by your organisation.</sl:field-description> </sl:field-content> </sl:field></sl:field-group>Rendered HTML
<div class="field-group"> <div class="field"> <label class="label field-label" for="companyName">Company name</label> <input class="input" type="text" disabled id="companyName" aria-describedby="companyName-description" name="companyName" value="Acme"> <p class="field-description" id="companyName-description">Only an administrator can change it.</p> </div> <div class="field" data-orientation="horizontal"> <input type="checkbox" role="switch" class="switch" disabled id="twoFactor" aria-describedby="twoFactor-description" name="twoFactor" value="true" checked> <div class="field-content"> <label class="label field-label" for="twoFactor">Two-factor authentication</label> <p class="field-description" id="twoFactor-description">Required by your organisation.</p> </div> </div></div>disabled on a <sl:field-set> disables every control in it, as it does on any fieldset. The legend and the
options’ labels fade with them.
Thymeleaf source
<sl:field-set th:field="*{billingCycle}" disabled> <sl:field-legend variant="label">Billing cycle</sl:field-legend> <sl:field-description>Fixed until your contract ends.</sl:field-description> <sl:radio-group> <sl:label><sl:radio-group-item value="monthly"/>Monthly</sl:label> <sl:label><sl:radio-group-item value="yearly"/>Yearly</sl:label> </sl:radio-group></sl:field-set>Rendered HTML
<fieldset class="field-set" aria-describedby="billingCycle-description" disabled> <legend class="field-legend" data-variant="label">Billing cycle</legend> <p class="field-description" id="billingCycle-description">Fixed until your contract ends.</p> <div class="radio-group" role="radiogroup"> <label class="label"> <input type="radio" class="radio-group-item" value="monthly" id="billingCycle1" name="billingCycle"> Monthly </label> <label class="label"> <input type="radio" class="radio-group-item" value="yearly" id="billingCycle2" name="billingCycle" checked> Yearly </label> </div></fieldset>A read-only control changes nothing in the field: its label stays as it is. See the input for how it looks.
Thymeleaf source
<sl:field th:field="*{accountNumber}"> <sl:field-label>Account number</sl:field-label> <sl:input readonly/> <sl:field-description>Quote it when you contact us.</sl:field-description></sl:field>Rendered HTML
<div class="field"> <label class="label field-label" for="accountNumber">Account number</label> <input class="input" type="text" readonly id="accountNumber" aria-describedby="accountNumber-description" name="accountNumber" value="AC-58213"> <p class="field-description" id="accountNumber-description">Quote it when you contact us.</p></div>A disabled control is not submitted, so an error on it usually comes from something the user cannot change in this
form. It still shows: the control keeps its destructive border, faded, next to the field’s error. With
<sl:form-errors autofocus/>, a control with a disabled attribute is passed over and the next control with errors
takes the focus; one disabled by th:disabled or by a disabled field set is not known to be disabled when the page is
rendered, and then nothing takes the focus.
Without th:field
Section titled “Without th:field”A field without th:field is only layout. Set for to give the control its id, the label its for, and the
description and error their ids. Without a binding, data-invalid and aria-invalid are yours to set.
Thymeleaf source
<sl:field for="field-search"> <sl:field-label>Search orders</sl:field-label> <sl:input type="search" name="q"/> <sl:field-description>An order number or a customer name.</sl:field-description></sl:field>Rendered HTML
<div class="field"> <label class="label field-label" for="field-search">Search orders</label> <input class="input" type="search" name="q" id="field-search" aria-describedby="field-search-description"> <p class="field-description" id="field-search-description">An order number or a customer name.</p></div>How the parts find the field
Section titled “How the parts find the field”<sl:field> renders its content with a local variable, slField, that holds the th:field, the ids and the
messages. Every part and control inside it reads that variable, which is why nothing has to be repeated. To know
whether it has a description or an error, the field looks at its content as written: a part added with th:replace is
not seen, and a part with a th:if that is false still counts, so the control would point at a description that is
not there.
Attributes
Section titled “Attributes”sl:field
Section titled “sl:field”| Attribute | Values | Default | Description |
|---|---|---|---|
orientation | vertical horizontal responsive | vertical | Stack the label above the control, or put them side by side (a checkbox or switch, then its label). responsive stacks them in a narrow sl:field-group and puts them side by side in a wide one. |
for | string | — | The id of the control: for a field without th:field, or to use another id than the field's name. The control gets it when it has no id of its own, and the description and error ids derive from it. |
Every prop can also be set from an expression: th:orientation="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:field-label
Section titled “sl:field-label”sl:field-label declares no props; every attribute passes through.
sl:field-description
Section titled “sl:field-description”sl:field-description declares no props; every attribute passes through.
sl:field-error
Section titled “sl:field-error”sl:field-error declares no props; every attribute passes through.
sl:field-content
Section titled “sl:field-content”sl:field-content declares no props; every attribute passes through.
sl:field-set
Section titled “sl:field-set”sl:field-set declares no props; every attribute passes through.
sl:field-legend
Section titled “sl:field-legend”| Attribute | Values | Default | Description |
|---|---|---|---|
variant | legend label | legend | A section heading, or the size of a field label (for a set of options that answers one question). |
Every prop can also be set from an expression: th:variant="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:field-group
Section titled “sl:field-group”sl:field-group declares no props; every attribute passes through.