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. It is generated from WebIDL, so a member the spec marks nullable reads as anOption(frame.contentWindow: Option[Window],el.getAttribute("id"): Option[String]), andcanvas.getContext(CanvasContextId.TwoD)answers aCanvasRenderingContext2Dwhere the IDL only says "one of five context types". Lifecycle hooks pin the element they take and check the live node against it:Lifecycle.onMount[dom.HTMLCanvasElement] { canvas => ... }runs on a canvas, and on any other element logs that it did not run.
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.
mcp-host is an MCP Apps host page in one file. A counter server
runs in the page, and each "Show a counter" frames its ascent-mcp-app view in an <ascent-mcp-view>
(mcp-host/README.md):
sbt mcpHostDemoJS/ascentPreview # http://localhost:8765Press +1 in the view: the element asks you before the call reaches the server, and the audit under it records each decision. "Close" asks the view to go before its frame is removed.
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.