HTML and event bindings
Before You Start
Import scalive.* wherever you construct HTML. Static pages use ordinary Scala
values, while LiveViews derive dynamic content from a model Signal. The DSL
uses Scala expressions and collections rather than introducing a template
language.
Build An HTML Tree
Import scalive.*, then call tag values such as divlazy val div: HtmlTagRepresents a generic container with no special meaning.,
buttonlazy val button: HtmlTagA button, and tablelazy val table: HtmlTagRepresents data with more than one dimension..
Pass attributes and children in document order:
def view(model: Signal[Model]): HtmlElement[Msg] =
sectionTag(
cls := "cart",
aria.label := "Shopping cart",
h1("Cart"),
p(model.map(model => s"${model.itemCount} items"))
)A tag call produces an HtmlElement[Msg]class HtmlElement[+Msg](tag: HtmlTag, mods: Vector[Mod[Msg]])An immutable element in Scalive's typed, protocol-neutral HTML algebra.. Strings
become escaped text content, nested elements become child content, and an
IterableOnce of modifiers can be passed directly. Derive dynamic text
and attributes with signal .map; use staged signal operators such as
choose, option, and splitBy for conditional and repeated content. The
view method constructs this signal-backed view graph once per graph lifetime
rather than rebuilding the tree after every model update.
Keep reusable markup in ordinary functions. Accept Mod.Input[Msg]type Input = ([Msg] =>> |[Mod[Msg], IterableOnce[Mod[Msg]]])A single modifier or one collection of modifiers accepted by HTML and component factories.
when callers should be able to supply individual modifiers, Option values, or
collections. Forward every input unchanged with mods*:
def passthrough[Msg](mods: Mod.Input[Msg]*): HtmlElement[Msg] =
articleTag(mods*)When adding the component's own modifiers, normalize the caller inputs with
Mod.flattendef flatten[Msg](inputs: IterableOnce[Mod.Input[Msg]]): Vector[Mod[Msg]]Normalizes modifier inputs while preserving their encounter order.:
def card[Msg](mods: Mod.Input[Msg]*): HtmlElement[Msg] =
articleTag(cls := "card", Mod.flatten(mods))
val featured = card(cls := "featured", "Featured product")The helper and caller classes combine into class="card featured". This makes
component-owned classes composable without requiring the helper to inspect caller
modifiers.
Mod.flatten is also available when a function must inspect, reorder, store, or insert caller
modifiers before generated trailing content.
Use the named tag definitions when they exist. The DSL gives Scala-safe names to
HTML names that would otherwise conflict with Scala or another exported symbol:
for example, sectionTaglazy val sectionTag: HtmlTagRepresents a generic section of a document, i.e., a thematic grouping of
content, typically with a heading.,
headerTaglazy val headerTag: HtmlTagDefines the header of a page or section. It often contains a logo, the
title of the Web site, and a navigational table of content.,
htmlRootTaglazy val htmlRootTag: HtmlTagRepresents the root of an HTML or XHTML document. All other elements must
be descendants of this element.,
headTaglazy val headTag: HtmlTagRepresents a collection of metadata about the document, including links to,
or definitions of, scripts and style sheets., and
idAttrlazy val idAttr: HtmlAttr[String]This attribute defines a unique identifier (ID) which must be unique in
the whole document. Its purpose is to identify the element when linking
(using a fragment identifier), scripting, or styling (with CSS)..
Use htmlTag(name)def htmlTag(name: String, void: Boolean = ...): HtmlTagCreates a reusable HTML tag definition. only when the framework does not
provide the element you need.
Render Static HTML
Use StaticHtml.renderdef render(element: => HtmlElement[Nothing], includeDoctype: Boolean = ...): zio.package.IO[StaticHtmlError, String]Renders element once, optionally prefixing the result with the HTML doctype. when markup does not need a
LiveView lifecycle. It evaluates the concrete tree once and returns a ZIO effect containing the
serialized string:
val rendered = StaticHtml.render(
htmlRootTag(
lang := "en",
headTag(titleTag("Status")),
bodyTag(mainTag(h1("Ready")))
),
includeDoctype = true
)This produces <!doctype html><html lang="en"><head><title>Status</title></head><body><main><h1>Ready</h1></main></body></html>.
Static rendering uses the same HTML validation, escaping, raw-content handling, and attribute
serialization as LiveView rendering. Construct dynamic application content from ordinary Scala
values before calling StaticHtml.render.
The root must be an HtmlElement[Nothing], which excludes event bindings that produce an owner
message. Ordinary reusable markup functions remain supported. StaticHtml can serialize
client-only JS commands into phx-* attributes, but those attributes are inert unless the
Phoenix LiveView client is loaded and a LiveSocket is initialized as described in
Client setup and static assets. A standalone
static page should not rely on those commands unless it deliberately includes that browser runtime.
Server event bindings, flash content, managed streams, stateful LiveComponents, and nested
LiveViews require lifecycle state and fail with a typed
StaticHtmlErrortype StaticHtmlError = render.RenderErrorA validation or evaluation failure produced while rendering static HTML. instead of being silently
discarded or mounted.
Set Typed Attributes
Assign attributes with :=def :=(value: V): Mod.Attr[Nothing]. Each HtmlAttr[V]class HtmlAttr[V](name: String, codec: codecs.Encoder[V, String])A typed, validated HTML attribute definition.
accepts its declared Scala value type, so disabled := model.lines.isEmpty takes
a Boolean while cls := "cart" takes a String. Boolean presence attributes
are emitted when true and omitted when false.
Compose Token-List Attributes
Some HTML attributes contain a space-separated token list rather than one scalar
value. Their CompositeHtmlAttrclass CompositeHtmlAttr(name: String) extends HtmlAttr[String]A space-separated token attribute whose repeated modifiers contribute to one rendered value.
definitions let independent modifiers contribute to one rendered attribute. The
built-in classNameval className: CompositeHtmlAttrDefines the element's space-separated CSS classes.,
clsval cls: CompositeHtmlAttrConcise alias for className., rellazy val rel: CompositeHtmlAttrDefines the space-separated relationship types of a linked resource., and
rolelazy val role: CompositeHtmlAttrDefines the accessibility role of the current element. definitions use this behavior:
div(
cls := "panel selected",
cls := model.map(model => if model.expanded then "expanded" else ""),
cls := "selected wide"
)Values are split on HTML ASCII whitespace. Empty tokens are ignored, exact
duplicate tokens keep their first position, and declaration order is preserved. The
example therefore renders class="panel selected expanded wide" while expanded,
and class="panel selected wide" otherwise. An attribute with no remaining tokens
is omitted.
Static values, signals, optional signals, and directly selected modifiers can be
mixed. Use cls.optional(...)def optional(value: Signal[Option[String]]): Mod.Attr[Nothing]
for a Signal[Option[String]]; a cls := ... branch selected by
chooseModextension def chooseMod(condition: Signal[Boolean])[Msg](whenTrue: => Mod[Msg], whenFalse: => Mod[Msg]): Mod[Msg] joins the same attribute.
Scalar attributes do not compose or use last-declaration-wins semantics. Declaring
the same scalar attribute more than once is an error, as is mixing composite and
scalar definitions for the same rendered name.
For role, order is significant because user agents use the first supported role.
Scalive does not validate ARIA role names; prefer an element with the required native
semantics when one exists. Use
CompositeHtmlAttr(name)def apply(name: String): CompositeHtmlAttrCreates a reusable composite definition for a space-separated token attribute. for a
custom attribute only when its grammar is a space-separated token list.
Use Namespaced And Custom Attributes
Use dataAttr(name)def dataAttr(suffix: String): HtmlAttr[String]Creates a custom data-* string attribute from suffix. for application data-* attributes and
the ariaobject ariaARIA attribute definitions. namespace for ARIA attributes:
button(
typ := "button",
dataAttr("product") := product.sku,
aria.label := s"Add ${product.name}",
disabled := !product.available,
on.click(Msg.Add(product)),
product.name
)For an attribute absent from the DSL, use htmlAttr(name, encoder)def htmlAttr[V](name: String, encoder: codecs.Encoder[V, String]): HtmlAttr[V]Creates a typed HTML attribute definition. with
an explicit encoder rather than assembling rendered HTML:
private val popover = htmlAttr("popover", scalive.codecs.StringAsIsEncoder)
div(popover := "manual", "Details")Avoid rawHtmldef rawHtml(html: String): Mod[Nothing] for ordinary content. It bypasses escaping and should be limited
to HTML that the application already trusts.
Bind Events To Messages
Use the onobject onTyped Phoenix LiveView event bindings. bindings to produce the view's message
type. This namespace creates Phoenix LiveView phx-* bindings, not native inline
HTML event attributes: on.click renders phx-click, and on.change renders
phx-change rather than onchange. A constant binding is enough when the event
carries no application value:
enum Msg:
case Add(product: Product)
case Clear
button(on.click(Msg.Add(product)), "Add")
button(on.click(Msg.Clear), "Clear")The message type remains part of the whole tree. If
viewdef view(model: Signal[Model]): HtmlElement[Msg] returns
HtmlElement[Msg], a binding that produces another message type does not
compile. The shopping cart uses this directly for product-specific add and
remove messages.
The distinction matters most for forms. Phoenix drives phx-change from both browser
input and change events, so a text control normally sends changes while the user
edits rather than only when the native change event commits. On a form, the binding
sends its successful controls and identifies the changed control. A binding on an
individual control overrides the form's change binding, sends only that control, and
still requires the control to belong to a form. Prefer the typed form helpers described
in Typed forms and validation
when handling a complete form.
Native inline attributes such as onchange are available only through the generic
htmlAttrdef htmlAttr[V](name: String, encoder: codecs.Encoder[V, String]): HtmlAttr[V]Creates a typed HTML attribute definition. escape hatch. They contain raw client-side
code and do not dispatch a typed Scalive message. Prefer the explicit boundaries in
Browser integration when behavior must run
in the browser.
Use withValuedef withValue[Msg](f: String => Msg): Mod.Attr[Msg] when an event's value
should construct the message, and use
withValueOptiondef withValueOption[Msg](f: Option[String] => Msg): Mod.Attr[Msg] when a missing value is
meaningful:
enum Msg:
case SearchChanged(value: String)
input(
typ := "search",
on.blur.withValue(Msg.SearchChanged.apply)
)withValuedef withValue[Msg](f: String => Msg): Mod.Attr[Msg] supplies an empty string when the payload has no value.
withValueOptiondef withValueOption[Msg](f: Option[String] => Msg): Mod.Attr[Msg] preserves that case as None. The lower-level function form of
on.clicklazy val click: HtmlAttrBinding receives the
binding payload as Map[String, String].
Configure rate limiting with debounce(duration)def debounce(duration: concurrent.duration.FiniteDuration): HtmlAttrBinding
before supplying the message. Durations are rendered in milliseconds, and
negative durations are rejected:
import scala.concurrent.duration.*
input(on.blur.debounce(300.millis).withValue(Msg.SearchChanged.apply))Advanced Client-Side Switches
Two uncommon client-side switches use the typed phx attribute namespace directly.
Scalive does not wrap them in form or DOM helpers because they carry no application
message or server-side value and apply across raw and typed controls.
Put phx.noUnusedField := truelazy val noUnusedField: HtmlAttr[Boolean] on a
form or individual form-associated control to omit Phoenix's _unused_* markers for
that scope. Without those markers, received fields are treated as used when deriving
validation visibility.
Put phx.patchFocused := truelazy val patchFocused: HtmlAttr[Boolean] on an
editable form-associated control, including a form-associated custom element, when
normal DOM patching should continue while it is focused. Focused controls otherwise
preserve browser-managed state during a patch.
Move Content And Wrap Focus
Use portaldef portal[Msg](id: String, target: DomSelector, container: String = ..., wrapperClass: Option[String] = ...)(mods: Mod.Input[Msg]*): HtmlElement[Msg] when content must remain owned by its
LiveView but appear elsewhere in the document, such as below a root-level modal
container:
portal("cart-dialog", target = DomSelector.css("#modal-root"))(
sectionTag(aria.label := "Cart", "...")
)The helper renders a source <template> and moves one generated wrapper to the
explicit CSS target in the browser. Keep id stable and unique, ensure the target
exists, and use container and wrapperClass only to customize that wrapper.
DomSelector.current and invalid container tag names are rejected, but selector
syntax and target existence are not checked server-side. A portal preserves event,
hook, component, and nested LiveView ownership; it is not a security boundary and
does not make untrusted HTML safe.
Use focusWrapdef focusWrap[Msg](id: String, mods: Mod[Msg]*)(content: Mod[Msg]*): HtmlElement[Msg]Renders a keyboard-focus boundary using Phoenix's Phoenix.FocusWrap hook. for content whose keyboard focus
should cycle at its boundaries:
focusWrap("cart-dialog-focus", cls := "dialog-body")(
button(on.click(Msg.Close), "Close")
)Keep its id stable and unique. Pass only wrapper attributes and bindings in
mods; do not override id or phx-hook, and put all child content in the second
argument list so the generated focus sentinels remain first and last. The helper
depends on the Phoenix client hook. It does not add dialog roles, labels, background
inertness, authorization, or a no-JavaScript focus trap; provide those separately.
Apply inert := dialogOpenlazy val inert: HtmlAttr[Boolean]Makes this element and its flat-tree descendants inert while assigned true. to the background
region, not the dialog, when that region must stop receiving user input and focus.
Inert content leaves the accessibility tree and receives no default visual treatment,
so keep the active region perceivable and visually distinguish inactive content.
Check Accessibility In The Browser
Typed attributes and focus helpers do not make a UI accessible automatically. For each interactive flow:
prefer the semantic element for the action or content;
give controls visible labels and accessible names;
complete the flow by keyboard and verify focus order, visibility, and restoration;
expose validation, progress, and other live feedback without unexpectedly moving focus; and
test the patched UI with browser accessibility tooling and representative assistive technology.
Key Repeated Content
Use splitBy(key) { ... }extension def splitBy[A, Items <: Iterable[A]](items: Signal[Items])[Key, Msg](key: A => Key)(project: (Key, Signal[A]) => HtmlElement[Msg]): Mod[Msg] on a collection
signal when entries have stable domain identity. Choose a key that is unique
within that collection and
does not change while the entry represents the same entity:
tbody(
model.map(_.lines).splitBy(_.product.sku) { (sku, line) =>
tr(
dataAttr("cart-line") := sku,
td(line.map(_.product.name)),
td(line.map(_.quantity.toString))
)
}
)Scalive's keyed diff uses the keys to match entries between updates. Current
tests cover unchanged keyed subtrees producing no diff, reorders producing
index changes without resending unchanged entry payloads, changed entries being
merged into reorder payloads, and deletion reducing the keyed count. Prefer a
SKU, database identifier, or another domain key. Use
splitByIndexextension def splitByIndex[A, Items <: Iterable[A]](items: Signal[Items])[Msg](project: (Int, Signal[A]) => HtmlElement[Msg]): Mod[Msg] only when
position is the identity and reordering is not meaningful.
The splitBy key remains server-side and is not rendered as an HTML id.
Phoenix's client merges the compact keyed payload before patching the resulting
HTML. Add a stable HTML id to each repeated root when the corresponding browser
node itself must survive a move, such as a row containing focused input or
browser-managed state.
The shopping cart example combines typed attributes, product-specific event messages, staged conditional content, and SKU-keyed rows in one view graph:
final class ShoppingCartExample
extends LiveView[ShoppingCartExample.Msg, ShoppingCartExample.Model]:
import ShoppingCartExample.*
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model.empty)
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Add(product) => ZIO.succeed(model.add(product))
case Msg.Remove(product) => ZIO.succeed(model.remove(product))
case Msg.Clear => ZIO.succeed(Model.empty)
def view(model: Signal[Model]): HtmlElement[Msg] =
div(
cls := "docs-cart",
fieldSet(
cls := "docs-cart-products",
dataAttr("example-controls") := "",
legend("Products"),
div(
cls := "docs-cart-product-grid",
Product.all.map { product =>
button(
typ := "button",
dataAttr("product") := product.sku,
on.click(Msg.Add(product)),
span(cls := "docs-cart-product-name", product.name),
span(cls := "docs-cart-product-price", money(product.priceInCents))
)
}
)
),
sectionTag(
cls := "docs-cart-summary",
aria.label := "Shopping cart",
headerTag(
div(
h4("Cart"),
p(
dataAttr("cart-item-count") := "",
role := "status",
aria.live := "polite",
aria.atomic := true,
model.map(model => itemCountLabel(model.itemCount))
)
),
button(
typ := "button",
dataAttr("cart-clear") := "",
disabled := model.map(_.lines.isEmpty),
on.click(Msg.Clear),
"Clear"
)
),
model
.map(_.lines.isEmpty).choose(
p(dataAttr("cart-empty") := "", cls := "docs-cart-empty", "Add a product to begin."),
div(
cls := "docs-cart-table-scroll",
table(
cls := "docs-cart-table",
aria.label := "Cart contents",
thead(
tr(
th("Product"),
th("Quantity"),
th("Subtotal"),
th(cls := "docs-visually-hidden", "Actions")
)
),
tbody(
model.map(_.lines).splitBy(_.product.sku) { (sku, line) =>
tr(
dataAttr("cart-line") := sku,
td(
strong(line.map(_.product.name)),
span(
cls := "docs-cart-unit-price",
line.map(line => money(line.product.priceInCents))
)
),
td(dataAttr("cart-quantity") := "", line.map(_.quantity.toString)),
td(
dataAttr("cart-subtotal") := "",
line.map(line => money(line.subtotalInCents))
),
td(
button(
typ := "button",
dataAttr("remove-product") := sku,
aria.label := line.map(line => s"Remove one ${line.product.name}"),
on.click(line.map(line => Msg.Remove(line.product))),
"Remove one"
)
)
)
}
),
tfoot(
tr(
th("Total"),
td(),
td(
dataAttr("cart-total") := "",
model.map(model => money(model.totalInCents))
),
td()
)
)
)
)
)
)
)
private def money(cents: Int): String =
val dollars = cents / 100
val remainder = cents % 100
f"$$$dollars%d.$remainder%02d"
private def itemCountLabel(count: Int): String =
if count == 1 then "1 item" else s"$count items"
end ShoppingCartExample
object ShoppingCartExample:
enum Product(val sku: String, val name: String, val priceInCents: Int):
case Coffee extends Product("coffee", "Coffee beans", 1299)
case Notebook extends Product("notebook", "Notebook", 850)
case Sticker extends Product("sticker", "Scalive sticker", 250)
object Product:
val all = Vector(Product.Coffee, Product.Notebook, Product.Sticker)
final case class Line(product: Product, quantity: Int):
def subtotalInCents: Int = product.priceInCents * quantity
final case class Model(lines: Vector[Line]):
def add(product: Product): Model =
lines.indexWhere(_.product == product) match
case -1 => copy(lines = lines :+ Line(product, quantity = 1))
case index =>
val current = lines(index)
copy(lines = lines.updated(index, current.copy(quantity = current.quantity + 1)))
def remove(product: Product): Model =
copy(lines = lines.flatMap { line =>
if line.product != product then Some(line)
else if line.quantity > 1 then Some(line.copy(quantity = line.quantity - 1))
else None
})
def itemCount: Int = lines.map(_.quantity).sum
def totalInCents: Int = lines.map(_.subtotalInCents).sum
object Model:
val empty = Model(Vector.empty)
enum Msg:
case Add(product: Product)
case Remove(product: Product)
case Clear
end ShoppingCartExampleView source (documentation/site/src/scalive/docs/examples/ShoppingCartExample.scala:8-170)
For the model and handler behind this tree, read Models, messages, and effects. For the diffing model, read Rendering, bindings, and diffs. For explicit ID-addressed inserts and deletes in frequently changing collections, read Streams and collection updates.
Related Tasks
Give frequently changing rows targeted updates with Streams and collection updates.
Build checked links and destinations with Routes, parameters, and navigation.
Assert rendered forms and markup with Testing LiveViews.