| id | html |
|---|---|
| title | HTML |
zio-blocks-html is a type-safe HTML templating library providing immutable data structures and a fluent DSL for building HTML, CSS, and JavaScript. It offers compile-time safety, automatic XSS protection, and zero-dependency simplicity across Scala 2.13, 3.x, and both JVM and Scala.js platforms.
Core types: Dom (HTML tree), CssSelector (CSS queries), DomSelection (DOM navigation), Css (stylesheets), Js (JavaScript expressions).
The module is structured around these core types:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
// Dom variants
sealed trait Dom
case class Dom.Text(content: String) extends Dom
sealed trait Dom.Element extends Dom
case class Dom.Element.Generic(tag: String, attributes: Chunk[Dom.Attribute], children: Chunk[Dom]) extends Dom.Element
case class Dom.Element.Script(attributes: Chunk[Dom.Attribute], children: Chunk[Dom]) extends Dom.Element
case class Dom.Element.Style(attributes: Chunk[Dom.Attribute], children: Chunk[Dom]) extends Dom.Element
// Attribute variants
sealed trait Dom.Attribute
case class Dom.Attribute.KeyValue(name: String, value: Dom.AttributeValue) extends Dom.Attribute
case class Dom.Attribute.AppendValue(name: String, value: Dom.AttributeValue, separator: Dom.AttributeSeparator) extends Dom.Attribute
case class Dom.Attribute.BooleanAttribute(name: String, enabled: Boolean) extends Dom.Attribute
// CssSelector variants (additional types omitted for brevity)
sealed trait CssSelector
case class CssSelector.Element(tag: String) extends CssSelector
case class CssSelector.Class(name: String) extends CssSelector
// Css variants
sealed trait Css
case class Css.Rule(selector: CssSelector, declarations: Chunk[Css.Declaration]) extends Css
case class Css.Declaration(property: String, value: String) extends Css
Why use zio-blocks-html?
String concatenation for HTML is error-prone:
- No type safety — typos in tag names or attributes only surface at runtime
- Manual escaping is tedious and easy to forget
- Tightly couples HTML structure to data serialization
Template engines with runtime parsing add overhead and complexity:
- Parsing validation on every render
- Separate template syntax to learn
- Difficult to compose programmatically
zio-blocks-html provides:
- Type-safe construction via DSL functions (
div(id := "main", p("Hello"))) - Automatic XSS protection through position-aware typeclasses and HTML escaping
- Scala 3 compile-time optimizations for string interpolators (constant folding, macro validation)
- Zero dependencies — pure Scala, works with any HTTP framework
- Cross-platform — identical API on JVM and Scala.js
- Structured querying — CSS selectors for DOM navigation and testing
Add to build.sbt:
libraryDependencies += "dev.zio" %% "zio-blocks-html" % "@VERSION@"
For Scala.js projects, use %%%:
libraryDependencies += "dev.zio" %%% "zio-blocks-html" % "@VERSION@"
Supported Scala versions: 2.13.x and 3.x
:::note
The html module depends transitively on zio-blocks-schema and zio-blocks-chunk. The schema dependency enables ToJs auto-derivation via JSON encoding — if your type has a Schema instance, it automatically becomes usable in js"..." expressions.
:::
The module is organized around five core subsystems that compose together:
┌─────────────────────────────────────────────────────┐
│ DSL Functions (div, p, span, ...) │
│ + Attribute Builders (id :=, className +=) │
│ └─> Produces: Dom.Element.Generic │
│ │
├─────────────────────────────────────────────────────┤
│ String Interpolators (html"", css"", js"") │
│ └─> Position-aware typeclasses │
│ └─> html"" → Dom.Element (attribute escaping) │
│ └─> css"" → Css (CSS value types) │
│ └─> js"" → Js (JavaScript with </script> guard) │
│ │
├─────────────────────────────────────────────────────┤
│ Dom ADT (sealed trait Dom) │
│ ├─> Dom.Text (HTML-escaped text content) │
│ ├─> Dom.Element (Generic, Script, Style) │
│ ├─> Dom.Doctype (<!DOCTYPE html>) │
│ └─> Dom.Empty (renders to nothing) │
│ │
├─────────────────────────────────────────────────────┤
│ Rendering │
│ ├─> dom.render → minified HTML String │
│ ├─> dom.render(indent=2) → pretty-printed String │
│ └─> Used in HTTP responses, testing │
│ │
├─────────────────────────────────────────────────────┤
│ CSS Selectors + DOM Querying │
│ ├─> CssSelector ADT (Element, Class, Id, etc.) │
│ ├─> Fluent combinators (>, >>, +, ~, &, |) │
│ ├─> DomSelection API (select, filter, extract) │
│ └─> Used for testing, transformation, navigation │
└─────────────────────────────────────────────────────┘
Typical workflow:
- Build — use DSL (
div(className := "card", ...)) or interpolators (html"<div>$content</div>") - Compose — nest elements and use typeclasses to extend for custom types
- Query — use CSS selectors (
page.select(div.hover).texts) for testing or transformation - Render — call
Dom#renderorDom#render(indent)for HTTP response or testing - Style — use
css"..."andCss.Ruleto define or embed stylesheets
The Dom sealed trait is the core data model. Everything in the module works with or produces Dom nodes.
A Dom tree is composed of four node types:
A text node containing string content. Text is automatically HTML-escaped when rendered to prevent XSS injection. Escaping happens at render time (not construction time), so Dom.Text stores the raw string.
To create text nodes, use the DSL (strings are converted via ToModifier[String]) or explicitly. Call Dom#render to convert a text node to an HTML string:
import zio.blocks.html._
val text = Dom.Text("Hello, world!")
text.renderA standard HTML element. The tag is the element name, attributes is a Chunk of key-value pairs and modifiers, and children is a Chunk of DOM nodes. Text content in children is HTML-escaped during rendering.
The DSL functions (div, p, span, etc.) construct these:
import zio.blocks.html._
val elem = div(id := "main", p("Content"))A specialized script element with tag = "script". Unlike Generic, Script renders its text children without HTML escaping, allowing inline JavaScript to be emitted as-is. This is the mechanism that enables safe js"..." interpolation — the interpolator escapes the JavaScript, but the Script element does not double-escape.
Use script().inlineJs(js"...") or script().externalJs(url):
import zio.blocks.html._
val inlineScript = script().inlineJs(js"console.log('Hello');")
val externalScript = script().externalJs("/app.js")A specialized style element with tag = "style". Like Script, Style renders text children without escaping, allowing raw CSS to be emitted. Use style().inlineCss(css"..."):
import zio.blocks.html._
val inlineStyle = style().inlineCss(css"body { margin: 0; }")A DOCTYPE declaration node. Renders as <!DOCTYPE value>. The singleton doctype value renders as <!DOCTYPE html>:
import zio.blocks.html._
doctype.renderA no-op node that renders to empty string. Useful for conditional rendering (e.g., if (condition) element else Dom.Empty).
Elements carry a Chunk[Dom.Attribute] where each attribute is one of:
-
Dom.Attribute.KeyValue(name: String, value: Dom.AttributeValue)— A standardname="value"attribute. The value can be a string, multi-value, boolean, or JavaScript expression. -
Dom.Attribute.AppendValue(name: String, value: Dom.AttributeValue, separator: Dom.AttributeSeparator)— Declares a value to be concatenated with any existing value for the same attribute. Provides accumulation of multi-valued attributes likeclassandrel. The separator appears between values (Space, Comma, Semicolon, or Custom). -
Dom.Attribute.BooleanAttribute(name: String, enabled: Boolean)— A standalone attribute (likedisabled,checked,required) that renders as just the name when enabled, or is omitted when disabled.
:::tip
The DSL provides id := value (:=) for single-valued attributes and className += "class" (+=) for appending to multi-valued attributes. Both are easier to use than constructing Dom.Attribute directly.
:::
Dom provides four pure tree transformation methods:
Dom#collect(pf: PartialFunction[Dom, Dom]): List[Dom] — Applies a partial function to every node in the tree (depth-first) and collects the results. Useful for extracting or transforming specific nodes:
import zio.blocks.html._
val tree = div(p("A"), span("B"), p("C"))
val paragraphs = tree.collect { case el: Dom.Element if el.tag == "p" => el }Dom#filter(predicate: Dom => Boolean): Dom — Removes any node for which the predicate returns false. Non-matching nodes are replaced with Dom.Empty, and their children are lost. Matching elements have their children recursively filtered:
import zio.blocks.html._
val tree = div(p("A"), span("Keep"), p("B"), span("Also keep"))
val filtered = tree.filter {
case el: Dom.Element => el.tag == "div" || el.tag == "span"
case _ => true
}Dom#find(predicate: Dom => Boolean): Option[Dom] — Returns the first node (depth-first) matching the predicate, or None:
import zio.blocks.html._
val tree = div(p("First"), span("Second"), p("Third"))
tree.find { case el: Dom.Element => el.tag == "p"; case _ => false }Dom#transform(f: Dom => Dom): Dom — Applies a transformation function to every node in pre-order. Each node receives the transformation first, and if it is an Element, its children are recursed on the transformed node, so a transformation that changes the child list affects what gets recursed into:
import zio.blocks.html._
val tree = div(h3("Old"), p("Content"))
val upgraded = tree.transform {
case el: Dom.Element.Generic if el.tag == "h3" => el.copy(tag = "h2")
case other => other
}The DSL provides functions for all HTML5 elements, allowing fluent construction with a modifier pattern.
All standard HTML elements are available as lowercase functions (or backtick-quoted if they shadow Scala keywords):
import zio.blocks.html._
// Basic elements
val paragraph = p("Hello, world!")
val heading = h1("Welcome")
val container = div("Content here")
// Void elements (auto self-closing)
val image = img(src := "photo.jpg", alt := "A photo")
val lineBreak = br
val horizontalRule = hr
// Keyword-shadowed elements (use backticks)
val objElement = `object`("data.bin")Attributes are set using the := operator on AttributeKey builders:
import zio.blocks.html._
val link = a(
href := "https://example.com",
target := "_blank",
titleAttr := "Visit Example",
"Visit Example"
)For attributes that can have multiple values (class, rel, accept), you can override or accumulate:
:=(override) — Replaces any previous value. Last assignment wins:
import zio.blocks.html._
val div1 = div(className := "a", className := "b")+=(append) — Concatenates with the previous value using a separator (space forclass):
import zio.blocks.html._
val div2 = div(className += "card", className += "active")- Mixed — Set a base value, then append:
import zio.blocks.html._
val div3 = div(className := "base").when(true)(className += "extra")Use BooleanAttribute.:= to conditionally include attributes like disabled, required, or checked:
import zio.blocks.html._
val isDisabled = true
val submitBtn = button(disabled := isDisabled, "Submit")Use dataAttr(name) and aria(name) builders for HTML5 data attributes and ARIA accessibility attributes:
import zio.blocks.html._
val userDiv = div(
dataAttr("user-id") := "42",
dataAttr("action") := "edit",
"User Card"
)
val closeBtn = button(
aria("label") := "Close",
aria("expanded") := "false",
"×"
)For custom attributes not provided by the DSL, use the generic attr(name) builder:
import zio.blocks.html._
val alpineDiv = div(attr("x-data") := "{open: false}", "Alpine.js component")
val htmxDiv = div(attr("hx-get") := "/api/data", "HTMX target")For programmatic construction of multi-valued attributes (when the DSL += operator is not available), use Dom.multiAttr(name) or Dom.multiAttr(name, separator):
import zio.blocks.html._
// Create a custom multi-valued attribute builder (alternative to DSL's className)
val classBuilder = Dom.multiAttr("class")
val div1 = div(classBuilder := "base", classBuilder += "active")
// Create a multi-valued attribute with explicit separator
val rel = Dom.multiAttr("rel", Dom.AttributeSeparator.Space)
val link = a(rel := "prev", rel += "first", href := "/previous")The MultiAttributeKey class handles accumulation of values with configurable separators (Space, Comma, Semicolon, or Custom).
For constructing multi-valued attributes directly from collections (without a builder chain), use the Iterable[String] overload:
import zio.blocks.html._
// Directly create a multi-valued attribute from a collection
val customClasses = Dom.multiAttr("class", List("card", "active", "large"))
val div1 = div(customClasses)This approach is useful when programmatically building multi-valued attributes outside the DSL's builder pattern.
Children can be strings, elements, or collections. The DSL uses the ToModifier typeclass to convert values into DOM nodes:
import zio.blocks.html._
// String children are converted to Dom.Text
val simple = p("Plain text")
// Element children are nested
val nested = div(p("First"), p("Second"))
// Lists of children — elements append directly, no wrapper
val items = List("Apple", "Banana", "Cherry")
val listEl = ul(items.map(item => li(item)))Use when(condition) to apply modifiers conditionally:
import zio.blocks.html._
val isHighlighted = true
val box = div(
className := "box"
).when(isHighlighted)(
className += "highlighted",
titleAttr := "This is highlighted"
)Use whenSome(option) to apply modifiers based on an Option:
import zio.blocks.html._
val maybeTitle: Option[String] = Some("Important")
val card = div(
className := "card",
p("Content")
).whenSome(maybeTitle)(t => Seq(
titleAttr := t,
className += "has-title"
))Void elements (self-closing tags) automatically render with the correct syntax:
import zio.blocks.html._
val voidElements = div(
br,
hr,
img(src := "photo.jpg", alt := "A photo"),
input(`type` := "text", placeholder := "Enter text")
)The module provides four string interpolators: html"", css"", js"", and selector"". All offer compile-time safety (on Scala 3) and automatic escaping appropriate to their context.
The html"" interpolator is position-aware: it detects whether each interpolated argument is in an attribute-value position or content position, then summons the appropriate typeclass.
The html"" interpolator determines position based on the preceding string:
Position detection:
- Attribute position: if the preceding string part ends with
=,=', or=", the next argument is treated as an attribute value and usesToAttrValue[A] - Content position: otherwise, the argument is treated as element content and uses
ToElements[A]
Position-aware interpolation enables safe attribute values and content:
import zio.blocks.html._
val name = "Alice"
val age = 30
// name is in content position → ToElements[String]
// age is in attribute position → ToAttrValue[Int]
val elem = html"""<div id="user-$age" class="profile">User: $name</div>"""Single-root requirement: The html"" interpolator requires a single root element. Multiple top-level nodes cause an exception at runtime (Scala 2) or compile error for static templates (Scala 3):
import zio.blocks.html._
// This compiles (single root)
val page = html"<div><p>A</p><p>B</p></div>"XSS Protection:
Content interpolated into html"" is stored as Dom.Text nodes, which are HTML-escaped during Dom#render to prevent XSS:
import zio.blocks.html._
val userInput = "<script>alert('XSS')</script>"
val safe = html"<p>$userInput</p>":::danger
The html"" interpolator requires a single root element. On Scala 3, pure-static templates with multiple roots fail at compile time. On Scala 2 or for dynamic templates, the error occurs at evaluation time. Always wrap multiple elements in a container (e.g., <div>).
:::
The css"" interpolator returns a Css value with ToCss typeclass dispatch for interpolated parts:
import zio.blocks.html._
val color = "blue"
val size = 16
val styles = css"color: $color; font-size: ${size}px;"Use CssLength and CssColor types for type-safe CSS values:
import zio.blocks.html._
val width = css"width: ${CssLength(300.0, "px")};"
val background = css"background: ${CssColor.Hex.unsafe("ff0000")};":::note
CssColor.Hex returns Option[CssColor] because hex validation may fail. Use CssColor.Hex.apply() (or just CssColor.Hex(...)) for safe validation, or CssColor.Hex.unsafe() for known-valid hex strings.
:::
The js"" interpolator returns a Js value. Strings are automatically quoted and escaped; numerics and booleans are rendered unquoted:
import zio.blocks.html._
val message = "Hello, world!"
val count = 42
val code = js"console.log($message); alert($count);":::warning
Never interpolate untrusted user input directly into js"". The interpolator escapes string values, but the Js value is rendered without additional escaping in script elements. Always use ToJs[String] (which automatically quotes and escapes) for any untrusted input.
:::
The interpolator protects against </script> injection by escaping < and > as Unicode escapes:
import zio.blocks.html._
val userInput = "if (x < y) alert('<script>');"
val code = js"let check = $userInput"
println(code.value)
// let check = "if (x < y) alert('<script>');"
// (with < and > characters escaped as < and > in actual output)The selector"" interpolator returns a CssSelector:
import zio.blocks.html._
val className = "active"
val selector = selector".$className"The CssSelector ADT provides a fluent DSL for building CSS selectors with combinator methods.
Create selectors for elements, classes, IDs, or universal matches:
import zio.blocks.html._
val divSel = CssSelector.Element("div")
val classSel = CssSelector.Class("container")
val idSel = CssSelector.Id("header")
val universal = CssSelector.UniversalAll HTML elements (div, span, p, etc.) implement CssSelectable, so you can use them directly as selectors:
import zio.blocks.html._
// Element selectors via DSL elements
val divSel = div.selector
val childSel = div > span // div > span
val descendantSel = div >> span // div span (descendant)
val adjacentSel = div + span // div + span
val siblingSel = div ~ span // div ~ span
val andSel = div & CssSelector.Class("active") // div.active
val orSel = div | span // div, spanPseudo-classes match elements by their state, and pseudo-elements create dynamic content:
import zio.blocks.html._
val hoverSel = a.hover // a:hover
val firstChild = li.firstChild // li:first-child
val nthChild = tr.nthChild(2) // tr:nth-child(2)
val before = div.before // div::before
val after = span.after // span::afterSelect elements by their attribute values using built-in matchers:
import zio.blocks.html._
val input = CssSelector.Element("input")
val hasType = input.withAttribute("type") // input[type]
val exactType = input.withAttribute("type", "text") // input[type="text"]
val containsClass = input.withAttributeContaining("class", "btn") // input[class*="btn"]
val startsWithHref = a.withAttributeStarting("href", "https") // a[href^="https"]
val endsWithPng = img.withAttributeEnding("src", ".png") // img[src$=".png"]The DomSelection API lets you query and navigate DOM trees using CSS selectors. It is useful for testing, transforming templates, and extracting information.
Call Dom#select(selector) to query the tree:
import zio.blocks.html._
val page = div(
header(nav(a(href := "/", "Home"), a(href := "/about", "About"))),
main(p(className += "intro", "Hello"), p("World")),
footer(p("© 2026"))
)
// Query by tag
val paragraphs = page.select(CssSelector.Element("p"))
paragraphs.length
// Query by class
val intros = page.select(CssSelector.Class("intro"))
intros.textsThe DomSelection API returned by Dom#select provides navigation methods like .children and .first to traverse the selection results:
import zio.blocks.html._
val page = div(
nav(a(href := "/", "Home"), a(href := "/about", "About")),
main(p("Content"))
)
// Direct children
val navLinks = page.select(CssSelector.Element("nav")).childrenThe DomSelection API provides methods like .attrs and .texts to extract attribute values and text content from selected elements:
import zio.blocks.html._
val page = div(
a(href := "/home", "Home"),
a(href := "/about", "About"),
a(href := "/contact", "Contact")
)
val hrefs = page.select(CssSelector.Element("a")).attrs("href")The DomSelection API provides .filter and .withClass methods to filter selections by predicate or attribute:
import zio.blocks.html._
val page = div(
p(className += "visible", "A"),
p(className += "hidden", "B"),
p("C")
)
// Filter by predicate
val visible = page.select(CssSelector.Element("p")).filter {
case el: Dom.Element => el.attributes.exists {
case attr: Dom.Attribute.KeyValue if attr.name == "class" => true
case _ => false
}
case _ => false
}
// Filter by class
val withClass = page.select(CssSelector.Element("p")).withClass("visible"):::warning
DomSelection returns new copies with modifications; the original DOM tree remains unchanged. The DomSelection API provides functional transformations. To modify the original DOM tree, use Dom#transform with a tree-rewriting function, or rebuild the tree from scratch using the DSL.
:::
The DomSelection API provides methods like .modifyAll, .replaceAll, and .removeAll to transform or replace selected nodes:
import zio.blocks.html._
val page = div(
p("Old 1"),
p("Old 2"),
span("Keep")
)
// Transform all Element nodes in the selection; non-Element nodes pass through unchanged
val modifiedSelection = page.select(CssSelector.Element("p")).modifyAll {
case el: Dom.Element.Generic if el.tag == "p" => el.copy(tag = "div")
case other => other
}
// Replace all selected nodes (returns DomSelection of replacement nodes)
val replacedSelection = page.select(CssSelector.Element("p")).replaceAll(p("New"))
// Remove all selected nodes (returns empty DomSelection)
val removedSelection = page.select(CssSelector.Element("p")).removeAll:::note
Pseudo-class selectors (:hover, :focus, etc.) match elements structurally by their underlying element selector only — they cannot detect browser interaction state in a static DOM tree. div.hover matches the same elements as CssSelector.Element("div").
Adjacent sibling (+) and general sibling (~) selectors are supported in CSS output but not in DOM querying — DomSelection.select with these combinators returns empty results.
:::
The Css ADT represents structured stylesheets as a typed data structure, separate from strings.
All Css subtypes support .render() for minified output and .render(indent: Int) for indented pretty-printing:
Css.Rule— single CSS rule with selector and declarationsCss.Sheet— collection of rulesCss.Raw— raw CSS stringCss.Comment— CSS comment
A Css.Declaration is a property-value pair:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
val marginDecl = Css.Declaration("margin", "10px")
val colorDecl = Css.Declaration("color", "blue")
val rule = Css.Rule(
CssSelector.Element("p"),
Chunk(marginDecl, colorDecl)
)
rule.renderA Css.Sheet is a collection of rules:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
val bodyRule = Css.Rule(
CssSelector.Element("body"),
Chunk(
Css.Declaration("margin", "0"),
Css.Declaration("font-family", "sans-serif")
)
)
val stylesheet = Css.Sheet(Chunk(bodyRule))
stylesheet.render(indent = 2)Embed stylesheets in HTML via the style() element:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
val page = html(
head(
style().inlineCss(
Css.Sheet(Chunk(
Css.Rule(
CssSelector.Element("body"),
Chunk(Css.Declaration("background", "#f0f0f0"))
)
))
)
),
body(p("Styled"))
)
page.renderFor CSS features not expressible via the ADT (media queries, keyframes, at-rules), use Css.Raw:
import zio.blocks.html._
val mediaQuery = Css.Raw("""
|@media (max-width: 600px) {
| body { font-size: 14px; }
|}
""".stripMargin)
println(mediaQuery.render):::tip
Prefer Css.Rule and Css.Sheet over Css.Raw when possible — structured CSS enables future optimization and prevents CSS injection. Use Css.Raw only for trusted, hardcoded CSS.
:::
Add comments to stylesheets using Css.Comment:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
val stylesheet = Css.Sheet(Chunk(
Css.Comment("Mobile-first responsive design"),
Css.Rule(
CssSelector.Element("body"),
Chunk(Css.Declaration("font-size", "16px"))
),
Css.Comment("Tablet and desktop breakpoints"),
Css.Raw("@media (min-width: 768px) { body { font-size: 18px; } }")
))
stylesheet.render(indent = 2)All Dom and Css values support multiple rendering modes.
Dom#render produces compact HTML with no extra whitespace. Use Dom#renderMinified as an explicit alias for the same operation:
import zio.blocks.html._
val page = div(h1("Title"), p("Content"))
page.renderrender(indent: Int) produces indented, readable output:
import zio.blocks.html._
val page = div(h1("Title"), p("Content"))
page.render(indent = 2)Dom#renderuses a pre-allocatedStringBuilderand while-loop rendering — zero allocations for iteration- Indentation strings get cached in an array of pre-built space strings (up to 128 characters) to prevent repeated string allocation
- Void elements automatically self-close with no children
- Script and Style elements render their children without escaping (with
</→<\/protection for scripts)
The module provides multiple layers of automatic XSS protection:
All Dom.Text nodes are HTML-escaped during rendering:
&→&<→<>→>"→"'→'
Untrusted content is always escaped to prevent XSS:
import zio.blocks.html._
val userInput = "<script>alert('XSS')</script>"
val safe = div(p(userInput))
safe.renderThe ToJs[String] typeclass escapes strings to prevent breaking out of script contexts:
<→ backslash-u-0-0-3-c (the six-character Unicode escape sequence<)>→ backslash-u-0-0-3-e (the six-character Unicode escape sequence>)&→ backslash-u-0-0-2-6 (the six-character Unicode escape sequence&)"→\",'→\',\→\\- Newlines, carriage returns, and Unicode line/paragraph separators are escaped
This protects against </script> injection:
import zio.blocks.html._
val userInput = "</script><script>alert('XSS');</script>"
val code = js"let payload = $userInput"
println(code.value)
// let payload = "</script><script>alert('XSS');</script>"
// (with < and > characters escaped as Unicode in actual output)Attributes named href, src, action, or formaction are checked for dangerous schemes at render time:
javascript:,vbscript:,data:text/html→ prefixed withunsafe:
Dangerous URLs are automatically sanitized in HTML output:
import zio.blocks.html._
val dangerous = a(href := "javascript:alert('XSS')", "Click me")The module intentionally provides no Dom.Raw type for embedding arbitrary HTML. This prevents XSS by ensuring all dynamic content is either constructed via the DSL or interpolated through context-aware interpolators.
The module supports several architectural patterns for code organization and reuse:
Define functions that return Dom.Element to create reusable components:
import zio.blocks.html._
def card(title: String, content: String): Dom.Element =
div(
className := "card",
h2(title),
p(content)
)
val page = div(
card("Card 1", "Content A"),
card("Card 2", "Content B")
)Use when and whenSome for conditional modifiers:
import zio.blocks.html._
def userCard(name: String, isAdmin: Boolean): Dom.Element =
div(
className := "user-card"
).when(isAdmin)(
className += "admin",
span(className := "badge", "Admin")
)Map over collections to create child elements:
import zio.blocks.html._
def userList(users: List[String]): Dom.Element =
ul(users.map(user => li(user)))
val page = userList(List("Alice", "Bob", "Charlie"))Combine interpolators and DSL for flexibility:
import zio.blocks.html._
val title = "My Page"
val content = "Welcome to my site"
val page = html"""
<html>
<head><title>$title</title></head>
<body>
${div(p(content))}
</body>
</html>
"""Use DomSelection to write structural assertions:
import zio.blocks.html._
val page = div(
ul(
li(a(href := "/home", "Home")),
li(a(href := "/about", "About"))
)
)
// Test: check number of links
val links = page.select(CssSelector.Element("a"))
assert(links.length == 2)
// Test: check specific href
val aboutLink = page.select(a.withAttribute("href", "/about"))
assert(aboutLink.texts.contains("About"))Here is a complete, self-contained example building a full dashboard page with navigation, metadata, styling, and content sections:
import zio.blocks.html._
import zio.blocks.chunk.Chunk
val userName = "Alice"
val items = List("Dashboard", "Settings", "Logout")
val pieces: Chunk[Dom] = Chunk(
doctype,
html(
head(
meta(charset := "utf-8"),
meta(name := "viewport", content := "width=device-width, initial-scale=1.0"),
title("My App"),
link(rel := "stylesheet", href := "/style.css"),
style().inlineCss(
Css.Sheet(Chunk(
Css.Rule(CssSelector.Element("body"), Chunk(
Css.Declaration("margin", "0"),
Css.Declaration("font-family", "sans-serif")
))
))
)
),
body(
header(
nav(items.map(item => a(href := "#", item)))
),
main(
h1(s"Welcome, $userName!"),
p("This is your dashboard.")
),
footer(
p("© 2026 My App")
),
script().inlineJs(js"console.log('Page loaded');")
)
)
)
pieces.map(_.render(indent = 2)).mkString("\n")