What is GEA · Engineering

Real CSS, compiled to a native layout engine

You write flexbox, transforms, @keyframes and media queries — real CSS. GEA compiles it into a tiny native engine that lays out and paints every frame on the device. No browser, no interpreter. Here is how it works, and what it costs.

The layout engine · part of the GEA stack

A stylesheet usually implies a browser behind it — a parser, a cascade, a layout engine, a rasteriser, megabytes of machinery. None of that fits on a chip with half a megabyte of RAM. So the usual answer is to give up CSS and hand-place pixels in C. GEA keeps the CSS and drops the browser instead.

The layout engine is a slice of CSS written from scratch in C++: flexbox, a small grid, 3D transforms, keyframe animation and media queries, laid out and painted natively every frame. The slice is chosen for impact, not completeness — and the whole of it is about six thousand lines.

Real CSS on an ESP32. A full weather app — fonts, variables, the cascade, flexbox, grid, animations and gradients — written in TypeScript, JSX and CSS, and rendered natively on the device.

CSS compiles at build time

Your CSS is read at build time and turned into native registration calls that run once at boot. After that there is no stylesheet text on the device and nothing to re-parse. A rule is typed data, matched against the live element tree.

.cube-title {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
}
↓ geatsc
auto& sheet = StyleSheet::instance();
sheet.registerRule("cube-title", "display", "flex");
sheet.registerRule("cube-title", "flex-direction", "column");
sheet.registerRule("cube-title", "align-items", "center");
sheet.registerRule("cube-title", "gap", "5px");

Colours never travel as strings into a frame. A literal is quantised once, at build time, to the panel's pixel format — on a 16-bit RGB565 display, #12130f becomes a single constant the rasteriser writes straight to the framebuffer.

.cube-app { background-color: #12130f; }
↓ geatsc
// quantised once, at boot, to the panel's RGB565
constexpr native_t bg = rgb565FromRgb888(0x12, 0x13, 0x0f); // 0x1081

Layout, from scratch

Layout is a reimplementation of flexbox: flex-direction, wrap, justify-content, align-items, gap, flex-grow and a real box model. Next to it sit a small grid (auto-placement, up to eight tracks per axis), block flow, and absolute/relative positioning with z-index. The whole layout pass is about 1,300 lines.

Sizes resolve against the live panel: px, %, vw, vh, vmin, clamp(), min()/max() and var() all work, and custom properties are dependency-tracked — change --accent and only the nodes that read it recompute, not the whole tree. Media queries are evaluated at runtime against the real panel, so one stylesheet reflows across a 410×502 AMOLED, a 480×480 dial and an e-paper panel without a rebuild.

Transforms and animation

The 3D transforms are real. Each node keeps rotate, translate and scale components with a perspective and back-face culling, projected per-point every frame. That is what makes the demo cube an actual cube, not a sprite. @keyframes run on-device through a runtime animation engine that decomposes a transform into per-component tracks and eases them — cubic-bezier() and steps() included — applying the result each tick.

@keyframes cube-spin {
  0%   { transform: rotateX(-18deg) rotateY(24deg) rotateZ(0deg); }
  100% { transform: rotateX(342deg) rotateY(384deg) rotateZ(0deg); }
}
↓ geatsc
sheet.registerKeyframeRule("cube-spin", 0,    "transform", "rotateX(-18deg) rotateY(24deg) rotateZ(0deg)");
sheet.registerKeyframeRule("cube-spin", 1000, "transform", "rotateX(342deg) rotateY(384deg) rotateZ(0deg)");

A compact data model

The engine is small because the data model is. Each node carries one typed ComputedStyle struct — no hash map, no per-property allocation. The hot struct is 136 bytes. Cold properties like transforms, gradients and shadows move into a pooled RareStyle, so a plain node pays a two-byte handle and nothing more. It's the same hot/cold split Blink and WebKit use, at a fraction of the size.

Computed style per node — lower is leaner, bytes
Monolithic struct320 B
Hot struct (now)136 B
The hot/cold split. Cold styling pooled into a RareStyle behind a 2-byte handle. Across one screen, retained tree state fell from 803 KB to 159 KB.
~6,000
lines of C++ — the whole style + layout engine
136 B
typed style per node, cold props pooled
402 KiB
framebuffer, RGB565, in PSRAM
Footprint. A browser engine is tens of millions of lines. This is a deliberately focused slice.

Rendering is compute-bound

Painting goes through a retained display list and dirty rectangles. Only the commands that intersect a changed region are replayed, in paint order, into an RGB565 framebuffer that is then DMA'd to the panel in chunks. The honest bottleneck is the CPU, not the bus. Most of a frame is spent rasterising fills, gradients, anti-aliased shapes and text — not moving bytes.

One CSS-3D-cube frame, ESP32-S3 — ms of a ~24 ms frame
Rasterise (CPU)13 ms
DMA flush6 ms
Re-project2.7 ms
Other2 ms
Where a frame goes. CPU rasterisation dominates. The engine is compute-bound, not DMA-bound.
The frame above, in motion. Real CSS keyframes, perspective and translucent faces on a $3 ESP32-S3. We pulled the whole thing apart in We taught a $3 chip to run CSS.
On-device frame rate — higher is smoother, fps
64 balls~60
CSS cube · opaque faces~60
CSS cube · translucent faces42
Measured on a few-dollar ESP32-S3 AMOLED. The 64-ball scene and the cube with opaque faces are vsync-locked to the panel's ~59.5 Hz. Translucent faces drop the cube to 42 fps — a hardware ceiling on this board, not a code limit.

Write the CSS you already know, and GEA runs it natively on the device — the same stylesheet, from a microcontroller to a Mac.

What works, and what doesn't

The boundary is deliberate, and worth stating plainly. What compiles and runs today:

  • Flexbox, a small grid, block flow, absolute/relative positioning
  • 3D transforms, @keyframes with cubic-bezier()/steps() easing
  • Media queries against the live panel, vw/vh/vmin/clamp(), dependency-tracked var()
  • border-radius and ellipses, filter: blur(), linear and radial gradients, shadows
  • Anti-aliased text from fonts rasterised at build time — no TTF parser on the device

And what it deliberately leaves out:

  • Authored CSS transition: — use @keyframes or the runtime animation engine instead
  • Full CSS Grid placement, position: fixed/sticky, em/rem units
  • backdrop-filter, scroll-snap, real stacking contexts, forms, tables and SVG

CSS, compiled to native

The same engine lays out the examples on this site for a microcontroller, embedded Linux, macOS and iOS — from one stylesheet. The code is the best place to see it for yourself.