| name | flexbox |
| description | TimeWarp.Flexbox library - compute CSS flexbox layouts (positions and sizes) in pure C# with no UI framework; a Native AOT-safe port of Facebook's Yoga engine. Use for laying out boxes/nodes in canvases, terminals, games, PDF/image generation, or custom renderers - anywhere you need flex-direction, flex-grow, wrap, gap, align/justify, percentages, or absolute positioning computed for you. |
TimeWarp.Flexbox
Pure C# flexbox layout engine — build a tree of nodes, set CSS-flexbox styles,
and read back computed pixel positions and sizes. No UI framework involved.
Repository: https://github.com/TimeWarpEngineering/timewarp-flexbox
Package: TimeWarp.Flexbox
Behavior is a from-scratch port of Facebook's Yoga engine, verified against
Yoga's own conformance suite (530 generated tests, LTR and RTL). Zero runtime
dependencies; fully Native AOT and trimming compatible.
When to Use What
| Need | Use |
|---|
| A layout container | new Node() + node.Style.FlexDirection = ... |
| Fixed size | Style.SetDimension(Dimension.Width, StyleSizeLength.Points(100)) |
| Percentage of parent | StyleSizeLength.Percent(50) |
| Share leftover space | Style.FlexGrow = 1 |
| Spacing between items | Style.SetGap(Gutter.All, StyleLength.Points(10)) |
| Margins / padding / borders | Style.SetMargin/SetPadding/SetBorder(Edge.All, StyleLength.Points(8)) |
| Pin to a corner/edge | Style.PositionType = PositionType.Absolute + Style.SetPosition(Edge.Right, ...) |
| Text or other content-sized leaf | node.SetMeasureFunc(...) |
| Compute the layout | root.CalculateLayout() (or CalculateLayout.Calculate(root, availW, availH, dir)) |
| Read results | node.Layout.GetPosition(PhysicalEdge.Left) / GetDimension(Dimension.Width) |
| Web-CSS defaults instead of Yoga defaults | new Node(new Config { UseWebDefaults = true }) |
Core Model
Five steps, always the same shape:
using TimeWarp.Flexbox;
Node root = new();
root.Style.FlexDirection = FlexDirection.Row;
root.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(300));
root.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(200));
Node left = new();
left.Style.FlexGrow = 1;
root.InsertChild(left, 0);
Node right = new();
right.Style.FlexGrow = 2;
root.InsertChild(right, 1);
CalculateLayout.Calculate(root, float.NaN, float.NaN, Direction.LTR);
Console.WriteLine($"left: x={left.Layout.GetPosition(PhysicalEdge.Left)} width={left.Layout.GetDimension(Dimension.Width)}");
Console.WriteLine($"right: x={right.Layout.GetPosition(PhysicalEdge.Left)} width={right.Layout.GetDimension(Dimension.Width)}");
Positions are relative to the parent. For absolute page coordinates,
accumulate ancestors' positions (see Reading Results below).
CSS-to-C# Mapping
| CSS | TimeWarp.Flexbox |
|---|
width: 100px | Style.SetDimension(Dimension.Width, StyleSizeLength.Points(100)) |
height: 50% | Style.SetDimension(Dimension.Height, StyleSizeLength.Percent(50)) |
width: auto | StyleSizeLength.Auto (also .MaxContent, .FitContent, .Stretch) |
min-width / max-width | Style.SetMinDimension / Style.SetMaxDimension |
flex-direction: row | Style.FlexDirection = FlexDirection.Row (Column, RowReverse, ColumnReverse) |
flex-wrap: wrap | Style.FlexWrap = Wrap.Wrap (NoWrap, WrapReverse) |
flex-grow: 1 | Style.FlexGrow = 1 |
flex-shrink: 1 | Style.FlexShrink = 1 |
flex-basis: 100px | Style.FlexBasis = StyleSizeLength.Points(100) |
justify-content: space-between | Style.JustifyContent = Justify.SpaceBetween (FlexStart, Center, FlexEnd, SpaceAround, SpaceEvenly) |
align-items: center | Style.AlignItems = Align.Center (FlexStart, FlexEnd, Stretch, Baseline) |
align-self / align-content | Style.AlignSelf / Style.AlignContent (same Align enum) |
gap: 10px | Style.SetGap(Gutter.All, StyleLength.Points(10)) (Gutter.Row, Gutter.Column) |
margin: 8px | Style.SetMargin(Edge.All, StyleLength.Points(8)) (per-edge: Edge.Left/Top/Right/Bottom/Start/End/Horizontal/Vertical) |
margin-left: auto | Style.SetMargin(Edge.Left, StyleLength.Auto) |
padding / border-width | Style.SetPadding / Style.SetBorder (same Edge pattern) |
position: absolute; right: 20px | Style.PositionType = PositionType.Absolute; Style.SetPosition(Edge.Right, StyleLength.Points(20)) |
display: none | Style.Display = Display.None |
overflow: hidden | Style.Overflow = Overflow.Hidden |
box-sizing | Style.BoxSizing = BoxSizing.BorderBox (default) or ContentBox |
aspect-ratio: 16/9 | Style.AspectRatio = 16f / 9f |
direction: rtl | pass Direction.RTL to Calculate (or Style.Direction per subtree) |
Two length types — do not mix them up:
StyleSizeLength for dimensions and flex-basis (supports Auto, MaxContent, FitContent, Stretch).
StyleLength for edges and gaps (margin, padding, border, position, gap; supports Auto for margins/positions).
Defaults Are Yoga's, Not Web CSS
This is the #1 source of surprises:
| Property | TimeWarp.Flexbox / Yoga default | Web CSS default |
|---|
flex-direction | Column | row |
flex-shrink | 0 | 1 |
align-content | flex-start | stretch |
position | relative | static |
box-sizing | border-box | content-box |
For web-style behavior, construct nodes with a web-defaults config:
Config config = new() { UseWebDefaults = true };
Node root = new(config);
Reading Results
float x = node.Layout.GetPosition(PhysicalEdge.Left);
float y = node.Layout.GetPosition(PhysicalEdge.Top);
float w = node.Layout.GetDimension(Dimension.Width);
float h = node.Layout.GetDimension(Dimension.Height);
Positions are parent-relative. Accumulate for absolute coordinates:
static (float X, float Y) AbsolutePosition(Node node)
{
float x = 0, y = 0;
for (Node? n = node; n is not null; n = n.Owner)
{
x += n.Layout.GetPosition(PhysicalEdge.Left);
y += n.Layout.GetPosition(PhysicalEdge.Top);
}
return (x, y);
}
Recipes
All outputs below are the actual values produced by the engine.
App shell: sidebar + header/main/footer
Node shell = new();
shell.Style.FlexDirection = FlexDirection.Row;
shell.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(420));
shell.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(200));
shell.Style.SetPadding(Edge.All, StyleLength.Points(8));
shell.Style.SetGap(Gutter.All, StyleLength.Points(8));
Node sidebar = new();
sidebar.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(100));
shell.InsertChild(sidebar, 0);
Node content = new();
content.Style.FlexGrow = 1;
content.Style.FlexDirection = FlexDirection.Column;
content.Style.SetGap(Gutter.All, StyleLength.Points(8));
shell.InsertChild(content, 1);
Node header = new();
header.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(48));
content.InsertChild(header, 0);
Node main = new();
main.Style.FlexGrow = 1;
content.InsertChild(main, 1);
Node footer = new();
footer.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(32));
content.InsertChild(footer, 2);
CalculateLayout.Calculate(shell, float.NaN, float.NaN, Direction.LTR);
Wrapping card grid with gap
Node grid = new();
grid.Style.FlexDirection = FlexDirection.Row;
grid.Style.FlexWrap = Wrap.Wrap;
grid.Style.SetGap(Gutter.All, StyleLength.Points(10));
grid.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(250));
grid.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(200));
for (int i = 0; i < 3; i++)
{
Node card = new();
card.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(120));
card.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(60));
grid.InsertChild(card, i);
}
CalculateLayout.Calculate(grid, float.NaN, float.NaN, Direction.LTR);
Corner overlay via absolute insets
Node overlay = new();
overlay.Style.PositionType = PositionType.Absolute;
overlay.Style.SetPosition(Edge.Right, StyleLength.Points(20));
overlay.Style.SetPosition(Edge.Bottom, StyleLength.Points(16));
overlay.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(120));
overlay.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(48));
root.InsertChild(overlay, root.Children.Count);
RTL
CalculateLayout.Calculate(root, float.NaN, float.NaN, Direction.RTL);
Content-measured leaf (text, images)
For leaves whose size depends on content, attach a measure function. It is
called with the available space and a MeasureMode per axis:
Node text = new();
text.SetMeasureFunc((node, width, widthMode, height, heightMode) =>
{
float measuredWidth = Math.Min(80, width);
return new YGSize(measuredWidth, 20);
});
container.InsertChild(text, 0);
Nodes with a measure function cannot have children (asserted).
Incremental re-layout
Style writes mark the tree dirty automatically; just call Calculate again.
Unchanged subtrees hit the internal measurement cache.
b.Style.FlexGrow = 3;
CalculateLayout.Calculate(root, float.NaN, float.NaN, Direction.LTR);
Pitfalls
- A child belongs to one parent.
InsertChild takes ownership; inserting a
node that already has an owner throws YogaAssertException ("Child already
has a owner, it must be removed first."). Call parent.RemoveChild(child)
first — removal resets the child's layout and clears its owner.
float.NaN means "undefined/unconstrained" for available space and any
optional float. Check with Comparison.IsUndefined(value), not == NaN.
StyleSizeLength vs StyleLength: dimensions/flex-basis take the former,
edges/gaps the latter. The compiler catches it, but pick the right factory.
- Positions are parent-relative — accumulate up the
Owner chain for
absolute coordinates.
- One
Config per tree: nodes constructed with different UseWebDefaults
values cannot be mixed after construction (asserted on SetConfig).
- The whole API is
float-based; use f literals or implicit int conversions.
Installation
dotnet add package TimeWarp.Flexbox
The package is on nuget.org and ships XML docs. Targets net10.0, zero
dependencies, IsAotCompatible.
Further Reference
samples/layout-demo/layout-demo.cs in the repo renders layouts as ASCII
boxes in the terminal, or (with --html) as a page comparing engine output
against the browser's native flexbox side by side.
test/timewarp-flexbox-tests/Generated/ contains Yoga's conformance suite
ported to C# — a searchable catalog of expected values for any flexbox
scenario.
- Defaults, enums, and the full style surface:
source/timewarp-flexbox/Style/Style.cs
and Enums/YGEnums.cs.