Skip to content

Field

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>

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 and id, as th:field on the control would give;
  • <sl:field-label> with a for that points at the control;
  • <sl:field-description> with an id, and the control’s aria-describedby pointing at it;
  • <sl:field-error> showing the binding’s messages, and the control described by them while there are any;
  • aria-invalid on the control and data-invalid on 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.

<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.

With errorsA second submit renders the form again. The field shows the binding's messages and is marked data-invalid; the input is aria-invalid and described by the message.
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>
Several errors
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.

A textarea and a native select work as the input does:

Textarea
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>
Native select
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>

orientation="horizontal" puts the control first and the label beside it. <sl:field-content> stacks the label with a description or an error.

SwitchA horizontal field puts the control first. field-content stacks the label with its description.
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>
CheckboxIn a field, the checkbox gets the field's name as its id, not Spring's numbered one, so the label points at it.
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>

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.

Radio groupThe field-set holds the th:field, which its radio buttons share. The description and the error describe the fieldset.
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.

Checkbox groupEach option is a field without th:field of its own. It takes the field-set's, with the next of Spring's numbered ids.
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.

<sl:field-group> stacks fields with even spacing. A field set without th:field groups related fields under a heading.

Field set and field group
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.

ResponsiveSide by side in a wide field-group, stacked in a narrow one.
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>

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.

DisabledThe label fades with a disabled control, found by the field itself; the description stays legible, as it often says why.
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.

Disabled field setdisabled on the field set disables every control in it, as on a fieldset; the legend and 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.

Read-onlyA read-only input keeps its label as it is. It still takes focus, and its value is submitted.
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.

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.

Without th:fieldfor gives the control its id, and the description and error their ids.
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>

<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.

AttributeValuesDefaultDescription
orientationvertical horizontal responsiveverticalStack 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.
forstring—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 declares no props; every attribute passes through.

sl:field-description declares no props; every attribute passes through.

sl:field-error declares no props; every attribute passes through.

sl:field-content declares no props; every attribute passes through.

sl:field-set declares no props; every attribute passes through.

AttributeValuesDefaultDescription
variantlegend labellegendA 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 declares no props; every attribute passes through.