Skip to content

RxElement & HTML DSL ​

RxElement is uni-dom's component base, and the HTML/SVG DSL builds its markup. Embed an Rx value in the markup and uni-dom re-renders just that part of the DOM when it changes. See Setup for the dependency.

scala
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.rx.Rx
import scala.language.implicitConversions

Components ​

RxElement ​

RxElement is the component base. Override render to return the markup; add lifecycle hooks as needed (all default to no-ops):

HookWhen
def render: RxElementproduces the markup (required)
beforeRender: Unitbefore the first render
onMount(node: Any): Unitafter the element is attached to the DOM (node is the dom.Node)
beforeUnmount: Unitbefore the element is detached
scala
class Panel extends RxElement:
  override def beforeRender: Unit  = println("about to render")
  override def onMount(node: Any): Unit = println("mounted")
  override def render: RxElement   = div(cls -> "panel", "content")

Components nest by embedding one in another's markup:

scala
class Inner extends RxElement:
  override def render: RxElement = span("inner")

class Outer extends RxElement:
  override def render: RxElement = div(Inner())

RxComponent ​

RxComponent is a trait for "chrome" that wraps caller-provided content. Override render(content: RxElement) and invoke it with apply(content) (or apply() for empty content):

scala
object MainFrame extends RxComponent:
  override def render(content: RxElement): RxElement =
    div(cls -> "main-frame", content)

MainFrame(span("editor")).renderTo("app")   // chrome wraps "editor"

HTML DSL ​

Tags ​

HTML tags are values (div, span, h1–h6, p, a, img, ul, li, table, form, input, button, select, …). Calling a tag with children and attributes builds an element:

scala
div(cls -> "container", id -> "main",
  h1("Title"),
  p("Some content"),
  a(href -> "/next", "Go next")
)

Attributes ​

Attributes use name -> value (or call syntax name(value)):

scala
input(tpe -> "email", placeholder -> "you@example.com", required)
div(data("user-id") -> "123", data("role") -> "admin")   // data-* attributes

A few names are suffixed to avoid clashing with same-named tags — the tag keeps the bare name:

Use this attributefor the HTML attributebecause the bare name is the tag
cls (or `class`)class—
styleAttrstylestyle is the <style> tag / CSS builder
titleAttrtitletitle is the <title> tag
tpe (or `type`)type—
forId (or `for`)for—

Use += to append to an existing value (handy for classes). Boolean attributes such as disabled, required, readonly, and hidden work on their own, and also take a Boolean or Rx[Boolean], where false removes the attribute. Use HtmlTags.attr(name).boolean for any other one:

scala
import wvlet.uni.dom.HtmlTags

val busy = Rx.variable(false)

div(cls -> "base", cls += "highlighted")   // class="base highlighted"
button(disabled, "Can't click")
button(disabled -> busy, "Send")           // toggles with busy
video(HtmlTags.attr("playsinline").boolean)

Events ​

Event handlers accept either a typed E => U or a no-arg () => U:

scala
import org.scalajs.dom

button(onclick -> { () => println("clicked") }, "Click me")
input(oninput -> { (e: dom.Event) => handle(e) })
form(onsubmit -> { (e: dom.Event) => e.preventDefault() })

The full set is available: mouse, keyboard, form (onchange, oninput, onsubmit), focus, clipboard, drag, touch, pointer, wheel, animation, and media events.

Inline styles and CSS ​

The style object is dual-purpose — typed CSS properties, a raw string, or the <style> tag:

scala
div(style(style.display := "flex", style.gap := "8px", style.color := "blue"))  // typed properties
div(styleAttr -> "display:flex; gap:8px;")                    // raw string
head(style("body { margin: 0; }"))                            // <style> element

Conditionals ​

when / unless include markup conditionally (they render nothing when the condition doesn't hold):

scala
div(
  when(isLoggedIn, span("Welcome back")),
  unless(items.isEmpty, ul(/* ... */))
)

SVG ​

SVG tags and attributes are in scope from the same import; the SVG namespace is applied automatically to children of an <svg>:

scala
svg(viewBox -> "0 0 100 100",
  circle(cx -> 50, cy -> 50, r -> 40, fill -> "blue")
)

Reactivity ​

Embedding Rx ​

Embed an Rx[A] (or RxVar[A]) anywhere a child is expected; the toolkit subscribes and re-renders just that section when the value changes:

scala
class Counter extends RxElement:
  private val count = Rx.variable(0)
  override def render: RxElement =
    div(cls -> "counter",
      p(count.map(c => s"Count: ${c}")),
      button(onclick -> { () => count.update(_ + 1) }, "Increment")
    )

Rx.variable(x) creates an RxVar; mutate it with update(f), set(v), or := v. An Rx passed as an attribute value is likewise subscribed:

scala
val activeClass = Rx.variable("idle")
div(cls -> activeClass, "status")   // class updates when activeClass changes

Lists that update item by item ​

items.map(_.map(render)) rebuilds the whole list each time items changes, so every row loses its DOM state (scroll position, focus, a running animation) and a long list flickers. Use each with a key instead:

scala
case class Message(id: String, text: String)
val messages = Rx.variable(Seq.empty[Message])

ul(messages.each(_.id)(m => li(m.text)))

On each update, each renders only items with a new key and removes items whose key is gone. Items that stay keep their DOM nodes and are moved into the new order. An item whose key stays but whose value changes (!=) is rendered again. Keys must be unique; a repeated key keeps only its first item. each and eachRx are extension methods on Rx[Seq[A]], so each(messages)(_.id)(...) works too.

When a row owns state that should survive a change to its value, such as a chat bubble whose entrance animation plays once, use eachRx. Each key renders once, with its first value and an Rx of its later values, so the row binds only the parts that change:

scala
ul(messages.eachRx(_.id)((first, current) => li(current.map(_.text))))

An item whose value changes under the same key keeps its DOM nodes, and its Rx emits the new value (only when it differs, !=). When the key leaves the list, the item is removed and its Rx completes.

Two-way form binding ​

Bind a RxVar to a form control so edits flow back into the variable and programmatic changes flow into the DOM:

scala
val username = Rx.variable("")
input(tpe -> "text", value.bind(username))      // text input / textarea

val subscribed = Rx.variable(false)
input(tpe -> "checkbox", checked.bind(subscribed))

Use value.bindOnChange(v) to update on the change event (e.g. for <select>) instead of on every keystroke. A binding attaches its own listener, so an oninput or onchange handler on the same element runs alongside it.

Mounting and teardown ​

renderTo (and the lower-level DomRenderer) returns an RxDomNode holding the created dom.Node and a Cancelable. Keep it and call cancel() to detach listeners and unsubscribe the Rx bindings when the component goes away:

scala
val mounted = MyApp().renderTo("app")
// later:
mounted.cancelable.cancel

RxDomNode also exposes outerHTML, innerHTML, and textContent for inspection (used heavily in tests).

For advanced cases, wvlet.uni.dom.DomRenderer offers renderToNode, renderTo(node, ...), createNode, and renderToHtml (server-side string rendering).

Browser Integrations ​

The package includes reactive wrappers over common browser APIs. Each is an object exposing Rx streams and/or helper nodes (imported via wvlet.uni.dom.all.*):

ModuleWhat it does
RouterClient-side routing over the History API — Route/RouterInstance, outlet: Rx[A], push/replace, link, isActive
PortalRender children into a different DOM subtree (Portal.toBody, Portal.to(id))
ClipboardRead/write the clipboard, onCopy/onPaste handlers, copyOnClick
DragDropDrag-and-drop state machine — draggable, dropZone, fileDropZone, state: Rx[DragState]
GeolocationgetCurrentPosition, watch → position: Rx[Option[GeoPosition]]
StorageReactive localStorage/sessionStorage — Storage.local[A](key, default) → an RxVar-like StorageVar
MediaQueryMediaQuery.matches(query) → .rx: Rx[Boolean] for responsive layouts
FocusFocus tracking (active: Rx[Option[Element]]), focus traps, autofocus on mount
KeyboardGlobal and scoped shortcuts — Keyboard.bind("ctrl+s", () => save()), isPressed, modifiers; onEnter for IME-safe Enter in text fields
PointerDrag / PaneResizePointer drags tracked on window (dragging: Rx[Boolean]), and drag-to-resize handles for split panes and sidebars
ClickOutsideDetect clicks outside an element (hide(visible) for menus/popovers)
NetworkStatusonline/offline: Rx[Boolean]
WindowScroll / WindowDimensions / WindowVisibilityReactive window scroll position, size, and tab visibility
AnimationFramerequestAnimationFrame loops — loop, once, fixedStep, timed
TransitionEnter/leave CSS transitions — fade(visible)(...), slide(visible)(...)
ValidateForm validation — required, minLength, pattern, email; field and whole-form state
DomRefDirect element handles — current: Option[E], focus(), getBoundingClientRect()
DomObserversIntersectionObserver / ResizeObserver bindings (lazy load, element-size → RxVar)

Each integration that attaches global listeners returns a Cancelable (or has a stop()), so you can detach it during teardown.

Persisting values with Storage ​

Storage.local and Storage.session store String, Int, Long, Double, and Boolean values out of the box. An enum whose cases take no parameters gets a codec with derives StorageCodec, which stores each value by its case name:

scala
import wvlet.uni.dom.all.*

enum Theme derives StorageCodec:
  case System, Light, Dark

val theme = Storage.local("theme", Theme.System)
theme := Theme.Dark // stored as "Dark"

A stored name that matches no case, for example after a case was renamed, reads as the default value. For other types, write a given StorageCodec[A] with encode and decode.

Enter in text fields ​

Keyboard.onEnter submits on plain Enter and leaves Shift+Enter (a newline) to the textarea. It also skips the Enter that confirms an IME conversion, so users typing Japanese, Chinese, or Korean can pick a candidate without sending the message:

scala
val draft = Rx.variable("")
textarea(value.bind(draft), Keyboard.onEnter(() => send(draft.get)))

Shortcuts from Keyboard.bind and Keyboard.scoped never fire during IME composition either. In your own onkeydown handler, check Keyboard.isComposing(e) for the same behavior.

Resizable panes ​

PaneResize turns any element into a resize handle: dragging changes a size RxVar within a SizeRange, and double-clicking restores the default. It adds role="separator" and aria-orientation; the handle's look and position stay in your markup:

scala
val width  = Rx.variable(272)
val resize = PaneResize(width, SizeRange(min = 208, default = 272, max = 480), ResizeDirection.Right)

aside(
  cls       -> "relative",
  styleAttr -> width.map(w => s"width: ${w}px"),
  div(
    cls       -> "absolute inset-y-0 right-0 w-2 cursor-col-resize touch-none",
    ariaLabel -> "Resize sidebar",
    resize.bind
  ),
  sidebarContent
)

ResizeDirection is the way the pointer moves to make the pane larger: Right for a left sidebar, Left for a right-hand dock, Up or Down for vertical splits. resize.dragging is an Rx[Boolean], handy for turning off a width transition during the drag. Constructing a PaneResize clamps the current size into its range, so a width kept in Storage.local and saved under an older, wider range is corrected before the pane renders.

For other drags (sliders, movable cards), use PointerDrag(onMove = (dx, dy) => ...) directly; it reports the distance from the start point and ends on pointerup or pointercancel.

Released under the Apache 2.0 License.