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.
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.rx.Rx
import scala.language.implicitConversionsComponents
RxElement
RxElement is the component base. Override render to return the markup; add lifecycle hooks as needed (all default to no-ops):
| Hook | When |
|---|---|
def render: RxElement | produces the markup (required) |
beforeRender: Unit | before the first render |
onMount(node: Any): Unit | after the element is attached to the DOM (node is the dom.Node) |
beforeUnmount: Unit | before the element is detached |
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:
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):
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:
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)):
input(tpe -> "email", placeholder -> "you@example.com", required)
div(data("user-id") -> "123", data("role") -> "admin") // data-* attributesA few names are suffixed to avoid clashing with same-named tags — the tag keeps the bare name:
| Use this attribute | for the HTML attribute | because the bare name is the tag |
|---|---|---|
cls (or `class`) | class | — |
styleAttr | style | style is the <style> tag / CSS builder |
titleAttr | title | title 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:
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:
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:
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> elementConditionals
when / unless include markup conditionally (they render nothing when the condition doesn't hold):
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>:
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:
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:
val activeClass = Rx.variable("idle")
div(cls -> activeClass, "status") // class updates when activeClass changesLists 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:
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:
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:
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:
val mounted = MyApp().renderTo("app")
// later:
mounted.cancelable.cancelRxDomNode 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.*):
| Module | What it does |
|---|---|
Router | Client-side routing over the History API — Route/RouterInstance, outlet: Rx[A], push/replace, link, isActive |
Portal | Render children into a different DOM subtree (Portal.toBody, Portal.to(id)) |
Clipboard | Read/write the clipboard, onCopy/onPaste handlers, copyOnClick |
DragDrop | Drag-and-drop state machine — draggable, dropZone, fileDropZone, state: Rx[DragState] |
Geolocation | getCurrentPosition, watch → position: Rx[Option[GeoPosition]] |
Storage | Reactive localStorage/sessionStorage — Storage.local[A](key, default) → an RxVar-like StorageVar |
MediaQuery | MediaQuery.matches(query) → .rx: Rx[Boolean] for responsive layouts |
Focus | Focus tracking (active: Rx[Option[Element]]), focus traps, autofocus on mount |
Keyboard | Global and scoped shortcuts — Keyboard.bind("ctrl+s", () => save()), isPressed, modifiers; onEnter for IME-safe Enter in text fields |
PointerDrag / PaneResize | Pointer drags tracked on window (dragging: Rx[Boolean]), and drag-to-resize handles for split panes and sidebars |
ClickOutside | Detect clicks outside an element (hide(visible) for menus/popovers) |
NetworkStatus | online/offline: Rx[Boolean] |
WindowScroll / WindowDimensions / WindowVisibility | Reactive window scroll position, size, and tab visibility |
AnimationFrame | requestAnimationFrame loops — loop, once, fixedStep, timed |
Transition | Enter/leave CSS transitions — fade(visible)(...), slide(visible)(...) |
Validate | Form validation — required, minLength, pattern, email; field and whole-form state |
DomRef | Direct element handles — current: Option[E], focus(), getBoundingClientRect() |
DomObservers | IntersectionObserver / 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:
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:
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:
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.