Effect-native reactive UI for Scala 3. ascent renders straight to the DOM — no virtual DOM, no diffing — building a pure UI tree once, then surgically patching the exact node, attribute, or child-list behind each reactive boundary. The substrate is ZIO, so effects, typed errors, and resource-safe lifecycles are first-class rather than bolted on: you build your UI the way you build the rest of your ZIO app.
Status: early / pre-1.0. Published under early-semver (
versionScheme := "early-semver") — the API can change between minor versions until1.0. See Status.
import ascent.*
import ascent.dsl.*
val counter =
for count <- sq(0)
yield E.div(
E.button(Ev.onClick(_ => count.update(_ - 1)), "-"),
E.span(count.map(_.toString)), // only this text node re-renders
E.button(Ev.onClick(_ => count.update(_ + 1)), "+"),
)Conventional virtual-DOM frameworks re-render a subtree and diff it on every change — which
rebuilds nodes that didn't change and, in practice, loses input focus and caret position on every
keystroke. ascent takes the opposite approach: the UI is a value, reactive values (Squawks) mark
the boundaries that can change, and the engine touches only those boundaries. A <li> that
isn't changing is never rebuilt; an <input> you're typing into keeps its focus and selection.
Three things it optimizes for:
- Developer ergonomics — a terse, two-import DSL that reads like the HTML it produces.
- Type safety — elements, attributes, ARIA, events, and CSS are all typed; mismatches are compile errors, not runtime surprises.
- An effect system you can lean on —
Squawkand the optionalconduitstate integration are ZIO all the way down, so state, side effects, and teardown compose withfor.
A view file needs exactly two imports:
import ascent.* // elements, attrs, aria, events, css authoring, Ctx, Squawk, … + aliases
import ascent.dsl.* // the builder DSL: el/attr `apply`, when / forEach / scoped / fragmentimport ascent.* unions whatever ascent modules are on your classpath (it's an open package
shared across the jars), so you never reach into ascent.domtypes / ascent.css / ascent.js
by hand. Both the descriptive names and short aliases are available — pick whichever reads best:
| Alias | Full name | Example |
|---|---|---|
E |
Elements |
E.div, E.input |
A |
Attrs |
A.className, A.`type` |
Aria |
AriaAttrs |
Aria.role, Aria.ariaLabel |
Ev |
TypedEvents |
Ev.onClick, Ev.onInput |
S |
Styles |
S.color, S.padding.px(8) |
Elements, attributes, and children compose through one uniform apply. A bare String becomes a
text node; a Squawk[String] becomes a reactive text node; an attribute key applied to a Squawk
becomes a reactive attribute — all without ceremony:
E.label(
A.className("title"),
Ev.onDblClick(_ => startEditing),
todoText, // Squawk[String] → live-updating text node
)Control flow is just functions that return UI:
when(isEditing)(editor) // reactive: mounts/unmounts as the Squawk[Boolean] flips
forEach(items)(_.id)(renderRow) // keyed list: reuses DOM nodes across reordersEverything you place in the tree is typed against the real web platform — the catalogs are
generated from the vendored W3C webref data (no npm, pinned snapshot), so they track the
actual spec:
- Attributes carry their value type and codec.
A.checkedisAttrKey[Boolean],A.autofocusencodes as a presence flag,Aria.ariaPressedserializes to"true"/"false". Passing the wrong type doesn't compile. - Events are typed.
Ev.onClickhands your handler adom.PointerEvent,Ev.onKeyDownadom.KeyboardEvent— nojs.Dynamiccasts at the call site. For the common two-way-binding needs there's an ergonomic, platform-neutral event withtargetValue/key:Events.onInput(e => setDraft(e.targetValue.getOrElse(""))) Events.onKeyDown(e => if e.key.contains("Enter") then submit else ZIO.unit)
- CSS is typed too.
S.padding.px(8),S.display.flex,S.color.rgba(255, 0, 170, 0.6)— typed value grammars per property, with class names derived automatically:object Card extends CssClass(S.padding.px(16), Selector(":hover", S.color("cyan"))) E.div(Card.toAttr, "hello")
- Void elements reject children at compile time, and the DOM facade (
ascent.dom) is our own typed@js.nativelayer — ascent does not depend onscalajs-dom.
Squawk[A] is ascent's reactive value-over-time. Constructing a mutable source is an effect
(sq(0): UIO[Source[Int]]); set / update / observe are effects; map and derived
combinators are pure and lazy, so the DSL stays clean. Changes are deduped by a pluggable
Eq[A] — an Eq-equal write is a no-op, so observers never see spurious updates. Every reactive
boundary registers a paired teardown; nothing leaks when a subtree unmounts.
For application state, ascent ships an optional bridge to
conduit, a ZIO-based unidirectional, lens-keyed immutable
store. Because both sides are ZIO, the bridge composes natively — and views never see conduit at
all. A view receives a Ctx[M] handle and speaks two verbs: read a reactive slice, dispatch an
action.
def component(ctx: Ctx[TodoApp.Model]) =
for draft <- ctx.squawk(_.draft) // reactive Squawk of a (possibly nested) slice
yield E.input(
A.value(draft),
Events.onInput(e => ctx(TodoApp.Action.SetDraft(e.targetValue.getOrElse("")))),
)Ctx exposes the full power of conduit's optics as plain field paths — nested reach
(ctx.squawk(_.a.b.c)), whole-model (ctx.model), one-shot reads (ctx.read(_.path)), and
element-scoped subscriptions for collections:
forEach(visible)(_.id) { t =>
scoped { // opens a ZIO Scope tied to this row's lifetime
ctx.squawkKey(_.todos, t.id).map { item => // subscribes to just this map entry
TodoItem.render(ctx)(t.id, item.map(_.getOrElse(t)))
}
} // row leaves → Scope closes → listener unsubscribes
}A change to a sibling key never wakes this row, and a churning list never accumulates dead
listeners — the scoped { … } boundary (a zio.Scope) guarantees the conduit subscription is
released exactly when the row unmounts. Application state and actions live in one model file; view
files import only ascent.*.
ascent is published to Maven Central under the rocks.earlyeffect group. It's cross-built for
JVM, Scala.js, and Scala Native, so use %%% (which picks the right platform artifact) in a
sbt-crossproject / Scala.js / Native build:
libraryDependencies += "rocks.earlyeffect" %%% "ascent-core" % "<version>" // Squawk + UI AST + DSL (all platforms)
libraryDependencies += "rocks.earlyeffect" %%% "ascent-js" % "<version>" // browser mount engine (Scala.js)
libraryDependencies += "rocks.earlyeffect" %%% "ascent-css" % "<version>" // typed CSS-in-Scala (all platforms)Other modules follow the same ascent-<module> naming (e.g. ascent-html, ascent-conduit,
ascent-datastar) — see the module table below. On a plain JVM-only build use %% instead of %%%.
ascent is early and evolving. It's well-tested (1000+ zio-test cases across JVM/JS/Native, leaning
on negative and pathological cases) and used to build real apps, but pre-1.0 the API is not frozen —
expect breaking changes between minor versions (that's what the early-semver scheme signals).
A few things to know before adopting:
- Some modules are low-level plumbing.
ascent-dom-core,ascent-mount-engine, andascent-dom-facadeare engine internals that other modules depend on transitively — they're published so consumers resolve, not because you'll typically depend on them directly. Most apps useascent-core+ascent-js(+ascent-css, andascent-conduitfor state). - Docs are a work in progress. Full documentation is coming — authored as runnable, tested
examples and rendered with ascent itself, via the forthcoming
specular tests-as-docs generator (a docs page is a Scala
Specthat both asserts in CI and SSR-renders through ascent, so examples can't drift). For now, the example app and the module table below are the best starting points.
example/ holds one self-contained app per subdirectory (more coming). The first,
todo-conduit, is a synthwave-glass TodoMVC over conduit, served by
Vite. The app is Scala.js, so it has to be linked by sbt — the
@scala-js/vite-plugin-scalajs plugin runs the sbt link step for you and rewires the
scalajs:main.js import to its output:
cd example/todo-conduit
npm install # first time only
npm run dev # invokes `sbt todoConduitJS/fastLinkJS`, then opens http://localhost:5173npm run dev triggers sbt todoConduitJS/fastLinkJS on demand — so the first load (and the first
load after a .scala edit) waits on sbt to relink. You can also run the link step directly, e.g.
to build the JS without a browser, or to pre-warm sbt before serving:
sbt todoConduitJS/fastLinkJS # one-shot link
sbt "~todoConduitJS/fastLinkJS" # watch mode: relink on every changeTry the TodoMVC: add todos, toggle and edit them (double-click a row), switch the All / Active / Completed filters, clear completed. Notice that editing a row preserves caret position and that toggling one item doesn't rebuild the others — that's the surgical patching.
sbt test # full cross-platform suite (JVM / JS / Native)
sbt todoConduitJS/fastLinkJS # link the example without a browserascent is built with Scala 3 and cross-compiled to JVM, Scala.js, and Scala Native via
sbt-projectmatrix. The module layout:
| Module | What it is |
|---|---|
dom-types |
Zero-dependency typed element / attribute / event / ARIA catalogs |
core |
The UI AST, the Squawk reactive primitive, and the DSL |
dom-facade |
Our own typed @js.native DOM facade (no scalajs-dom) |
js |
The DOM mount/binding engine, typed events, canvas helper |
css |
CSS-in-Scala authoring (typed properties, classes, at-rules) |
conduit |
Optional bridge to the conduit state store (Ctx[M]) |
html |
UI → HTML string renderer — standalone SSR (readme) |
datastar |
datastar protocol core + SignalStore (readme) |
datastar-js |
Browser datastar runtime: SSE → Squawk / DOM, action dispatch (readme) |
datastar-http |
Server wrapper over zio-http-datastar-sdk (readme) |
domgen |
JVM-only generator that emits the typed catalogs from W3C webref (readme) |
example/* |
One self-contained app per subdir (e.g. todo-conduit) |
Runtime dependencies are kept deliberately small: core needs only ZIO and the zero-dep
dom-types; conduit is opt-in.
ascent is licensed under the Apache License 2.0.