Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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 div, button, and table. Pass attributes and children in document order:

scala
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]. 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] when callers should be able to supply individual modifiers, Option values, or collections. Forward every input unchanged with mods*:

scala
def passthrough[Msg](mods: Mod.Input[Msg]*): HtmlElement[Msg] =
  articleTag(mods*)

When adding the component's own modifiers, normalize the caller inputs with Mod.flatten:

scala
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, sectionTag, headerTag, htmlRootTag, headTag, and idAttr. Use htmlTag(name) only when the framework does not provide the element you need.

Render Static HTML

Use StaticHtml.render when markup does not need a LiveView lifecycle. It evaluates the concrete tree once and returns a ZIO effect containing the serialized string:

scala
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 StaticHtmlError instead of being silently discarded or mounted.

Set Typed Attributes

Assign attributes with :=. Each HtmlAttr[V] 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 CompositeHtmlAttr definitions let independent modifiers contribute to one rendered attribute. The built-in className, cls, rel, and role definitions use this behavior:

scala
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(...) for a Signal[Option[String]]; a cls := ... branch selected by chooseMod 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) for a custom attribute only when its grammar is a space-separated token list.

Use Namespaced And Custom Attributes

Use dataAttr(name) for application data-* attributes and the aria namespace for ARIA attributes:

scala
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) with an explicit encoder rather than assembling rendered HTML:

scala
private val popover = htmlAttr("popover", scalive.codecs.StringAsIsEncoder)

div(popover := "manual", "Details")

Avoid rawHtml for ordinary content. It bypasses escaping and should be limited to HTML that the application already trusts.

Bind Events To Messages

Use the on 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:

scala
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 view 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 htmlAttr 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 withValue when an event's value should construct the message, and use withValueOption when a missing value is meaningful:

scala
enum Msg:
  case SearchChanged(value: String)

input(
  typ := "search",
  on.blur.withValue(Msg.SearchChanged.apply)
)

withValue supplies an empty string when the payload has no value. withValueOption preserves that case as None. The lower-level function form of on.click receives the binding payload as Map[String, String].

Configure rate limiting with debounce(duration) before supplying the message. Durations are rendered in milliseconds, and negative durations are rejected:

scala
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 := true 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 := true 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 portal when content must remain owned by its LiveView but appear elsewhere in the document, such as below a root-level modal container:

scala
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 focusWrap for content whose keyboard focus should cycle at its boundaries:

scala
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 := dialogOpen 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) { ... } 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:

scala
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 splitByIndex 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:

Source
scala
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 ShoppingCartExample

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.