Reusable UI Components
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9), thymeleaf-extras-data-attribute 2.0.1 and Java 21,
checked on 2026-10-08, and every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, MockMvc); the rendered
output shown is the real output of those runs. The fragments are Standard Dialect features and work standalone, with Spring MVC and with Spring WebFlux; the form
field and pagination components use Spring features (th:field, Page) and are shown with Spring MVC. The running scenario is the Bookshelf introduced on the
Getting Started page.
Once the fragments and the layout exist, the next step is a small component library:
a single template, here templates/ui/components.html, in which every th:fragment is one reusable piece of UI with a documented signature. The page templates then read as a list of
components, the CSS classes live in one place, and a design change is a change to one fragment. The cost is a naming convention to keep up and the lack of compile-time checking: a wrong
argument count fails at run time, so cover the components with a few rendering tests.
A fragment component library
The convention used here: one file per family or one file for a small application, fragment names in lower-case with hyphens, parameters in a signature with
th:assert for the ones that must not be empty, and prototype text in each fragment so the file is a valid page when opened in a browser. Callers use th:replace, so the component
element (not a wrapper) lands in the page.
Form fields with preprocessing
A form field is a label, an input and an error message, repeated for every attribute of the form object. A fragment that takes the field name as a parameter removes the repetition. The
field name has to reach th:field, whose value is a selection expression, and that is what preprocessing (__${...}__) is for: the inner expression is evaluated first, and its result becomes part
of the outer expression (see Literals, Operators & Preprocessing).
<div th:fragment="input-field(fieldName, label, type)" class="field"
th:classappend="${#fields.hasErrors(fieldName)} ? 'has-error'">
<label th:for="${fieldName}" th:text="${label}">Label</label>
<input th:type="${type}" th:field="*{__${fieldName}__}">
<p class="error" th:if="${#fields.hasErrors(fieldName)}" th:errors="*{__${fieldName}__}">Error</p>
</div>
The form uses it for each property. The fragment is processed inside the form, so *{...} is resolved against the th:object of the enclosing <form>:
<form th:object="${book}" th:action="@{/ui/save}" method="post">
<div th:replace="~{ui/components :: input-field('title', 'Title', 'text')}"></div>
<div th:replace="~{ui/components :: input-field('author', 'Author', 'text')}"></div>
<button th:replace="~{ui/components :: button('Save', 'primary')}"></button>
</form>
Rendered after a post with a blank title (the hidden CSRF input that Spring adds to the form is omitted from the output below):
<form action="/ui/save" method="post">
<div class="field has-error">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="">
<p class="error">must not be blank</p>
</div>
<div class="field">
<label for="author">Author</label>
<input type="text" id="author" name="author" value="Ana">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
Preprocessing is evaluated in restricted expression mode (U Appendix D), so the field name must be a plain value that the template (or the controller) supplies, never anything taken from the request. A literal
passed from the including template, as above, is safe. The same technique with th:field, the validation helpers and the checkbox and select variants belongs to Forms & Data Binding; the full form story (binding, errors, CSRF) is not repeated here.
Buttons and alerts
Components that only vary by a style are the simplest ones: a parameter selects the variant, and th:classappend adds the matching class to the fixed classes of the prototype.
<button th:fragment="button(label, kind)" type="submit" class="btn"
th:classappend="|btn-${kind}|" th:text="${label}">Save</button>
<div th:fragment="alert(kind, message)" role="alert" class="alert"
th:classappend="|alert-${kind}|" th:text="${message}">Message</div>
<div th:replace="~{ui/components :: alert('success', 'Book saved')}"></div>
Rendered: <div role="alert" class="alert alert-success">Book saved</div>. The message goes through th:text, so it is HTML-escaped; never use th:utext for a message that contains user data.
Modals: passing markup
A modal has a shell (the dialog element, the title, the accessibility attributes) and a body that differs for each use. The body is passed as markup, the technique from the Fragments page: the
caller selects its own element with ~{::#id} and the component places it with th:replace.
<div th:fragment="modal(id, title, body)" class="modal" role="dialog" aria-modal="true"
th:id="${id}" th:attr="aria-labelledby=${id + '-title'}">
<h2 th:id="${id + '-title'}" th:text="${title}">Title</h2>
<div class="modal-body"><th:block th:replace="${body}">Body</th:block></div>
</div>
<div th:replace="~{ui/components :: modal('confirm', 'Delete book', ~{::#confirm-body})}">
<p id="confirm-body">Really delete <strong th:text="${book.title}">Dune</strong>?</p>
</div>
Rendered:
<div class="modal" role="dialog" aria-modal="true"
id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete book</h2>
<div class="modal-body"><p id="confirm-body">Really delete <strong>Dune</strong>?</p></div>
</div>
The markup is evaluated in the context of the caller, so ${book.title} is the caller’s variable. The Layout Dialect’s layout:insert offers a similar slot mechanism with named
fragments; see Layouts & the Layout Dialect. Opening and closing the dialog is a matter of JavaScript or htmx (see Alpine.js & Web Components and htmx Fundamentals), not of the fragment.
Pagination
A pagination component takes a Spring Data Page and a base URL. It renders nothing for a single page (th:if on the root of the fragment), builds a link per page with a link expression whose
parameters are added to the base URL, and marks the current page. The base URL is the base part of a URL expression, which is evaluated in restricted mode, so it must come from the template or the controller.
<nav th:fragment="pagination(page, baseUrl)" th:if="${page.totalPages > 1}" aria-label="Pagination">
<ul class="pagination">
<li th:each="i : ${#numbers.sequence(0, page.totalPages - 1)}"
th:classappend="${i == page.number} ? 'active'">
<a th:href="@{${baseUrl}(page=${i})}" th:text="${i + 1}"
th:attr="aria-current=${i == page.number} ? 'page'">1</a>
</li>
</ul>
</nav>
Used in a page as
<nav th:replace="~{ui/components :: pagination(${page}, '/books')}"></nav>
with a Page of three pages whose current page is the second, the output is:
<nav aria-label="Pagination">
<ul class="pagination">
<li>
<a href="/books?page=0">1</a>
</li>
<li class="active">
<a href="/books?page=1" aria-current="page">2</a>
</li>
<li>
<a href="/books?page=2">3</a>
</li>
</ul>
</nav>
A production version also keeps the sort and the filter in the query string, shows "previous" and "next" links and collapses long ranges. Those refinements are covered on Displaying Data, Pagination & Sorting, as is Spring Data’s Pageable handling.
Source: Using Thymeleaf — Link URLs
Menu items with an active state
A menu item compares its own key with a model attribute that says which item is current. The attribute can be set by a @ModelAttribute method in a @ControllerAdvice, by each controller, or
by an interceptor. Because the comparison is an ordinary expression, the fragment is a few lines.
<li th:fragment="menu-item(key, href, label)" th:classappend="${activeMenu == key} ? 'active'">
<a th:href="@{${href}}" th:text="${label}" th:attr="aria-current=${activeMenu == key} ? 'page'">Books</a>
</li>
<ul>
<li th:replace="~{ui/components :: menu-item('books', '/books', 'Books')}"></li>
<li th:replace="~{ui/components :: menu-item('authors', '/authors', 'Authors')}"></li>
</ul>
Rendered with activeMenu set to books:
<ul>
<li class="active">
<a href="/books" aria-current="page">Books</a>
</li>
<li>
<a href="/authors">Authors</a>
</li>
</ul>
Deriving the active item from the URL is possible, but the 3.1 context no longer exposes #request, #response, #session and #servletContext (removed in Thymeleaf 3.1, see the archived what’s-new article), so the model attribute above is the portable choice and keeps the template independent of the servlet API.
The Taming Thymeleaf book builds the same menu-item component with th:classappend and shows the IDE hint @thymesVar for typing the variables used inside fragments; that hint belongs to
Tooling & IDE Support. The book’s examples (Thymeleaf 3.0, Boot 2.3) still work in spirit, but use th:insert/th:replace with ~{...} rather than the unwrapped syntax it predates.
Inline SVG icons through an extra resolver
Icons are best served inline: an <img> cannot take its colour from currentColor or be styled by the surrounding CSS. Thymeleaf can read an SVG file as a template in XML mode and insert it
as markup. This takes a second SpringResourceTemplateResolver that answers only for names under icons/, reading files from src/main/resources/icons/. The configuration is the one introduced on
Spring Boot Auto-configuration & Properties, where the prefix gotcha (the prefix is classpath:/ because the template name already
contains icons/) is explained:
@Configuration(proxyBeanMethods = false)
class ThymeleafExtrasConfig {
@Bean
SpringResourceTemplateResolver iconTemplateResolver(ApplicationContext context) {
var resolver = new SpringResourceTemplateResolver();
resolver.setApplicationContext(context);
resolver.setPrefix("classpath:/");
resolver.setSuffix(".svg");
resolver.setResolvablePatterns(Set.of("icons/*"));
resolver.setTemplateMode(TemplateMode.XML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCheckExistence(true);
resolver.setOrder(2);
return resolver;
}
}
A small fragment hides the lookup behind a component, so templates ask for an icon by name:
<span th:fragment="icon(name)" class="icon"><th:block th:insert="~{icons/__${name}__}"/></span>
<a href="/books/new"><span th:replace="~{ui/components :: icon('plus')}"></span> New book</a>
With icons/plus.svg holding a 16 by 16 SVG whose path uses currentColor, the page renders the SVG inline, so the link’s text colour colours the icon:
<a href="/books/new"><span class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</span> New book</a>
The icon name is preprocessed into a template name, which is evaluated in restricted mode and which must never come from user input. Because the resolver is restricted to icons/* by setResolvablePatterns
and setCheckExistence makes a missing icon fall through to the next resolver, a wrong name still produces a TemplateInputException ("Error resolving template") rather than leaking another file.
Icon sets with many files can also use a sprite sheet and <use href="...">, which needs no resolver at all, at the price of one request.
The data-attribute dialect
HTML5 data-* attributes carry state to JavaScript. Writing them with th:attr="data-title=${...}" or the default attribute processor works but is verbose. The small
thymeleaf-extras-data-attribute dialect adds a data: prefix: data:name="expression" is rendered as data-name="value". Its latest release is 2.0.1 (2016), which still works with Thymeleaf 3.1, and
Spring Boot’s BOM manages the version, so the dependency needs no <version>.
<dependency>
<groupId>com.github.mxab.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-data-attribute</artifactId>
</dependency>
Spring Boot registers the DataAttributeDialect automatically when the library is on the classpath (the DataAttributeDialectConfiguration of the auto-configuration, which backs off if a bean of that type exists);
outside Boot, add it with templateEngine.addDialect(new DataAttributeDialect()).
<div data:book-id="${book.id}" data:title="${book.title}" data:state="${'open'}" data:msg="#{books.title}">hi</div>
Rendered, for a book with title Dune, no id yet, and the English message bundle (the books.title message is "Books"):
<div data-title="Dune" data-state="open" data-msg="Books">hi</div>
Notice that data:book-id is absent from the output: a null value omits the attribute, as for any Thymeleaf attribute processor. Compare with th:attr, which can be used for the same effect without a
dependency; the dialect is a convenience, not a necessity. Values written to attributes are escaped, but as with every attribute, user data placed into attributes that browsers interpret as code (event handlers)
needs the care described on Template Security & XSS.
Web components or a custom dialect instead
Fragments are the right tool while a component is "some markup with holes". Two situations call for something else.
-
Behaviour that lives in the browser. If a component needs its own state, events and encapsulated styling (a date picker, a rich-text editor, a chart), build it as a web component (custom elements) and let Thymeleaf render the element and its attributes, passing data with
data:attributes or properties. The server-rendered page works without JavaScript for the parts that do not need it, and the component can be reused outside Thymeleaf. The Alpine.js and web components page of this section covers how Alpine and web components fit with server-side rendering. -
Repeated markup patterns that deserve an element of their own. When many templates repeat the same fragment call with the same boilerplate (
th:replacewith a long signature) and you want<bs:alert kind="danger">...</bs:alert>, write a custom dialect with an element tag processor. That gives templates a domain vocabulary and keeps the library in Java, at the price of more code to test and maintain, and of templates that no longer render in a plain browser without the dialect. Extending Thymeleaf is described in the third official tutorial, "Extending Thymeleaf"; the Extending Thymeleaf page works through the dialect and processor API.
A rule of thumb: start with a fragment; move to a custom dialect when the signature becomes unwieldy in many places; move to a web component when the behaviour, not the markup, is the thing being reused.
Source: Extending Thymeleaf
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
The fragment was included outside the |
Include the component inside the form; check that the |
The field component works for |
The name passed as parameter does not match the path of the form object |
Pass the full path ( |
|
The resolver prefix repeats the |
Use the prefix |
Two resolvers answer for the same name |
No order set on the resolvers |
Set an explicit |
The modal body is wrong or the include fails |
|
Put the selected element inside the including template, with the id used in the selector |
|
The value is |
Check the value and the dependency; the dialect adds no attribute for |
A |
|
Use |