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.

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:replace with 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.

Pitfalls

Symptom Cause Fix

th:field inside a field component fails at run time

The fragment was included outside the <form th:object="..."> that provides the selection target

Include the component inside the form; check that the th:object variable exists

The field component works for title but not for a nested property

The name passed as parameter does not match the path of the form object

Pass the full path (address.city), which is also what #fields.hasErrors expects

Error resolving template [icons/plus] for an icon that exists

The resolver prefix repeats the icons/ folder (classpath:/icons/) or the file is below templates/

Use the prefix classpath:/ and the pattern icons/*, as above

Two resolvers answer for the same name

No order set on the resolvers

Set an explicit order on the extra resolver

The modal body is wrong or the include fails

~{::#confirm-body} selects an element that the calling template does not contain

Put the selected element inside the including template, with the id used in the selector

data: attributes are missing

The value is null (the attribute is omitted), or the dialect is not on the classpath

Check the value and the dependency; the dialect adds no attribute for null

A th:utext alert shows user data unescaped

th:utext for messages that may contain user data

Use th:text