Skip to content

elm-ui — a layout language with no measurement seam at all

Category: purity as a forcing function on measurement. Last reviewed: August 23, 2026. Pinned at acae8857.

The control experiment for this survey. elm-ui is a complete layout and styling language whose host language cannot measure text — Elm has no FFI, no synchronous JavaScript interop, and no metrics service — and which shipped a production toolkit anyway. It is therefore the strongest available test of the question underneath friction §1: how much of layout genuinely needs measure, as opposed to needing somewhere to put measure.

FieldValue
LanguageElm 0.19 (0.19.0 <= v < 0.20.0, per elm.json)
LicenseBSD-3-Clause (elm.json)
Repositorymdgriffith/elm-ui
DocumentationElement module docs (package version 1.1.8)
Categorypurity as a forcing function on measurement
Pinned revisionacae8857a02e600cc4b4737ca2f70607228b4489
Backends shippedexactly one — the browser, via elm/virtual-dom (elm.json dependency list)
Renderer seamnone: the output is DOM nodes plus a generated stylesheet

NOTE

The umbrella deliberately excludes "React/DOM-style retained trees where the backend is a browser". elm-ui is on the list for the opposite reason from the others: not for what its seam does, but for what its absence proves about which layout decisions require measurement.

Overview

What it solves

elm-ui replaces CSS and HTML with a typed layout vocabulary — Element, row, column, el, paragraph, and a Length type of px / shrink / fill / fillPortion (src/Element.elm). Its stated goal is not to abstract a renderer but to make a class of layout bugs unwritable:

The high level goal of this library is to be a design toolkit that draws inspiration from the domains of design, layout, and typography, as opposed to drawing from the ideas as implemented in CSS and HTML.

This means: […] Many layout errors (like you'd run into using CSS) are just not possible to write in the first place!

README.md

Design philosophy

The rewrite note from its predecessor states the invariant that makes the no-measurement design tolerable:

The key insight is that it's not so much the separation of layout and style that is important as it is that properties affecting layout should all be in the view function. […] Everything should be explicit and in your view!

notes/CHANGES-FROM-STYLE-ELEMENTS.md

Everything that decides layout is a value the author wrote. Nothing that decides layout is a number the toolkit obtained by asking a font. That is a philosophical commitment in the source and a hard constraint of the language simultaneously, and the two are indistinguishable from inside.

How it works

There is no drawing seam to quote, because there is no drawing. Element is a function to a VirtualDom.Node, and the "backend" is CSS:

elm
type Element msg
    = Unstyled (LayoutContext -> VirtualDom.Node msg)
    | Styled
        { styles : List Style
        , html : EmbedStyle -> LayoutContext -> VirtualDom.Node msg
        }
    | Text String
    | Empty

src/Internal/Model.elm L120–L126

Sizing is a five-constructor sum type, and every constructor lowers to a CSS class rather than to a number:

elm
type Length
    = Px Int
    | Content
    | Fill Int
    | Min Int Length
    | Max Int Length

src/Internal/Model.elm L341–L346

renderWidth (L1353) is the whole of width resolution: Px n emits width: npx; Content emits the class wc, whose rule is width: auto (src/Internal/Style.elm L1223); Fill n emits the class wf, whose rules give a child width: 100% and, for heightFill, flex-grow: 100000 (src/Internal/Style.elm L888–L894), plus, inside a row, flex-grow: n * 100000 for a fillPortion; Min/Max emit min-width / max-width and recurse. Spacing is likewise a stylesheet rule — margin-left on any + any inside a row, margin-top in a column, and, for a paragraph, line-height: calc(1em + Npx) (src/Internal/Model.elm L2743).

The consequence is precise and worth stating plainly: elm-ui computes no extents. It emits a constraint system — flexbox — and the browser solves it. The toolkit never learns the answer, and neither does the program.

Text

Element.text is Internal.Text content (L549), rendered as a div carrying the classes t wc hc (textElement, L2058) whose rule is white-space: pre; display: inline-block (src/Internal/Style.elm L866). Its own doc comment records the restriction:

Note text does not wrap by default. In order to get text to wrap, check out paragraph!

src/Element.elm L545

paragraph (L1047) switches the subtree to display: block; white-space: normal; overflow-wrap: break-word (src/Internal/Style.elm L1626) — i.e. it hands wrapping entirely to the browser's line breaker. The source is candid about the limit of that arrangement:

css
/* Inline block allows the width of the item to be set
   but DOES NOT like wrapping text in a standard, normal, sane way.
   We're sorta counting that if an exact width has been set,
   people aren't expecting proper text wrapping for this element */

src/Internal/Style.elm L1653–L1656

The one metrics channel: hand-measured font data

elm-ui does have vertical font metrics — supplied as data by the author, never obtained by the toolkit:

elm
type alias Adjustment =
    { capital : Float
    , lowercase : Float
    , baseline : Float
    , descender : Float
    }

src/Element/Font.elm L141–L146

Font.with { name, adjustment, variants } attaches those four fractions to a typeface; convertAdjustment (src/Internal/Model.elm L3484) turns them into line-height, vertical-align and font-size in em (fontAdjustmentRules, L2518), which is what makes Font.sizeByCapital (src/Element/Font.elm L161) able to size text from cap-height to baseline.

Where do the four numbers come from? From a human dragging sliders over a rendered specimen: experiments/font-adjustment/FontAdjustment.elm is an elm-ui application whose Input.slider (L293) positions dashed guide lines over sample text until they touch the ascender, x-height, baseline and descender, and prints the fractions. Measurement is real, necessary, and pushed entirely out of the toolkit and out of the program, into a design-time tool and a literal in source.

The escape hatch that proves the rule

Runtime measurement exists in Elm, and its shape is the point. Browser.Dom.getElement returns a Task:

elm
getElement : String -> Task Error Element

elm/browser, src/Browser/Dom.elm L356, pinned at 1d28cd62

It is asynchronous, addressed by DOM id, resolved by getBoundingClientRect, and available only after the frame it describes has been rendered (doc comment, L347–L351). It cannot inform the layout that produced it. And elm/browser is not a dependency of elm-ui at all — elm.json lists only elm/core, elm/html, elm/json and elm/virtual-dom — so the escape hatch is not merely unused by the toolkit; it is out of the toolkit's reach by construction.

How elm-ui verifies layout it cannot compute

The rendering test suite is the tell. Testable.Found carries a bbox, a computed style, and textMetrics (tests-rendering/src/Testable.elm L187–L201); the numbers are gathered in JavaScript, by a harness page that calls element.getBoundingClientRect() and ctx.measureText(...) on a 2d canvas context and ships the results back over a port (tests-rendering/automation/templates/gather-styles.html L81, L116), driven through Selenium against real Chrome, Firefox, Edge and IE (tests-rendering/automation/run.js). A comment in Testable.elm concedes the resulting metrics are approximate:

The font metrics we currently have are actual, meaning for the text actually rendered, not the font as a whole. We also know the font size is 20, so we're just going to return 20.

tests-rendering/src/Testable.elm L204–L210

So the project's own answer to "did the layout come out right" requires measurement, a real browser, and a JS shim — none of which the library may contain. The measurement seam did not disappear. It moved to the test harness and became an out-of-process oracle.

Q1 — measurement units, and who answers

Nobody measures, and there is no unit. Length never resolves to a number inside elm-ui; Content (shrink) is spelled width: auto and answered by the browser's intrinsic-sizing algorithm, Fill by flexbox. The only extent-shaped values that cross the API are the author's own px literals and the four Adjustment fractions.

This is the extreme end of F1. The survey's other subjects put measurement somewhere other than the painter; elm-ui shows that a toolkit can be built where measurement does not belong anywhere in the toolkit — provided one backend, with a complete constraint solver, is willing to finish the job. The condition is the finding: you may delete measure only if the seam is a constraint system rather than a command stream, and only if every backend can solve it. A RecordingCanvas cannot solve flexbox, and neither can a terminal.

Q2 — is the backend contract stated in one place?

Does not arise as a contract question, and the reason is instructive. There is one backend and it is not pluggable: Element.layout returns Html msg (src/Element.elm L444), and the "contract" is the generated stylesheet in src/Internal/Style.elm — 1798 lines of CSS emitted as Elm data. It is stated in one place, exhaustively, because it is a single implementation rather than an interface.

What the project pays instead is a browser-compatibility matrix: the tests-rendering suite exists to catch the places where Chrome, Firefox, Safari and IE disagree about the same CSS, and the case directory carries the scars by name — Layout/SafariBugIssue147.elm, Layout/Columns/SafariTwo.elm, open/WeirdCentering.elm. A single seam with one implementation still had multiple backends; they were just not declared.

Q3 — semantic widgets or primitives?

Semantic all the way down, and the widget is the seam. row, column, paragraph, table, Element.Input.slider, scrollbarY are the vocabulary; nothing below them is expressible. Two consequences bear directly on friction §3:

  • A scrollbar is not drawn. scrollbarX / scrollbarY emit overflow-x: auto / overflow-y: auto (src/Internal/Style.elm L1199–L1213). The thumb's existence, size and position are computed by the browser from content extent — a quantity elm-ui cannot know. Our scrollbar payload carries content, viewport and offset precisely so that a backend can compute a thumb from them, through the one scrollbarThumb formula every backend shares; elm-ui carries nothing because it delegates the sizing along with the drawing.
  • There is no ellipsis. text-overflow appears nowhere in the source tree (verified: no match for text-overflow or ellipsis in src/). Truncation requires knowing that a string exceeds a box, which is exactly the fact the design forgoes. Clipping is available (clipoverflow: hidden, L1214) because clipping needs no measurement; eliding needs measurement, and so it does not exist.

The pattern generalises: an operation that only needs a constraint survives delegation; an operation that needs an answer does not. Fill, shrink, align, space, clip, scroll all survive. Ellipsis, "wrap only if it doesn't fit", content-sized thumbs and any layout that branches on a measured extent do not.

Q4, Q5, Q6, Q7 — not answered here

These four questions presuppose a reified draw command with a payload, an appearance and a coordinate. elm-ui has none, so the questions do not arise — but the reason they do not arise is a single fact worth recording once.

  • Q4 (command shape): the output is VirtualDom.Node, diffed by elm/virtual-dom. There is no command stream to encode well or badly.
  • Q5 (sub-unit placement): coordinates are continuous CSS pixels and fractional ems (vertical-align is emitted as a Float in em, L2518). Like Slint, Qt and egui, elm-ui never names a unit it cannot subdivide, so nothing in the vocabulary asks where a sub-unit mark lands. F6 is why that reads as relocation rather than dissolution: the browser still has to decide which device pixels a half-pixel border covers, and it decides alone, under rules elm-ui neither states nor can query. RuleEdge (our answer to the same question, friction §5) is an artifact of integer cell coordinates, but continuous coordinates would only move the decision to whoever rasterises — F6's conclusion, that the answer is a named fidelity plus a queried device unit, is what survives either coordinate choice.
  • Q6 (resolved or semantic styling): semantic on the way in (Background.color, Font.size), resolved by CSS cascade on the way out. elm-ui never carries both, because the thing it hands over — a class name — is the semantic token, and resolution happens in a layer it does not own. That is the HTML interpreter's position in our own tree, generalised to the whole toolkit — and one more subject for F9, which finds no one carrying a resolved appearance and a semantic role together. Our seam carries both: each payload stores the resolved fields its primitive paints from, and six of the eight carry a Slot beside them (friction §6).
  • Q7 (payload ownership): immutable, garbage-collected, shared by default. A String in Elm cannot dangle and cannot be mutated, so borrowed-slice lifetime — friction §7 — is unrepresentable rather than solved. That places elm-ui at the far end of F8: sharing an immutable value is the cheapest of the mechanisms the survey finds, and none of them is a borrow.

Q8 — extent query

Absent by construction, and this is the survey's cleanest illustration of the split F7 draws. The only extent query in the ecosystem is Browser.Dom.getElement, which returns scene, viewport and element rectangles (src/Browser/Dom.elm L367–L384) — three different extents, named separately, exactly as F7 says they must be. elm-ui answers only the surface one, and it answers post-hoc, asynchronously, from outside the toolkit. It never answers a layout or an ink extent, and it cannot derive one by scanning, because there is no display list to scan.

The corollary for our friction §8: a design that cannot ask "how big is this subtree" also cannot offer content-sized offscreen rendering. elm-ui does not offer it, and no one appears to have demanded it — a data point for how narrowly the layout-extent question can be scoped, and for F7's observation that answering it at construction is the alternative to answering it by scan. Our own scan folds op.rect across a built stream, since nothing on CmdBuffer, the display list or the arena reports a bound.

What would survive in sparkles:ui if measure were deleted

The question the survey actually needs, answered against libs/ui/src/sparkles/ui/layout.d. Text measurement enters the four-pass engine at exactly four call sites:

SitePassWhat it decides
tm.width(node.text)1, natural widtha text node's intrinsic width
tm.width(span.text)1, natural widtha rich node's intrinsic width
wrapLines(…, tm.width, …)3, height for widthgreedy line breaking of a text
wrapSpans(…, tm.width, …)3, height for widthline breaking of a rich paragraph

Everything else in 1053 lines is measurement-free: SizeSpec resolution (fit / fixed / grow / percent), distributeMain's leftover distribution and overflow reclamation with exact divmod remainders, alignOffset, padding and gap arithmetic, stretch, Visibility.collapsed removal from flow, clipX/clipY, and the placement pass. Those are precisely elm-ui's surviving set — fill, fillPortion, spacing, padding, alignment, clip — and the correspondence is not a coincidence: they are the decisions expressible as constraints among siblings rather than as a function of content.

What would break is equally precise: a fit/shrink box around text, and wrapping. elm-ui keeps both by delegating them to a solver in the backend. sparkles:ui has no such solver in either backend — a terminal grid cannot line-break for us and neither can Skia's canvas — so deletion is not available to us; only relocation is. That is the negative result this subject was on the list to produce, and it is worth having stated with the four call sites named.

IMPORTANT

The survey's sharpest local finding is adjacent and uncomfortable. sparkles:ui already does what F1 recommends: layout is parameterised on isTextMeasure and never touches a canvas (layout.d, isTextMeasure / CellMeasure). Meanwhile isCanvas!T counts Size measure(const(char)[]) among its five required methods (canvas.d L661–L692) — and no interpreter call site calls it. A grep across libs/ and apps/ finds .measure( on a canvas only in canvas.d's own concept and unittest — never in interp/. Backends do implement it (GridCanvas.measure is the grapheme-aware one), but every in-tree call site of layout passes the default CellMeasure, so no backend's answer reaches the engine; grid_canvas.d records exactly that as the open LAY5/MIG5 measurement gap. The cell-denominated measure of friction §1 is therefore less a backend forced to lie than a member of a concept the painter never consults — and sparkles:ui sits, operationally, in elm-ui's position without having chosen it.

Strengths

  • The measurement question is not deferred, it is answered "no". A design that cannot measure cannot accumulate measurement-dependent features by accident, and the API stays small for the same reason.
  • Layout is a constraint system, so it composes. fill inside fill inside a wrappedRow needs no arbitration by the toolkit.
  • One authority for extents means no disagreement. The class of bug where layout believes one advance and the painter draws another — the bug friction §1 warns about — is structurally impossible.
  • Metrics-as-data is a clean pattern. Font.Adjustment shows that a toolkit can accept font metrics as declared values rather than as a query, which decouples the layout engine from the measuring subsystem entirely.

Weaknesses

  • The forbidden set is large and popular. No ellipsis, no measured truncation, no toolkit-side wrapping decisions, no content-sized scrollbar thumb, no "size this offscreen surface to its content".
  • Correctness is only verifiable out of process. The rendering suite needs Selenium, four browsers, and JS measureText; nothing about layout can be asserted in a unit test.
  • Delegation reintroduces backend divergence under a different name. The Safari- and IE-named test cases are backend-parity bugs in a project that believed it had one backend.
  • px is a leak. The one absolute unit in the API is a device unit, so authorship is implicitly pinned to the browser's pixel — there is no Length a terminal could interpret.

Key design decisions and trade-offs

DecisionRationaleTrade-off
No text measurement anywhere in the libraryElm is pure and total; a synchronous metrics query is not expressibleEvery measurement-dependent feature is unbuildable, including ellipsis and conditional wrapping
Length lowers to CSS classes, never to numbersThe browser already has a constraint solver; duplicating it would only disagreeThe toolkit cannot answer questions about its own layout, including for tests
Wrapping is a paragraph element, not a text propertywhite-space: normal on a subtree is a delegation the browser can honourWrapping is a structural choice made before any width is known; no fallback if it wraps badly
Font metrics supplied as an author-declared AdjustmentVertical rhythm needs real metrics; a design-time slider tool can produce themMetrics are per-typeface literals that silently rot when the font changes
Scrollbars are overflow: autoThumb geometry requires content extent, which is unavailableNo control over the thumb, its size, its styling, or its behaviour
One backend, undeclaredKeeps the vocabulary small and the stylesheet exhaustiveBrowser divergence resurfaces as a Selenium matrix instead of as a capability query

Bearing on the proposal

  1. measure cannot be deleted from sparkles:ui, only relocated. The four call sites in layout.d are all text sizing or wrapping, and no sparkles:ui backend contains a line breaker willing to take them over. This closes the "maybe the seam does not need measurement at all" branch that friction §1 leaves implicitly open.
  2. The relocation F1 recommends is half in place, which is F2's point exactly. layout takes an injected isTextMeasure; isCanvas separately requires measure, and nothing calls it. The concrete action is removing measure from isCanvas, not designing a new font abstraction — and removing it settles only the placement decision, leaving F2's other five (unit, oracle authority, return shape, device parameterisation, what is measured) untouched. It also qualifies the emphasis of friction §1: "the best measurer in the system must lie" describes a member no caller consults, so the harm is latent rather than active, and the unit question is the live half.
  3. Adopt metrics-as-data alongside metrics-as-query. Font.Adjustment is a better fit for a terminal-first toolkit than a callback: a cell backend's metrics are four constants. An isTextMeasure implementation that is a plain value, not a canvas reference, is what every in-tree call site already uses (CellMeasure), and formalising that keeps layout @safe pure.
  4. Instantiates the last of F4's six positions. F4 asks where a lowering lives, and its sixth answer is nobody — the toolkit never names the thing, so there is nothing to lower. elm-ui is that answer in a shipped toolkit: its scrollbar is not a semantic op a backend degrades, it is an overflow policy. Reading our scrollbar payload against that: its fourteen fields exist because the toolkit does name the thing, and the naming is what a cell backend needs — a policy-shaped alternative ("this subtree scrolls") is available for the GPU backend and not for the cell backend, which has to draw the thumb itself. So friction §3 resolves toward keeping the semantics. What stays worth pruning is the part of the payload that is one backend's private answer: trackGlyph and thumbGlyph ride past every backend that never reads them, while scrollbarThumb in sparkles.ui.state is the shared lowering that already keeps the geometry from being computed twice.
  5. Confirms F7's decomposition from the far side. A toolkit with no extent query shipped for years, and the ecosystem's one answer names three rectangles and delivers them from the surface, asynchronously, after the fact (Browser.Dom.getElement). That is F7's split — surface, layout, ink — arriving whether or not a design asks for it. Scope friction §8 to the layout question and the offscreen sizing case that needs it, and answer it at construction rather than by folding op.rect over a finished stream.
  6. A warning about the "two seams" open question. elm-ui is the case where a toolkit assumed one backend and got four (Chrome, Firefox, Safari, IE) with no capability vocabulary to describe their disagreement. Whichever way the terminal/GPU seam question resolves, the disagreements must be nameable; elm-ui shows what it costs when they are not.

Sources