DEV Community

DevSnack
DevSnack

Posted on Originally published at formaui.dev

Docs that run the real component

Documenting a UI library has three bad answers. Screenshots go stale by the next release. Videos don't let anyone interact. A separate demo app doesn't get installed, because the whole point of evaluating a library is that you haven't committed to it yet.

What you actually want is for the reader to click the button. Not a picture of the button.

So every component page on this site runs the real component. Same Kotlin source that compiles to the Android artifact, compiled a second time for the browser, rendering live on the page you're reading. Change a variant, disable it, flip it to dark — you're driving real Compose code, not a CSS class swap.

Here's how it works, what it cost, and the parts that were more awkward than they should have been.

Compiling the library twice

FormaUI ships to Android and only Android. But Compose has a wasmJs target, and the components are plain Compose, so the same source compiles for the browser too. That second compilation exists purely to build these docs. It isn't a platform you can ship FormaUI to, and I want to be unambiguous about that before "runs in the browser" reads as a support claim.

Compose on the web doesn't compile to DOM elements — it renders through Skia onto a single canvas. Your Column isn't a <div>; it's pixels, drawn by the same rendering path that draws them on a phone.

For a normal web app that's mostly a downside: no DOM means no browser text selection, a weaker accessibility story than real HTML, and a large runtime download before anything appears. For previewing a component library it's exactly right, because fidelity is the entire product. A DOM reimplementation of these components would be a second codebase that could drift, and the moment it drifts the preview is a lie. Rendering through Skia means what you see is what compiles to the artifact — bug for bug.

One bundle, not forty

This is the decision that made the whole thing viable.

The naive approach is one wasm build per component. It's a disaster: Skia's runtime is 8.3 MB and it would be in every single one of them. Instead there's a single parameterised entry point that reads a query parameter and looks the component up in a registry:

fun main() {
    val requestedId = componentQueryParam() ?: DefaultComponentId
    val entry = PreviewRegistry[requestedId]
    document.title =
        if (entry != null) "formaui-preview:$requestedId" else "formaui-preview:unknown:$requestedId"
    // …
}
Enter fullscreen mode Exit fullscreen mode

Forty pages, one runtime, downloaded once and cached across every page a reader visits. Adding a component to the docs is adding an entry to PreviewRegistry — no new build target, no new bundle.

That document.title line is doing quiet work too: it means an end-to-end test can assert which component actually rendered inside the canvas, which is otherwise opaque from the outside. When your UI is pixels on a canvas, the usual DOM queries have nothing to grab.

The controls are HTML. The component is canvas.

The bit I didn't expect to build.

Each doc page has a control bar — variant tabs, a disabled toggle, a light/dark pill. The obvious approach is to draw those inside the canvas, since that's where the component lives. That's wrong: canvas controls aren't keyboard-navigable in the way the rest of the page is, they don't inherit the site's styling, and they're invisible to a screen reader.

So the docs page draws the controls in its own DOM, as ordinary accessible HTML, and drives the component through them over postMessage. State lands in a couple of holders created before composition starts:

val controls = mutableStateOf(PreviewControls())
val hostDark = mutableStateOf<Boolean?>(null)

installPreviewControlBridge(requestedId) { message ->
    val update = decodePreviewControls(message)
    controls.value = update.applyTo(controls.value)
    update.dark?.let { hostDark.value = it }
}
Enter fullscreen mode Exit fullscreen mode

Writing Compose state from a JS event callback looks alarming and isn't, for a reason specific to this target: wasm is single-threaded here, so the write lands on the same thread the recomposer observes and the global snapshot picks it up like any other state write. On Android the same pattern would need thought about which thread you're on. Here there is only one.

Two details in that snippet are load-bearing.

Capability is announced, not assumed. Each registry entry declares which controls it honours, and the harness reports that to the embedder:

announcePreviewControls(
    component = requestedId,
    variants = entry.controls.variants.joinToString(ControlFieldSeparator),
    supportsEnabled = entry.controls.supportsEnabled,
)
Enter fullscreen mode Exit fullscreen mode

The docs page renders tabs and the disabled toggle only for what's declared. So a control can never appear for a preview that would silently ignore it — the failure mode where a user clicks something and nothing happens is designed out rather than tested for. The variant lists are derived from the Kotlin enums rather than written by hand, so a new variant in the library can't go missing from the docs tab row.

hostDark starts null, and null means something. Not "light" — "no host is driving theme." While it's null the preview keeps its own in-canvas light/dark switch on screen. The moment a host sends a theme, that switch steps aside rather than sitting next to the page's theme pill doing the same job. The same principle applies to every host-driven value: they default to inert, so opening a bundle directly — no iframe, no docs site — still renders the full self-contained preview it always did.

That's a rule worth generalising. An embeddable thing that only works when embedded is much harder to debug than one that degrades to standing alone.

Getting the bundle to the build

The docs site is a separate repo from the library, and Vercel checks out only the docs site. It has no Gradle, no Kotlin toolchain, and no access to a build of the library.

So the library's CI publishes previews-<version>.tar.gz as a release asset, and the site's prebuild step resolves it from one of two places: a sibling checkout of the library repo when developing locally, or that release asset when building on Vercel. The tarball layout is a cross-repo contract — changing it means changing the workflow that produces it, the script that consumes it, and the test that guards both.

A missing bundle fails the build, loudly and on purpose. My first instinct was to make it a warning so builds would never break. That was wrong: a warning means you ship a docs site where every preview is a "not available" card, and you find out from a user. There's an explicit opt-out flag for the rare case where shipping without previews is genuinely what you want. Loud by default, quiet on request.

The assembled bundle lands in a directory named for a content hash of its own contents:

public/previews/b3483432795a/
Enter fullscreen mode Exit fullscreen mode

A changed bundle always gets a new URL, so everything under that path can be cached forever — immutable, a year — including the entry HTML. Nothing ever needs revalidating, because a new bundle is reached by a new URL rather than by busting an old one. For a 39 MB payload that distinction matters more than it usually would.

About that 39 MB

Let's not bury it. The current bundles are 27.9 MB of wasm plus 8.3 MB of Skia, because they're development builds. That's a lot of bytes for a docs page.

Four things make it survivable:

  • Nothing is eager. Previews load on click or when an IntersectionObserver says they've scrolled into view — never on page load, and never on the component index, where forty of them would otherwise be in the DOM at once.
  • It's paid once per visitor, not once per component, because of the single-bundle decision above.
  • It's cached immutably, because of the content-hashed path.
  • None of it reaches a consuming app. This is a docs artifact. The Android dependency is an ordinary Compose library.

Release builds would be substantially smaller and I haven't done that work yet. I'd still rather ship an honest 39 MB preview than a fast screenshot that's lying.

Two smaller traps

Fonts don't come for free. The theme uses Public Sans, and on wasm that means the compiled resources have to ship alongside the wasm output so fonts load at runtime. Miss it and you get a preview that renders in a fallback font — which, for a library whose pitch is partly "the typography is better", is the worst possible failure mode. It looks fine. It's just wrong.

Browser support is narrower than you'd guess. The bundle needs WasmGC and JS string builtins — recent-Chromium-class, not "modern browsers." The check is pure feature detection with no network calls, so it can run before the bundle exists:

export async function isPreviewSupported(): Promise<boolean> {
  if (typeof WebAssembly === "undefined") return false;
  const [hasGc, hasJsStringBuiltins] = await Promise.all([gc(), jsStringBuiltins()]);
  return hasGc && hasJsStringBuiltins;
}
Enter fullscreen mode Exit fullscreen mode

Unsupported browsers get a card that says so plainly. I decided against a screenshot fallback, because a screenshot silently standing in for a live preview is exactly the stale-screenshot problem this was built to escape.

One more, since it's easy to skip: the message listener checks the sender's origin. A same-origin postMessage from anywhere else on the site shouldn't be able to spoof preview state. It's a docs site, the stakes are low, and it's still four lines.

Was it worth it?

Yes, for a reason I didn't anticipate: it changed what I'm able to say.

The pitch for an opinionated component library is "this looks good, so you don't have to make it look good." That's a claim about aesthetics, which means every word written in support of it is worth approximately nothing. Nobody believes "beautiful defaults," and they're right not to.

Live previews replace the claim with the thing. A reader doesn't evaluate my adjectives — they click the button, drag the slider, flip to dark, and decide for themselves in about four seconds. It's the shortest possible distance between "I'm reading about a library" and "I know whether I want this."

That's also the honest test of an opinionated library, and I'd rather fail it in public than win an argument about it in a README.


FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. Try every one of them live, which is the entire point of the last two thousand words.

Top comments (0)