Building a CSS flexbox engine in Go

I rebuilt CSS flexbox in Go to lay out a terminal UI framework. The interesting bugs were the places where the terminal disagreed with the browser: integer coordinates that needed float math, text wrapping that couples height back to width, and a box model that mostly evaporated.

The framework is go-tui. You write .gsx templates with HTML-like syntax and Tailwind-style classes, and a compiler generates type-safe Go from them. Here's a centered greeting in a bordered card:

gsx
templ Hello() {
    <div class="flex-col items-center justify-center h-full">
        <div class="border-rounded border-cyan p-2 gap-1 flex-col items-center">
            <span class="text-cyan font-bold">Hello, Terminal!</span>
            <br />
            <span class="font-dim">Press q to quit</span>
        </div>
    </div>
}

The part that makes this work is the layout engine. When a template says flex-col gap-1 grow, something has to turn those declarations into actual coordinates on a character grid. In a browser, that's the CSS flexbox algorithm. I wrote my own flexbox implementation for the terminal.

This post is mostly about the bugs. Reimplementing a spec gets interesting where your target environment disagrees with the spec's assumptions.

Why not wrap an existing engine?#

Facebook's Yoga is an excellent flexbox engine used by React Native. But it's written in C, and wrapping it means CGO. CGO complicates cross-compilation and forces anyone building the code to have a C toolchain. For a Go library that should work with go install and only has four external dependencies (all in the extended standard library), that's a non-starter.

Also a terminal is different enough from a browser that a browser-targeted engine would need significant adaptation anyway. Terminal coordinates are integers on a canvas that's maybe 200 columns wide, text measurement is character counting rather than font shaping, and CJK/emoji characters take two cells instead of one.

The core algorithm ended up requiring around 600 lines of code.

Characters are strange pixels#

A terminal is a fixed grid of cells. Every cell is 1 unit wide and 1 unit tall, and an 80x24 terminal has 1,920 cells you can treat as pixels.

Measuring text means counting characters. "hello" is always 5 cells wide. That simplicity is appealing until you hit the edge cases of swapping pixels for characters.

CJK (Chinese, Japanese, and Korean) characters and many emoji take two cells. The engine tracks this per cell, marking the phantom second column of a wide character as a "continuation cell" with width 0. Every place that measures width has to account for this.

Another problem is precision. A browser viewport might be 1920 pixels wide. A terminal is 80 columns. Being off by one pixel in a browser is invisible. Being off by one character in a terminal is immediately obvious, and it turns out that flexbox involves a lot of division.

The six phases (and one extra)#

My implementation follows the same general structure as the CSS flexbox spec, broken into six phases. Each phase answers one question and feeds its answer forward:

  1. Base sizing: how big does each child want to be before any flexing?
  2. Space distribution: there's leftover (or missing) space; who gets it?
  3. Min/max clamping: did any child end up outside its allowed range?
  4. Justify positioning: where along the main axis does each child sit?
  5. Cross-axis alignment: where does it sit perpendicular to the main axis?
  6. Coordinate conversion and recursion: convert to screen coordinates, recurse into children.

I also wedged in a phase 3.5 for text wrapping.

The algorithm works in terms of "main axis" and "cross axis" rather than width and height. The swap back to X/Y only happens in Phase 6.

Phase 1: base sizing#

The first design decision was how to represent dimensions. CSS has a lot of unit types. I needed three: a fixed number of cells, a percentage of available space, or auto (size to content). The Value type captures this:

go
type Value struct {
    Amount float64
    Unit   Unit  // UnitAuto, UnitFixed, or UnitPercent
}

func (v Value) Resolve(available, fallback int) int {
    switch v.Unit {
    case UnitFixed:
        return int(v.Amount)
    case UnitPercent:
        return int(float64(available) * v.Amount / 100.0)
    case UnitAuto:
        return fallback
    default:
        return fallback
    }
}

For Auto, the fallback is the child's intrinsic content size, whatever IntrinsicSize() returns. For a text element, that's the character count. For a container, it's recursive.

Phase 1 collects all this into a temporary struct per child:

go
type flexItem struct {
    node      Layoutable
    baseSize  int     // starting size from Phase 1
    mainSize  int     // final size after grow/shrink
    crossSize int
    mainPos   float64 // position along main axis
    crossPos  float64 // position along cross axis
    grow      float64
    shrink    float64

    ...
}

One slice of these gets allocated per container per layout pass. Results get written back to the element nodes afterward.

The mainPos and crossPos fields are float64, which was not my first instinct for integer coordinates. That decision came later, from a bug.

Phase 2: distributing space#

After base sizing, the engine knows what each child wants and what the container actually has. The difference is the free space:

go
freeSpace := mainSize - totalFixed - totalGap

Positive means room left over. Negative means overflow. The grow/shrink pass distributes or reclaims space proportionally:

go
if freeSpace > 0 && totalGrow > 0 {
    for i := range items {
        if items[i].grow > 0 {
            extra := int(float64(freeSpace) * items[i].grow / totalGrow)
            items[i].mainSize = items[i].baseSize + extra
        }
    }
}

The proportional math is the same whether you're distributing pixels or character cells, so this phase didn't have any terminal-specific surprises. Shrinking is the mirror image, subtracting proportionally from children with flex-shrink set.

Where it got interesting was Phase 3.

Phase 3: clamping#

Phase 3 clamps every child to its min/max constraints after the grow/shrink pass:

go
minMain := resolveMinMain(childStyle, isRow, mainSize, intrinsicMain)
maxMain := resolveMaxMain(childStyle, isRow, mainSize)
items[i].mainSize = clampFlex(items[i].mainSize, minMain, maxMain)

The CSS spec has a rule here. When min is greater than max, min wins.

go
func clampFlex(v, minVal, maxVal int) int {
    if v < minVal {
        return minVal
    }
    if maxVal >= minVal && v > maxVal {
        return maxVal
    }
    return v
}

In CSS flexbox, min-width defaults to auto, which resolves to the intrinsic content size. I implemented the same default (MinWidth and MinHeight default to Auto()).

A flex child with text content won't shrink below the width of that text unless you set min-width: 0 explicitly. This is the same gotcha that frustrates people in browser CSS.

Phase 3.5: text wrapping in the terminal#

This phase has no equivalent in browser flexbox. It came from a bug that took real time to isolate.

Certain layouts with long text strings had misaligned elements. Sometimes off by one row, sometimes by several. The elements next to them looked fine and the sizes were correct in isolation.

The cause took a while to trace. Phase 2 might change a child's width from 20 to 40 cells, so the text inside, which previously wrapped across four lines, now fits on two. The element should be shorter, but the cross-axis heights still reflected the pre-grow widths.

In a browser, the layout engine and text rendering engine are separate systems. Layout determines box dimensions; text reflow happens in a later pass. In a terminal, text is the content. There is no separate text engine. The layout algorithm has to account for wrapping during layout, because the wrapped height feeds directly into cross-axis sizing for the current container.

I ended up adding a recomputeTextWrapping() pass that asks each child how tall it needs to be at its new width.

go
wrappedHeight := child.HeightForWidth(childWidth)
_, intrinsicH := child.IntrinsicSize()

if wrappedHeight > intrinsicH {
    // update cross-axis size to the wrapped height
}

The obvious concern is a feedback loop: width changes height, height changes layout, layout changes width. I run this pass once and don't iterate. Terminal text wrapping is deterministic (there's no font hinting or subpixel kerning to make it context-dependent), so one pass gives the right answer. But I'll admit I held my breath the first time I tested it on a deeply nested layout.

I numbered it 3.5 because it's not a real phase in the CSS spec sense. It's a terminal-specific fix for a coupling that browsers don't have to deal with at this point in the pipeline.

Phase 4: justify positioning#

At this point every child has its final size. The remaining question is where each one goes along the main axis.

Implementation-wise, this phase was the cleanest to write. The entire thing reduces to two numbers: an initial offset and the spacing between children. The six justify modes (start, end, center, space-between, space-around, space-evenly) are just different formulas for those two values. Then you walk the children and accumulate positions:

go
for i := range items {
    items[i].mainPos = offset
    offset += float64(items[i].mainSize) + float64(gap) + spacing
}

The offset and spacing are float64. That looked wrong to me at first, on a grid where every coordinate is an integer. The next section explains why it's necessary.

Phase 5: the cross axis#

Phase 5 positions children perpendicular to the flow direction. The parent picks start, end, center, or stretch, and each child can override with align-self.

I like that AlignSelf is a pointer (*Align) instead of an enum value. Nil means inherit from the parent.

go
align := style.AlignItems
if childStyle.AlignSelf != nil {
    align = *childStyle.AlignSelf
}

This lets a row of children be top-aligned except for one that's centered, without any sentinel values or "inherit" enum members.

The default is stretch, which expands the child to fill the cross-axis. This is why elements in a flex row take on the row's full height by default, which catches people off guard. It caught me off guard too, even while implementing it, because I'd see a layout that looked wrong and then realize it was actually correct per spec.

Phase 6: coordinates and recursion#

Phase 6 converts main/cross positions back into screen X/Y:

go
if isRow {
    childAbsX = parentAbsX + lineItems[i].mainPos
    childAbsY = parentAbsY + line.crossPos + lineItems[i].crossPos
} else {
    childAbsX = parentAbsX + line.crossPos + lineItems[i].crossPos
    childAbsY = parentAbsY + lineItems[i].mainPos
}

In row mode, main becomes X, cross becomes Y. In column mode, they swap. Then the algorithm subtracts the child's margin and recurses. Each child runs through all six phases for its own children, relative to its content area.

This recursive structure means the algorithm only needs to solve one level of layout correctly. The same logic applies at every depth. It also means that the float64 positions from the justify and align phases propagate downward through the entire tree, and that's where I hit the jitter bug.

The jitter bug#

The first version of the layout engine used int for all positions. Character grid, integer coordinates, obvious choice.

That broke when I tested JustifySpaceEvenly during animation.

Three children in an 80-column container, each 10 cells wide. Free space is 50. Spacing is 50 / 4 = 12.5. Integer truncation gives 12. Children land at positions 12, 34, 56. Total: 12 + 10 + 12 + 10 + 12 + 10 + 12 = 78. Two cells vanish. Frame to frame, depending on how the math works out, a child might sit at column 56 one frame and 57 the next. On a character grid, that one-column jitter is very visible.

The fix was to track positions as float64 through the entire layout tree and round once at the end.

go
type Layout struct {
    Rect        Rect
    ContentRect Rect
    AbsoluteX   float64
    AbsoluteY   float64
}

When recursing into a child, the engine passes the parent's float position, not the rounded integer. Rounding happens once, via math.Round(), when converting to the final rect.

Yoga uses the same approach for React Native. I only found that out after arriving at the same solution independently, which was reassuring. Integer pixel grids have this problem regardless of whether the integers represent pixels or character cells.

Deviations from the CSS spec#

Percentage rounding#

CSS can assign a child 33.33% width. On a terminal, Value.Resolve() truncates to int:

go
case UnitPercent:
    return int(float64(available) * v.Amount / 100.0)

Three children at 33% in a 100-cell container: 33 + 33 + 33 = 99 cells. One cell unaccounted for. This hasn't been visually noticeable because the gap is at most one cell and float positioning smooths the coordinates. But it's a latent imprecision I want to fix eventually.

Flex-wrap and the chicken-and-egg problem#

When a container has flex-wrap enabled and its cross-axis size is auto, you get a circular dependency. The container needs to know how many wrap lines there are to determine its height, but it needs its height to run the layout that determines the wrap lines.

I solved this with a pre-sizing pass:

go
preLines := breakIntoLines(preItems, mainSz, style.Gap)

Before the real layout runs, the engine does a quick base-sizing pass, breaks items into lines, measures each line's cross-axis height, and sums them. Then the real layout runs with the now-known height.

When a container has FlexGrow > 0, the pre-sizing pass only expands, never shrinks, to avoid conflicting with space the parent already allocated through flex.

The box model gets simpler#

CSS has content-box vs border-box sizing, collapsing margins, outline vs border, and decades of accumulated box model interactions. Terminals don't need any of that. A border is one character per side, always. The entire box model is:

go
type Edges struct {
    Top, Right, Bottom, Left int
}

Rect.Inset(edges) subtracts the edges from a rect. That's the only box model operation the engine needs.

I don't think of this as cutting corners. The CSS box model is complicated because it carries decades of backward compatibility. Starting fresh for a constrained domain meant I could skip all of it, and the result is easier to reason about. This is the part of the project where the terminal's limitations worked in my favor.

From rects to characters#

The flex engine stops once every element has a rect. Getting those rects onto a terminal is a separate pipeline.

Rendering is three steps: paint, diff, flush.

Paint walks the element tree and writes into a Buffer, which is a double-buffered 2D grid of cells.

go
type Cell struct {
    Rune  rune
    Style Style
    Width uint8 // 1 normal, 2 wide, 0 continuation
}

The Width == 0 cells are the phantom second column of a wide character like 漢 or 🎉. The buffer keeps two slices: front is what the terminal is currently showing, back is what the next frame should look like. All paint writes go to back.

Paint has no z-buffer and no compositing. Every write is a plain overwrite into a cell. There's no decision about whether a given cell should be "a space, a character, or a border". The renderer just writes things in a specific order and the last write wins. For each element, in order:

  1. Fill the entire rect with spaces in the background style.
  2. Stamp border runes around the perimeter (┌─┐│└┘ for single, ╭╮╰╯ for rounded, ╔═╗║╚╝ for double). The fill from step 1 is still there, it just doesn't matter for the cells that the border writes to.
  3. Write text into the content rect, which is the element's rect with the border and padding subtracted. Text writes can't reach the border cells because they aren't in that inner rect.
  4. Recurse into children, each of which lays down its own background, border, and text inside its own rect.

Parents paint before children, so a child's fill covers the part of the parent's fill that sits under the child. That's the whole compositing model.

Diff walks the buffer in row-major order and returns only the cells where back != front. Repainting the full buffer every frame is essentially free (pure Go memory writes), but emitting the corresponding escape sequences over a tty is not. The diff step is what makes updates viable on a slow SSH connection.

Flush turns each changed cell into three things: a cursor move to (x, y), an SGR escape to set the style, and the rune itself. Most of the cursor moves and SGR escapes can be skipped. After printing a glyph the cursor auto-advances by one column, so if the next change is in the very next column there's no need to move. And if the next cell's style matches the last style we emitted, there's no need to set it again. In practice a row of changes collapses to one cursor move, one style set, and then a run of runes. The whole frame's escapes get built up in one byte buffer and written to the tty in a single call. Then back is copied into front.

go
func Render(term Terminal, buf *Buffer) {
    changes := buf.Diff()
    if len(changes) > 0 {
        term.Flush(changes)
    }
    buf.Swap()
}

The renderer doesn't know anything about flex-grow or justify-content. It just consumes rects. The flex engine and the renderer only meet at the rect boundary, which is what lets the testing strategy in the next section work.

Testing a layout engine#

Layout bugs have a specific character that makes them tricky to test. Small mistakes produce results that look almost right until the terminal resizes and everything breaks.

I started with the obvious approach: build element trees, run Calculate(), check rects. The layout package has a testNode type for this, a minimal Layoutable implementation with configurable styles and intrinsic sizes:

go
parent := newTestNode(DefaultStyle())
parent.style.Width = Fixed(100)
parent.style.Height = Fixed(50)
parent.style.Display = DisplayFlex
parent.style.Direction = Row

fixed := newTestNode(DefaultStyle())
fixed.style.Width = Fixed(30)

growing := newTestNode(DefaultStyle())
growing.style.Width = Fixed(0)
growing.style.FlexGrow = 1

parent.AddChild(fixed, growing)
Calculate(parent, 200, 200)

// growing.layout.Rect.Width == 70  (100 - 30)

These unit tests caught the straightforward bugs. But the class of bug that actually cost me time was subtler. Every element was the right size but the wrong position. A sidebar that's 20 characters wide but starts at X=1 instead of X=0, off by one in a way you'd never catch by checking widths.

The integration tests exist because of those bugs. One constructs a full dashboard (header, sidebar, flex-grow content area, footer) and checks that every element lands at its exact expected coordinate.

For rendering bugs that go beyond coordinates, tests render into a Buffer (a double-buffered 2D character grid) and compare buf.StringTrimmed() against expected output, character by character. The expected output is a multi-line string literal, literally a picture of what the terminal should display. If a border character is wrong, or text starts one column too far right, or a gradient interpolates incorrectly, the diff shows exactly which cell is off. It's crude, but it catches things that positional assertions miss.

The benchmarks track allocations: one []flexItem slice per container per frame, zero allocations for leaf nodes. They verify this across trees from 13 to 1,000 nodes. They also measure incremental layout by dirtying a single leaf and checking how much of the tree gets recalculated. The answer, currently, is all of it. That's on the list.

Try it#

The code is at github.com/grindlemire/go-tui.

Here's a dashboard layout as a .gsx component:

gsx
templ Dashboard() {
    <div class="flex-col h-full">
        <div class="border-single p-1">
            <span class="font-bold">Header</span>
        </div>
        <div class="flex-row grow">
            <div class="w-20 border-single">Sidebar</div>
            <div class="grow border-single p-1">Main Content</div>
        </div>
        <div class="border-single">Footer</div>
    </div>
}

flex-col stacks the three sections vertically. grow on the middle section takes whatever vertical space remains. Inside, w-20 pins the sidebar at 20 characters, and grow fills the rest. The layout engine figures out the coordinates.

Dashboard layout rendered in a terminal: a full-width Header row on top, a Sidebar and wider Main Content region in the middle, and a Footer row at the bottom.

Most of the time I spent on this went into reading the CSS spec, misunderstanding some part of it, implementing my misunderstanding, and then fixing it when a test case (or my eyes) told me something was off. The code is all in internal/layout. It's about 600 lines.

Opinions expressed here are my own and do not reflect those of any employer, past or present.