Text & Attribute Setting
|
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) and Java 21, checked on
2026-10-08, and every example was compiled and run on that stack (a Spring MVC scratch app rendered through MockMvc, JDK 26 with
--release 21). The Standard Dialect attributes described here are the same standalone, with Spring MVC and with
Spring WebFlux; the only parts that need Spring are the request-aware behaviours called out in the text (the CSRF field that
th:action adds, the #{...} message source and the locale). Add-on dialects (Layout Dialect 4.0.x, the data-attribute dialect) and the
htmx libraries (htmx 2.0.x, htmx-spring-boot 5.x) are not required: htmx only appears here as an example of an attribute family
that the default attribute processor sets.
Almost every Thymeleaf template is made of two operations: replace the body of an element with text and set, change or remove an
attribute of an element. Both are done with th:* attributes that are processed when the template is rendered and removed from the output, so the
rest of the markup stays valid HTML that a browser can open as a static prototype (a natural template). This page covers all
of them: the text processors, th:attr, the specific attribute processors, the appending processors, the boolean attributes and the
default attribute processor, and the one security rule that connects them: which positions are evaluated in restricted mode.
The expressions placed inside the attribute values (${...}, #{...}, @{...} and the rest) are explained on the Standard Expression Syntax page; the URL expressions in th:href/th:src on the Link URLs page; [[...]] inlining (Inlining), text and form binding
(th:field, Forms & Data Binding) on pages of their own.
The model of a processed attribute
An attribute processor is registered in a dialect under a prefix (here th) and a name (text, href, class…). When the engine
meets th:text="${book.title}", it evaluates the expression, applies the processor (replace the body of the element), and drops the
th:text attribute from the output. The static content you wrote in the element is only the prototype for designers:
p th:text=book.title: Prototype title"] --> parse["Parse
element + th attributes"] parse --> eval["Evaluate the expression
against the context"] eval --> proc["Attribute processor
text: set the body"] proc --> out["Output
p: Dune"] tpl -. "opened in a browser
without the engine" .-> proto["Prototype title"]
The processors in this page fall into five families, and picking the right one is most of the work:
| Family | Attributes | What it does |
|---|---|---|
Body text |
|
Replaces the element body with escaped ( |
One attribute |
|
Sets one attribute; the processor is named after the attribute it sets. |
Several at once |
|
Sets two attributes to the same value. |
Add to an existing value |
|
Suffixes or prefixes the existing attribute value instead of replacing it. |
Conditional presence |
|
Writes the attribute with its fixed value, or leaves it out. |
th:text: replacing the body with escaped text
th:text evaluates its expression and replaces the body of the element with the result, after HTML-escaping it. The element
itself and its other attributes are untouched. Because the escaping happens for you, th:text is the default choice for any value that
could contain <, >, & or a quote, which means any data that came from a user or a database. The first example uses the
Bookshelf’s messages.properties bundle (the #{...} expression is a message lookup, resolved with the request locale) and a value
that deliberately contains markup:
books.title=Books
text.tagline=Find your next <b>favourite</b> book
text.greeting=Welcome, {0}!
@Controller
public class CardController {
public record BookCard(Long id, String title, String blurb, boolean available, int copies, String level) { }
@GetMapping("/text/texts")
public String texts(Model model) {
model.addAttribute("book", new BookCard(7L, "Dune",
"A <em>desert</em> planet & a <script>alert('x')</script> prophecy", true, 3, "warning"));
model.addAttribute("user", "Ada");
return "text/texts";
}
}
<h1 th:text="#{books.title}">Books</h1>
<p th:text="#{text.greeting(${user})}">Welcome, reader!</p>
<p th:text="${book.blurb}">Prototype blurb</p>
<p th:text="|${book.title} (${book.copies} copies)|">Prototype</p>
<p>Title: <span th:text="${book.title}">Prototype title</span></p>
The rendered HTML. The markup in the blurb is shown as text, not executed, which is the protection against cross-site scripting:
<h1>Books</h1>
<p>Welcome, Ada!</p>
<p>A <em>desert</em> planet & a <script>alert('x')</script> prophecy</p>
<p>Dune (3 copies)</p>
<p>Title: <span>Dune</span></p>
Source: Using Thymeleaf — Using th:text and externalizing text, Using Thymeleaf — Using and displaying variables
A few points about the example:
-
th:text="#{text.greeting(${user})}"is a message expression with an argument; the message is looked up in the SpringMessageSource(a plainResourceBundlestandalone). The Internationalization page covers bundles, locales and message arguments in depth. -
|...|is a literal substitution: it joins text and variables without+signs and quotes. -
The text between the tags (
Prototype title) is only what a browser shows when the file is opened without the engine. It is replaced by the processor, so changing it does not change the application. -
The engine escapes
<,>,&,"and'(the single quote becomes'), which is what makes the value safe both in element bodies and, withth:attrand the specific attribute processors, in quoted attribute values. -
If the same text is meant to appear in the middle of other text, you can write
[[${book.title}]]instead of a<span>; it is the inlined form ofth:textand is covered on the Inlining page.
th:utext: unescaped text and the XSS decision
th:utext ("unescaped text") does the same replacement but writes the result without escaping it. Its legitimate use is a value
that is meant to contain markup and that your own code controls, such as a message from a resource bundle written by the
development team. In the Bookshelf the tagline message holds a <b> element:
<p th:utext="#{text.tagline}">Find your next favourite book</p>
<p th:text="#{text.tagline}">Find your next favourite book</p>
<p>Find your next <b>favourite</b> book</p>
<p>Find your next <b>favourite</b> book</p>
Source: Using Thymeleaf — Unescaped Text
The first line shows the bold text, the second the literal tags a visitor would see if the markup were escaped. Never use
th:utext for data that originated outside your code (form input, request parameters, database rows filled by users, third-party
API responses): the browser would run any script the value contains. When user-supplied rich text is a requirement, sanitise it
with an HTML sanitiser on the way in (not in the template) and then store the cleaned result; escaping at output with th:text is
the rule for everything else. Thymeleaf’s restrictions are only a defence in depth: Appendix D of the tutorial states that they are
not a substitute for input validation and sanitising.
th:utext is evaluated in restricted expression mode (see the section on restricted mode below). A request parameter is forbidden in that position:
<p th:utext="${param.q}">x</p>
org.thymeleaf.exceptions.TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is forbidden in this context (template: "text/r-utext" - line 4, col 6)
The same expression in th:text renders the escaped value (<b>x for ?q=<b>x). The restriction is on the expression, not
on what the data is: it blocks ${param.*}, new expressions, static members (T(...) in SpEL) and a few utility objects, and it
is not a validator of the data you put in the model.
th:attr: setting any attribute
th:attr takes a comma-separated list of name=expression assignments and sets each attribute on the element. Because XML rules forbid
repeating an attribute on an element, the list is how several attributes are set from one processor:
<form action="subscribe.html" th:attr="action=@{/subscribe}" method="post">
<input type="text" name="email">
<input type="submit" value="Subscribe!" th:attr="value=#{text.subscribe}">
</form>
<img src="logo.png" th:attr="src=@{/images/logo.png},title=#{text.logo},alt=#{text.logo}">
<form action="/subscribe" method="post"><input type="hidden" name="_csrf" value="..."/>
<input type="text" name="email">
<input type="submit" value="Subscribe">
</form>
<img src="/images/logo.png" title="Bookshelf logo" alt="Bookshelf logo">
Two details deserve attention. First, the form gained a hidden _csrf field: that comes from Spring Security through the
RequestDataValueProcessor integration described below, not from th:attr. Second, the tutorial itself says th:attr is scarcely used:
it is ugly, and the specific processors in the next section do the same job with less markup. Use it for attributes that have no
specific processor and that you do not want to write with the default attribute processor, or when you want to set several attributes in one place.
When th:attr creates an attribute with an arbitrary name it is evaluated in restricted mode.
The specific attribute processors
For nearly every HTML attribute there is a th:<name> processor that sets exactly that attribute with the result of its expression.
th:value="${book.title}" sets value, th:id sets id, th:href sets href (with the context path prepended for @{...}
links) and so on. They are the everyday tools and the reason th:attr is seldom needed. The tutorial lists them all; the table
groups the complete list by purpose (every name is written with the th: prefix: th:abbr, th:accept…).
| Purpose | Attributes |
|---|---|
URLs and resources |
|
Form controls |
|
Tables and layout |
|
Identity, language and metadata |
|
Media |
|
Also in the tutorial’s list |
|
The same list contains the whole family of event-handler attributes, th:on followed by the event name:
onabort, onafterprint, onbeforeprint, onbeforeunload, onblur, oncanplay, oncanplaythrough, onchange, onclick,
oncontextmenu, ondblclick, ondrag, ondragend, ondragenter, ondragleave, ondragover, ondragstart, ondrop,
ondurationchange, onemptied, onended, onerror, onfocus, onformchange, onforminput, onhashchange, oninput,
oninvalid, onkeydown, onkeypress, onkeyup, onload, onloadeddata, onloadedmetadata, onloadstart, onmessage,
onmousedown, onmousemove, onmouseout, onmouseover, onmouseup, onmousewheel, onoffline, ononline, onpause,
onplay, onplaying, onpopstate, onprogress, onratechange, onreadystatechange, onredo, onreset, onresize,
onscroll, onseeked, onseeking, onselect, onshow, onstalled, onstorage, onsubmit, onsuspend, ontimeupdate,
onundo, onunload, onvolumechange and onwaiting. They have a special restriction, explained below.
A realistic Bookshelf fragment uses several of them together. th:id builds a unique id with a string concatenation, th:value fills a
form control, th:href builds a link with a path variable and th:title adds a tooltip:
<input id="book" th:id="'book-' + ${book.id}" th:value="${book.title}" value="Prototype" name="title">
<a href="book.html" th:href="@{/books/{id}(id=${book.id})}" th:title="${book.title}">Dune</a>
<input id="book-7" value="Dune" name="title">
<a href="/books/7" title="Dune">Dune</a>
Notes on how they behave:
-
The static attribute (
id="book",value="Prototype") is the prototype value. The processor replaces it, and theth:*attribute is removed, so the output has oneid. -
An expression that evaluates to
nullremoves the attribute. In the scratch app<p th:title="${null}" title="proto">rendered as<p>. An empty string also removedth:titleandth:alt(<img th:alt="''" alt="p">rendered as<img>), whileth:value="''"kept an emptyvalue="", which is what a form control needs. If a rendered page is missing an attribute you expected, check for a null or empty value first. -
th:classreplaces the whole class attribute (<p th:class="${book.available} ? 'ok' : 'gone'" class="proto">gaveclass="ok"); to add a class to an existing one useth:classappend(below). -
th:actionand the other form attributes take a link expression. In a Spring application a form withmethod="post"and ath:actionreceives an extra hidden_csrfinput from Spring Security. Thymeleaf-Spring calls theRequestDataValueProcessorforth:action,th:valueand related attributes, and Spring Security registers one that adds the CSRF token; the Spring integration is described in the Thymeleaf + Spring tutorial. -
th:hrefandth:srcare in the restricted-mode list for the base of the URL (not for the parameters); the Link URLs page of this section covers URLs.
Setting two attributes at once
Two special processors set a pair of attributes to the same value: th:alt-title sets alt and title, and th:lang-xmllang sets lang
and xml:lang. They save repetition for the cases where the pair must stay in sync, as with an image description or an XHTML language declaration:
<img src="logo.png" th:src="@{/images/logo.png}" th:alt-title="#{text.logo}">
<html lang="en" th:lang-xmllang="es"></html>
<img src="/images/logo.png" alt="Bookshelf logo" title="Bookshelf logo">
<html lang="es" xml:lang="es"></html>
The first image is equivalent to th:src plus th:alt plus th:title with the same expression. HTML5 pages normally only need lang;
xml:lang matters when the page is also served as XHTML.
Appending and prepending
th:attrappend and th:attrprepend add the result of their expression to the end or the start of the existing attribute value
instead of replacing it. The value comes from a variable, so the template does not know the class in advance, and the space that separates two class
names must be part of the added text:
<input type="button" value="Do it!" class="btn" th:attrappend="class=${' ' + cssStyle}">
<input type="button" value="Do it!" class="btn" th:attrprepend="class=${cssStyle + ' '}">
<input type="button" value="Do it!" class="btn warning">
<input type="button" value="Do it!" class="warning btn">
Because the pair is generic, the Standard Dialect adds two specialised versions for the two attributes that are most often built up, so you do not have to remember the separators:
th:classappend adds a CSS class and th:styleappend adds a style fragment, both without overwriting what the markup already has.
They are the way to switch a class on a condition. In the Bookshelf, an odd row of a table gets an extra class (s.odd comes from the iteration
status variable, explained on the Iteration & Conditionals page):
<tr th:each="t, s : ${tags}" class="row" th:classappend="${s.odd} ? 'odd'"><td th:text="${t}">tag</td></tr>
<p style="color: red;" th:styleappend="${book.available} ? 'font-weight: bold'">Available</p>
<tr class="row odd"><td>sci-fi</td></tr>
<tr class="row"><td>classic</td></tr>
<p style="color: red; font-weight: bold">Available</p>
Note the inline condition ${cond} ? 'odd': with no : branch it evaluates to nothing when the condition is false, so no
class is added (that is the "if-then" form of the conditional expression, which is covered on the Standard Expression Syntax page). On Thymeleaf 3.1.5 the
two parts of a style are joined with a single space and no separator is added. If the static style does not end with a semicolon, the result is not valid CSS:
<p style="color: red" th:styleappend="'font-weight: bold'"> rendered style="color: red font-weight: bold". Always finish the static style
with ; or start the appended fragment with ;.
Fixed-value boolean attributes
HTML has boolean attributes: attributes with no meaningful value where the presence of the attribute means true (checked,
disabled…). XHTML writes the value as the attribute’s own name (checked="checked"). The Standard Dialect provides a processor for
each of them, and each takes a condition: if it is true, the attribute is written with its fixed value; if it is false, the attribute is left out. The complete list, each
with the th: prefix, is:
async, autofocus, autoplay, checked, controls, declare, default, defer, disabled, formnovalidate, hidden,
ismap, loop, multiple, novalidate, nowrap, open, pubdate, readonly, required, reversed, scoped, seamless,
selected.
The processors use the usual Thymeleaf truthiness rules (the full table is on the Iteration & Conditionals page of this section). These Bookshelf examples show the common conditions and what the engine does with unusual values:
<input type="checkbox" name="available" th:checked="${book.available}">
<input type="checkbox" name="gone" checked th:checked="${!book.available}">
<select><option value="a" th:selected="${book.copies > 2}">A</option></select>
<input type="text" name="isbn" th:required="${book.available}" th:disabled="${book.copies == 0}" th:readonly="true">
<details th:open="${book.available}"><summary>More</summary></details>
<input type="checkbox" th:checked="'on'">
<input type="checkbox" th:checked="'off'">
<input type="checkbox" th:checked="${book.copies}">
<input type="checkbox" th:checked="0">
<input type="checkbox" th:checked="null">
<input type="checkbox" th:checked="''">
<input type="checkbox" name="available" checked="checked">
<input type="checkbox" name="gone">
<select><option value="a" selected="selected">A</option></select>
<input type="text" name="isbn" readonly="readonly" required="required">
<details open="open"><summary>More</summary></details>
<input type="checkbox" checked="checked">
<input type="checkbox">
<input type="checkbox" checked="checked">
<input type="checkbox">
<input type="checkbox">
<input type="checkbox" checked="checked">
The static checked in the second line is the prototype: because the condition is false, the output has no checked at all. The
last six lines show that 'on', a non-zero number and the empty string are true, while 'off', 0 and null are false.
Do not set a boolean attribute from a string that holds "false" unless you know the engine reads it as false; pass a real boolean from the
model. For a form bound to a model object, th:field (Thymeleaf + Spring) writes checked and selected for you, which is the Forms & Data Binding page.
The default attribute processor
Not every attribute has a processor. The default attribute processor fills that gap: any th: attribute whose name no dialect processor
claims is turned into an attribute of the same name, set to the result of the expression. This is how data, ARIA and htmx attributes are written without
th:attr. It is the processor that makes th:data-*, th:aria-* and th:hx-* work:
<span th:whatever="${book.title}">x</span>
<li th:data-isbn="${book.id}" th:data-title="${book.title}" th:aria-label="'Open ' + ${book.title}">Dune</li>
<button th:hx-get="@{/books/{id}(id=${book.id})}" hx-target="#detail" th:hx-vals="|{"id": ${book.id}}|">Open</button>
<span whatever="Dune">x</span>
<li data-isbn="7" data-title="Dune" aria-label="Open Dune">Dune</li>
<button hx-get="/books/7" hx-target="#detail" hx-vals="{"id": 7}">Open</button>
Three consequences:
-
Attributes that the markup does not know are still written. A misspelt name such as
th:hrfproduces ahrfattribute and no error, which is the typical reason for "the link has no URL" after a typo. A name with a processor always wins over the default. -
htmx attributes that depend on data (
hx-getwith a path variable,hx-valswith JSON) are set withth:hx-*; the static ones (hx-target) need noth:. Prefer quoting JSON by building the value in Java and passing the text, so the template never concatenates user text into JSON. -
The default attribute processor runs in restricted mode, the same as
th:attrwith an arbitrary name.th:data-q="${param.q}"failed with the same exception as theth:utextexample, andth:attr="data-q=${param.q}"did too. Putting request-dependent values into the model in the controller (after validation) is the supported way.
HTML5-friendly names: data-th-* and th-block
The th: prefix is an XML namespace habit. A document that has to be valid strict HTML5 can use the data- form for every
processor: data-{prefix}-{name}, which for the Standard Dialect gives data-th-*. The two forms are equivalent, and the namespaced one is not deprecated:
<ul><li data-th-each="t : ${tags}" data-th-text="${t}">tag</li></ul>
<button data-th-hx-get="@{/books}" data-th-text="${book.title}">Open</button>
<ul><li>sci-fi</li><li>classic</li></ul>
<button hx-get="/books">Dune</button>
The same section defines a syntax for custom elements, {prefix}-{name}, following the W3C Custom Elements convention. The only such
element the Standard Dialect needs is the synthetic block that iterates or conditions without adding markup, written th:block or th-block:
<th-block th:each="t : ${tags}"><i th:text="${t}">t</i></th-block>
<th:block th:each="t : ${tags}"><b th:text="${t}">t</b></th:block>
<i>sci-fi</i><i>classic</i>
<b>sci-fi</b><b>classic</b>
The element is th-block, not a data-th-block attribute (an element name cannot be a data- attribute). Both th:block and th-block disappear from the output and
keep their children; the Local Variables, Attribute Precedence, Comments & Blocks page covers it in full.
Restricted mode for th:utext and th:on* attributes
Appendix D of the tutorial defines a restricted expression evaluation mode that is applied automatically in specific positions. Of those, the ones that belong to this page are:
| Position | Why |
|---|---|
|
The output is written without escaping. |
All |
The value is JavaScript code. Variable expressions in them must evaluate to a number or a boolean. |
|
The name of the attribute is open-ended, so any value could end up in a sensitive attribute. |
|
URL parameters in link expressions are not restricted; the base is. |
In restricted mode these are forbidden: access to request parameters (${param.*}), instantiation with new, static classes and members
(T(...) in SpEL, @identifier@ in OGNL), some context-level utility objects such as #execInfo, and, in Spring applications,
@beanName bean references. Breaking the rules raises a TemplateProcessingException at render time. For th:on* there is an additional rule: a
variable expression that produces anything other than a number or a boolean is rejected.
A number is accepted, so an id in a confirmation call works:
<button type="button" th:onclick="|confirmDelete(${book.id})|">Delete</button>
<button type="button" onclick="confirmDelete(7)">Delete</button>
A string is not. <button th:onclick="|confirm(${book.title})|"> threw:
org.thymeleaf.exceptions.TemplateProcessingException: Only variable expressions returning numbers or booleans are allowed in this context, any other datatypes are not trusted in the context of this expression, including Strings or any other object that could be rendered as a text literal. A typical case is HTML attributes for event handlers (e.g. "onload"), in which textual data from variables should better be output to "data-*" attributes and then read from the event handler. (template: "text/r-onclick" - line 4, col 11)
The error message gives the solution: output the text to a data-* attribute (escaped by the normal attribute rules) and read it in the handler.
<button type="button" th:data-title="${book.title}" onclick="confirmTitle(this.dataset.title)">Title</button>
<button type="button" data-title="Dune" onclick="confirmTitle(this.dataset.title)">Title</button>
Source: Using Thymeleaf — Appendix D: Expression Restrictions, Using Thymeleaf — Setting value to specific attributes
The more general advice is to avoid inline event handlers and attach listeners from a JavaScript file; if data must reach script from the template, see the Inlining page for JavaScript inlining and the safe way to pass server data. The restrictions are the same on Thymeleaf 3.1.5 (the minimum version this section teaches, since earlier 3.1 releases have published advisories on the expression sandbox) in a standalone engine and in Spring MVC or WebFlux.
A test for the rendered attributes
Since the processors only change markup, a rendering test is the cheapest regression net. With @SpringBootTest and MockMvc (the scratch app used the
Boot 4 test starter spring-boot-starter-webmvc-test), assert on the HTML:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class TextAttributeTest {
@Autowired MockMvc mvc;
@Test
void textIsEscaped() throws Exception {
String html = mvc.perform(get("/text/texts").header("Accept-Language", "en"))
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("<script>").doesNotContain("<script>");
}
}
AutoConfigureMockMvc lives in org.springframework.boot.webmvc.test.autoconfigure in Boot 4. If the bundle has a locale-specific file and the machine’s default
locale is not the one you expect, set spring.messages.fallback-to-system-locale=false so tests render the base bundle.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Markup from the model shows as text |
|
Keep it; use |
A |
|
Use |
|
|
Compute and validate the value in the controller, put it in the model |
|
A string variable in a |
Write it to a |
An attribute disappears |
The expression is |
Check the value; provide a default with the Elvis operator |
A |
The default attribute processor accepts any name |
Fix the spelling; compare the output with the table |
|
|
Use |
|
|
End the static style with |
A checkbox stays checked |
The condition evaluates true for |
Pass a real boolean, or negate the condition |
The form has an unexpected |
Spring Security’s |
Expected; keep CSRF protection on |