Every message in this app is a tiny web page. Not a web view — there is no browser process, no web engine and no network stack hiding behind the bubbles. Instead each message is handed to litehtml, a compact C++ library that understands HTML and CSS well enough to lay out documents, and then asks its host application to do the actual drawing. That split — litehtml decides where things go, you decide how they look on screen — is what makes it such a good fit for chat.
Every message in this app is a tiny web page. Not a web view — there is no browser process, no web engine and no network stack hiding behind the bubbles. Instead each message is handed to litehtml, a compact C++ library that understands HTML and CSS well enough to lay out documents, and then asks its host application to do the actual drawing. That split — litehtml decides where things go, you decide how they look on screen — is what makes it such a good fit for chat.
This article walks through the whole journey of a message: from a string of HTML to pixels on a Retina display. Along the way we will look at what litehtml does for you, what it deliberately leaves out, and the handful of decisions that make HTML bubbles feel native instead of embedded.
litehtml does not ship its own HTML tokenizer. It bundles gumbo, Google's HTML5-compliant parser, which means malformed markup is repaired exactly the way a browser would repair it: unclosed paragraphs are closed, stray </b> tags are ignored, and a <div> inside a <p> splits the paragraph in two. That matters in a chat app, where messages come from people (and bots) who do not run validators.
The gumbo tree is then converted into litehtml's own element classes — el_div, el_text, el_image, el_table and friends. Text is split into words and spaces at this stage, because the layout engine wraps text one word at a time. Every word is measured once by calling back into the host: text_width("hello", font).
Three stylesheets are applied to every bubble, in order. First the master CSS that ships with litehtml — the equivalent of a browser's user-agent stylesheet, which knows that <b> is bold and <ul> has bullets. Then our user CSS, generated per bubble: it sets the system font, the text colour for light or dark mode, link colours, code blocks, and the small utility kit (.card, .btn, .badge…) used by the cards in this demo. Finally the message's own <style> blocks and inline style attributes.
Selectors cover what you would expect in practice: descendant and child combinators, classes and IDs, attribute selectors, :first-child, :nth-child(even), :not(), :hover and :active, plus ::before and ::after with generated content and CSS counters. Hover over the rows of the table in the next section to see :hover at work.
After styling, litehtml builds a render tree of render_block, render_inline, render_table and render_flex items. Layout is a single call: document::render(maxWidth). It positions every box, wraps every line, resolves percentages, floats and absolutely-positioned children, and returns the document's natural width — how wide it would like to be if nothing forced it to wrap.
That return value is exactly what a chat bubble needs. We render once at the maximum bubble width; if the natural width is smaller, we render again at that width and the bubble shrinks to hug its text. Because word widths were measured during parsing, re-layout is pure arithmetic and typically takes a small fraction of a millisecond.
| Capability | litehtml | WKWebView | Attributed string |
|---|---|---|---|
| Block, inline, floats, positioning | ✅ | ✅ | — |
| Tables with rowspan / colspan | ✅ | ✅ | ⚠️ basic |
| Flexbox | ✅ | ✅ | — |
| Linear / radial / conic gradients | ✅ | ✅ | — |
| CSS grid, transforms, shadows | — | ✅ | — |
| JavaScript | ⚠️ sandbox, HTML out | ✅ | — |
| Synchronous height for a list cell | ✅ | ❌ async | ✅ |
| Cost per message | one C++ object | a web process | one string |
litehtml never touches a pixel. To draw, it walks the render tree and calls methods on a document container — an abstract C++ class the host implements. There are about thirty of them, and they read like a to-do list for a 2D renderer:
class document_container {
// text
uint_ptr create_font(descr, doc, metrics);
pixel_t text_width(text, font);
void draw_text(hdc, text, font, color, pos);
// boxes
void draw_borders(hdc, borders, pos, root);
void draw_solid_fill(hdc, layer, color);
void draw_image(hdc, layer, url, base_url);
void set_clip(pos, radius);
// … ~20 more: gradients, list markers, links
};
Our implementation (LHContainer.mm) maps these onto Apple's APIs. Text goes through CoreText: every word becomes a CTLine, which gives us kerning, ligatures and — crucially — automatic font fallback, so emoji 🎉, Cyrillic, Japanese and Arabic all render even though the CSS asked for the system font. Shapes, borders and gradients are CoreGraphics paths; conic gradients map directly onto CGContextDrawConicGradient.
NSView and to an off-screen bitmap. The lhrender command-line tool in this repo uses the bitmap path to turn any HTML file into a PNG — handy for testing messages without launching the app.The hardest part of the integration is not drawing, it is agreeing. litehtml asks for a font's ascent, descent, height and x-height, and then trusts those numbers when it stacks lines. If the container measures a word one way and draws it another, lines overlap or wrap one word too early. The fix is boring and strict: measure and draw with the identical CTLine, round the line height up once, and derive the descent from it so that ascent + descent is exactly the height litehtml thinks it is.
Images follow the same rule. litehtml asks for an image's size during layout, so the container must know it synchronously. In this MVP that means images come from data: URIs, SF Symbols (sf:heart.fill) or pictures the app draws at launch (app:aurora). Remote images would need an asynchronous load followed by a re-layout of the affected bubble — very doable, just not done yet.
overflow-wrap. A 200-character URL without spaces will overflow its bubble; the bubble clips it. If you generate HTML from user text, insert <wbr> or zero-width spaces into very long tokens.There is no CSS grid, no box-shadow, no opacity, no transforms and no CSS animations. There is no bidirectional text reordering, so Hebrew and Arabic words are shaped correctly but placed left-to-right. And litehtml itself never runs a line of JavaScript — which for a messaging app is a feature, not a bug: the layout engine cannot track you, mine cryptocurrency or rewrite the chat around it.
Interactivity lives next to the engine instead. A message may carry a script that runs in its own JavaScriptCore sandbox, with no network and a strict CPU budget. Its render() returns a fresh HTML string, litehtml lays it out again, and clicks on data-action elements call back into onAction — which is how this article turns pages, resizes its text and remembers what you have read.
<style> block, a two-line static fallback and a script whose render() returns only the section you are reading.