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-preview is a published JVM library (static files + SSE tab reload). Sibling projects
(Specular docs, Preactile, any splice+preview app) should depend on it rather than rolling a file
server. The command is the same everywhere:
sbt todoConduitJS/ascentPreview # example app → http://localhost:8765
sbt docs/ascentPreview # this repo's docs site (auto port; URL printed in the terminal)ascentPreview starts Preview, prints the URL, and watches sources. From a terminal
(sbt <module>/ascentPreview) it stays in the foreground until interrupt (Ctrl-C). Typed at an
sbt prompt, it returns so tests and compiles still run; stop with <module>/ascentPreviewStop (or
exit). Save a .scala file (or index.html): the served tree rebuilds, assets/dev-stamp is
rewritten, and the tab reloads over /__ascent/reload. Preview does not restart. One-shot
(start and return, no watch): sbt <module>/ascentPreviewOnce. Do not ~ascentPreview.
Do not sbt ~docs/Test/runReload (that kills the Preview JVM on every compile).
When a JVM app calls Preview.serve with extra routes (datastar / hybrid), keep that server up and
watch the JS module with ascentPreviewAutoServe := false. Recipes: preview/README.md
and the Preview docs page.
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 Specular DocSpecs. Pages under
docs/assert under zio-test and SSR-render via specular. Browse the site at earlyeffect.rocks/ascent. Locally:sbt docs/ascentPreview(output intarget/site). CI deploys onv*tags and Docsworkflow_dispatch.
example/ holds one self-contained app per subdirectory (more coming). The first,
todo-conduit, is a synthwave-glass TodoMVC over conduit:
sbt todoConduitJS/ascentPreview
# open http://localhost:8765ascentPreview stages index.html + spliceFast JS + assets/dev-stamp, then starts
ascent-preview and watches sources in the background. After a .scala edit, the poller restages
and the tab reloads. The sbt prompt stays free.
Docs use the same command: sbt docs/ascentPreview.
Datastar and hybrid examples call Preview.serve with extra API routes so the client and API
share :8080:
sbt datastarExampleJS/ascentPreview # stage only (API server already serves the tree)
sbt datastarExampleServer/run # http://localhost:8080Try 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 testJVM # JVM suites (use testFull per module on sbt 2)
sbt todoConduitJS/ascentPreviewStage # splice + stage the example without a browser
sbt e2e/chekhovInstall && sbt e2e/testFull # Firefox suites against splice+preview
sbt "e2e/chekhovInstall; chekhovJs/testFull" # JSEnv typed handles (ascent-chekhov)
./scripts/install-git-hooks # once per clone: pre-commit runs scalafmtCheckAllComponent tests (JSEnv) mount a UI and talk to nodes as InputHandle / ButtonHandle:
import ascent.*, ascent.dsl.*, ascent.chekhov.AscentChekhov.withMounted
withMounted(ui) { root =>
root.button("inc").click *>
root.getByTestId("count").innerText.map(t => assertTrue(t == "1"))
}JVM ChekhovSuite uses the same lattice as tagged Playwright selectors (HtmlTag.input keeps fill off a button). Chekhov's Page.getByPlaceholder(text) already exists, so the typed call is on PageHandles (not an overloaded page.getByPlaceholder):
import ascent.HtmlTag
import ascent.chekhov.PageHandles
PageHandles.getByPlaceholder(page, "Your name", HtmlTag.input).fill("Ada")
PageHandles.getByTestId(page, "inc", HtmlTag.button).click
page.button("inc").clickascent 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]) |
history |
Optional URL session as a Squawk (History / Location) |
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) |
preview |
Static file server + SSE reload; optional extra routes / sidecar (ascent-preview) (readme) |
chekhov |
Typed Chekhov locators (ascent-chekhov): JSEnv live handles + JVM Page selectors |
sbt-ascent-preview |
enablePlugins(AscentPreviewPlugin) then sbt <module>/ascentPreview |
domgen |
JVM-only generator that emits the typed catalogs from W3C webref (readme) |
example/* |
One self-contained splice+preview 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.