Introduction

Red otter is a self-contained WebGL flexbox layout engine.

Features

Why?

There are apps (probably not websites but apps) that require very minimal rendering overhead, maybe they need to be lightweight and don't care about having persistent component tree or local state management (or anyway they need a fine-grained, custom solution for this). I am not the first person to notice similar thing [1].

It's also very common need of game developers to have a capable UI library. For example Unity UI Toolkit is using Yoga as a layout engine, which should be a nice illustration why this library might be very useful for WebGL applications.

If you find yourself writing some WebGL and at any point think "I wish there was a simple way to render text and maybe some UI on top of it", then this library might be for you.

In terms of software philosophy, think about products such as Warp or Zed. They are extremely fast programs which implement their own GPU rendering.

What this is not

What is it then?

Think about it as a lightweight Unity UI Toolkit for WebGL. TypeScript-first Dear ImGui .

Those are bold claims and current state is far from it, but this actually gives a good picture of the roadmap.

Install

yarn add red-otter

To render text you will also need to generate the font atlas. See guide.

Quick start

For a quick start, try playing with CodeSandbox example:

Edit red-otter-example

Enabling JSX

Using JSX syntax is entirely optional and if you don't want to use it, you will actually save yourself a lot of potential trouble with tricking your IDE to understand it and possibly you can also avoid stepping into uncharted territories of making React and another JSX-based library live in one project.

If you want to see comparison of JSX and non-JSX syntax, see Why JSX section.

For editor to correctly highlight JSX, add to compilerOptions in tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    // ...
    "jsx": "react-jsx",
    "jsxImportSource": "red-otter/dist"
  }
}

You will likely need to configure your bundler to enable JSX (in case of React or Preact this is usually done automatically with some plugin).

Vite

Add following to your vite.config.ts:

vite.config.ts
import { defineConfig, Plugin } from "vite";

function jsx(): Plugin {
  return {
    name: "jsx",
    config() {
      return {
        esbuild: {
          jsx: "automatic",
          jsxImportSource: "red-otter",
        },
      };
    },
  };
}

export default defineConfig({
  // ...
  plugins: [jsx()],
});

Parcel

Make sure that your .parcelrc config supports static files via raw transformer:

.parcelrc
{
  "extends": "@parcel/config-default",
  "transformers": {
    "url:*": ["@parcel/transformer-raw"]
  }
}

And then use a special import (in this example runtime loading, but the same principle applies to pregenerated data):

import fontURL from "url:./public/inter.ttf";
// ...
const fontFace = new FontFace("Inter", `url("${fontURL}")`);
// ...
const file = await fetch(fontURL);

Examples

All code present below follows similar pattern. You need to create <canvas> element, load font (either with pregenerated data or in runtime, see guide), initiatilize Context and if you want to just draw shapes and polygons that's it. If you want to utilize layout engine (you probably do), then you need to add Layout and use one of available syntaxes to declare elements (see Why JSX for comparison).

import { Font, Context, Layout } from "red-otter";

const canvas = document.createElement("canvas");
const scale = window.devicePixelRatio;
canvas.width = 800 * scale;
canvas.height = 600 * scale;

const div = document.getElementById("app");
div.appendChild(canvas);

const font = new Font({
  spacingMetadataJsonURL: "/spacing.json",
  spacingBinaryURL: "/spacing.dat",
  UVBinaryURL: "/uv.dat",
  fontAtlasTextureURL: "/font-atlas.png",
});
await font.load();

const context = new Context(canvas, font);
context.clear();

const layout = new Layout(context);
layout.add(
  <view style={{ width: 100, height: 100, backgroundColor: "#fff" }}>
    <text style={{ fontFamily: font, fontSize: 20, color: "#000" }}>Hello</text>
  </view>
);

layout.render();
context.flush();

First example

Example layout made with red-otter. Everything is rendered using the library.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
const container: Style = {
  width: "100%",
  height: "100%",
  backgroundColor: zinc[900],
};

const menu: Style = {
  flexDirection: "row",
  gap: 24,
  paddingHorizontal: 12,
  paddingVertical: 8,
  backgroundColor: zinc[800],
  alignSelf: "stretch",
  borderBottomWidth: 1,
  borderColor: zinc[700],
};

const content: Style = {
  padding: 24,
  gap: 16,
  flex: 1,
};

const headerText: TextStyle = {
  color: "#fff",
  fontFamily: font,
  fontSize: 24,
};

const text: TextStyle = {
  color: zinc[400],
  fontFamily: font,
  fontSize: 14,
};

const overlay: Style = {
  position: "absolute",
  top: 0,
  left: 0,
  width: "100%",
  height: "100%",
  backgroundColor: "rgba(0, 0, 0, 0.4)",
  justifyContent: "center",
  alignItems: "center",
};

const dialog: Style = {
  backgroundColor: zinc[800],
  borderRadius: 10,
  borderWidth: 1,
  borderColor: zinc[700],
};

const headerLine: Style = {
  padding: 20,
  flexDirection: "row",
  justifyContent: "space-between",
  alignSelf: "stretch",
  alignItems: "center",
};

const closeButton: Style = {
  backgroundColor: zinc[700],
  width: 24,
  height: 24,
  justifyContent: "center",
  alignItems: "center",
  borderRadius: 12,
  borderWidth: 1,
  borderColor: zinc[600],
};

const separator: Style = {
  backgroundColor: zinc[700],
  height: 1,
  alignSelf: "stretch",
};

const paragraphs: Style = {
  gap: 16,
  padding: 24,
};

const row: Style = {
  flexDirection: "row",
  gap: 12,
  padding: 20,
  alignSelf: "flex-end",
};

const buttonSecondary: Style = {
  backgroundColor: zinc[600],
  paddingHorizontal: 16,
  justifyContent: "center",
  alignItems: "center",
  borderRadius: 8,
  height: 32,
  borderWidth: 1,
  borderColor: zinc[500],
};

const buttonText: TextStyle = {
  color: zinc[100],
  fontFamily: font,
  fontSize: 15,
};

const buttonPrimary: Style = {
  paddingHorizontal: 16,
  justifyContent: "center",
  alignItems: "center",
  backgroundColor: "#2563eb",
  borderRadius: 8,
  height: 32,
  borderWidth: 1,
  borderColor: "#3b82f6",
};

const buttonPrimaryText: TextStyle = {
  color: "#fff",
  fontFamily: font,
  fontSize: 15,
};

const checkboxLine: Style = {
  flexDirection: "row",
  gap: 12,
  alignItems: "center",
};

const checkbox: Style = {
  height: 20,
  width: 20,
  justifyContent: "center",
  alignItems: "center",
  borderRadius: 3,
  backgroundColor: "#2563eb",
  borderWidth: 1,
  borderColor: "#3b82f6",
};

const footer: Style = {
  flexDirection: "row",
  gap: 24,
  paddingHorizontal: 12,
  paddingVertical: 8,
  backgroundColor: zinc[800],
  alignSelf: "stretch",
  justifyContent: "space-around",
  borderTopWidth: 1,
  borderColor: zinc[700],
};

layout.add(
  <view style={container}>
    <view style={menu}>
      <text style={text}>File</text>
      <text style={text}>Edit</text>
      <text style={text}>Run</text>
      <text style={text}>Terminal</text>
      <text style={text}>Window</text>
      <text style={text}>Help↗</text>
    </view>
    <view style={content}>
      <text style={headerText}>Welcome to Red Otter!</text>
      <text style={text}>
        I am a self-contained WebGL flexbox layout engine. I can render
        rectangles, letters and polygons.
      </text>
      <text style={text}>
        I can do flexbox layout, position: absolute, z-index. Everything you
        would expect from a layout engine!
      </text>
      <text style={text}>
        I am also quite not bad at styling: as you can see I can handle rounded
        corners and borders.
      </text>
    </view>
    <view style={footer}>
      <text style={text}>main*</text>
      <text style={text}>Ln: 1257, Col 38</text>
      <text style={text}>UTF-8</text>
      <text style={text}>LF</text>
      <text style={text}>TypeScript</text>
    </view>
    <view style={overlay}>
      <view style={dialog}>
        <view style={headerLine}>
          <text style={headerText}>This is a modal</text>
          <view style={closeButton}>
            <shape
              type="polygon"
              color={"#fff"}
              points={[
                [3, 4],
                [4, 3],
                [8, 7],
                [12, 3],
                [13, 4],
                [9, 8],
                [13, 12],
                [12, 13],
                [8, 9],
                [4, 13],
                [3, 12],
                [7, 8],
              ]}
            />
          </view>
        </view>
        <view style={separator} />
        <view style={paragraphs}>
          <text style={text}>
            All elements here take part in automatic layout.
          </text>
          <text style={text}>No manually typed sizes or positions.</text>
          <text style={text}>Everything is rendered by the library.</text>
          <view style={checkboxLine}>
            <view style={checkbox}>
              <shape
                type="polygon"
                color="#fff"
                points={[
                  [3.5, 7],
                  [6.5, 10],
                  [12.5, 4],
                  [14, 5.5],
                  [6.5, 13],
                  [2, 8.5],
                ]}
              />
            </view>
            <text style={text}>Even the tick icon.</text>
          </view>
        </view>
        <view style={separator} />
        <view style={row}>
          <view style={buttonSecondary}>
            <text style={buttonText}>Cancel</text>
          </view>
          <view style={buttonPrimary}>
            <text style={buttonPrimaryText}>Confirm</text>
          </view>
        </view>
      </view>
    </view>
  </view>
);

Text

Simple showcase of text rendering. Note that text is using cap height for measuring text height, so some diacritic marks, or letters like 'g' will be cut off and this is intentional behavior. The biggest text is 33% larger than the source font atlas texture, showing upscaling capabilities.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
const text = "Turtle żółw черепаха želva sköldpadda süß Æøñ@ø№→⏎⁂➆§∑¾¤ - - – —";

// NOTE: this showcases calling layout.text(), which is a direct API. You can
// alternatively do:
// ```
// <text style={{ fontFamily: font, fontSize: 64, color: "#fff" }}>{text}</text>
// ```
layout.text(text, font, 64, "#fff", 0, 0);
layout.text(text, font, 32, "#fff", 0, 60);
layout.text(text, font, 16, "#fff", 0, 100);
layout.text(text, font, 12, "#fff", 0, 130);
layout.text(text, font, 10, "#fff", 0, 150);

Justify content

Place content along the main axis.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const container: Style = {
  width: "100%",
  height: "100%",
  gap: 20,
  padding: 20,
  alignItems: "stretch",
};

const row: Style = {
  flexDirection: "row",
  gap: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

const red = {
  width: 80,
  height: 40,
  backgroundColor: "#eb584e",
};

const orange = {
  width: 80,
  height: 40,
  backgroundColor: "#ef8950",
};

const yellow = {
  width: 80,
  height: 40,
  backgroundColor: "#efaf50",
};

layout.add(
  <view style={container}>
    <text style={text}>justifyContent: "start"</text>
    <view style={[row, { justifyContent: "flex-start" }]}>
      <view style={red} />
      <view style={orange} />
      <view style={yellow} />
    </view>
    <text style={text}>justifyContent: "center"</text>
    <view style={[row, { justifyContent: "center" }]}>
      <view style={red} />
      <view style={orange} />
      <view style={yellow} />
    </view>
    <text style={text}>justifyContent: "end"</text>
    <view style={[row, { justifyContent: "flex-end" }]}>
      <view style={red} />
      <view style={orange} />
      <view style={yellow} />
    </view>
    <text style={text}>justifyContent: "space-between"</text>
    <view style={[row, { justifyContent: "space-between" }]}>
      <view style={red} />
      <view style={orange} />
      <view style={yellow} />
    </view>
    <text style={text}>justifyContent: "space-around"</text>
    <view style={[row, { justifyContent: "space-around" }]}>
      <view style={red} />
      <view style={orange} />
      <view style={yellow} />
    </view>
    <text style={text}>justifyContent: "space-evenly"</text>
    <view style={[row, { justifyContent: "space-evenly" }]}>
      <view style={[red, { flex: 1 }]} />
      <view style={orange} />
      <view style={yellow} />
    </view>
  </view>
);

Align items

Place content along the cross axis. Stretch makes item fill cross axis of parent if width or height is not specified.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const container: Style = {
  width: "100%",
  height: "100%",
  alignItems: "stretch",
  gap: 20,
  padding: 20,
};

const row: Style = {
  flexDirection: "row",
  gap: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <text style={text}>alignItems: "start"</text>
    <view style={[row, { alignItems: "flex-start" }]}>
      <view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
      <view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
      <view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
    </view>
    <text style={text}>alignItems: "center"</text>
    <view style={[row, { alignItems: "center" }]}>
      <view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
      <view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
      <view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
    </view>
    <text style={text}>alignItems: "end"</text>
    <view style={[row, { alignItems: "flex-end" }]}>
      <view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
      <view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
      <view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
    </view>
    <text style={text}>alignItems: "stretch"</text>
    <view style={[row, { alignItems: "stretch" }]}>
      <view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
      <view style={{ flex: 1, padding: 20, backgroundColor: "#ef8950" }}>
        <text style={{ fontFamily: font }}>height: undefined</text>
      </view>
      <view
        style={{
          flex: 1,
          padding: 20,
          backgroundColor: "#efaf50",
          height: 80,
        }}
      >
        <text style={{ fontFamily: font }}>height: 80</text>
      </view>
    </view>
  </view>
);

Align self

Override parent's alignItems property.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const container: Style = {
  width: "100%",
  height: "100%",
  alignItems: "stretch",
  gap: 20,
  padding: 20,
};

const row: Style = {
  flexDirection: "row",
  gap: 20,
  height: 120,
  alignItems: "flex-start",
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <view style={row}>
      <view
        style={{
          flex: 1,
          padding: 20,
          backgroundColor: "#eb584e",
          height: 60,
        }}
      />
      <view
        style={{
          flex: 1,
          paddingLeft: 20,
          backgroundColor: "#ef8950",
          height: 60,
          justifyContent: "center",
          alignSelf: "stretch",
        }}
      >
        <text style={text}>alignSelf: "stretch"</text>
      </view>
      <view
        style={{
          flex: 1,
          paddingLeft: 20,
          backgroundColor: "#efaf50",
          height: 60,
          justifyContent: "center",
          alignSelf: "flex-end",
        }}
      >
        <text style={text}>alignSelf: "end"</text>
      </view>
    </view>
  </view>
);

Flex

Example of flex property. Note that flex takes precedence over width.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const container: Style = {
  width: "100%",
  height: "100%",
  alignItems: "stretch",
  gap: 20,
  padding: 20,
};

const row: Style = {
  flexDirection: "row",
  gap: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <text style={text}>flex: 1</text>
    <view style={row}>
      <view style={{ height: 100, flex: 1, backgroundColor: "#eb584e" }} />
      <view style={{ height: 100, flex: 1, backgroundColor: "#ef8950" }} />
      <view style={{ height: 100, flex: 1, backgroundColor: "#efaf50" }} />
    </view>
    <text style={text}>flex: 1, 2, 3</text>
    <view style={row}>
      <view style={{ height: 100, flex: 1, backgroundColor: "#eb584e" }} />
      <view style={{ height: 100, flex: 2, backgroundColor: "#ef8950" }} />
      <view style={{ height: 100, flex: 3, backgroundColor: "#efaf50" }} />
    </view>
    <text style={text}>width: 200, flex: 1, 2</text>
    <view style={row}>
      <view style={{ height: 100, width: 200, backgroundColor: "#eb584e" }} />
      <view style={{ height: 100, flex: 2, backgroundColor: "#ef8950" }} />
      <view style={{ height: 100, flex: 1, backgroundColor: "#efaf50" }} />
    </view>
  </view>
);

Percentage size

Size can be specified using percent units. It is relative to the parent size and does not take into account padding or gaps.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
  gap: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <view
      style={{
        padding: 5,
        width: 120,
        height: "50%",
        backgroundColor: "#eb584e",
      }}
    >
      <text style={text}>height: "50%"</text>
    </view>
    <view
      style={{
        padding: 5,
        width: 120,
        height: "30%",
        backgroundColor: "#ef8950",
      }}
    >
      <text style={text}>height: "30%"</text>
    </view>
    <view
      style={{
        padding: 5,
        width: 120,
        height: "5%",
        backgroundColor: "#efaf50",
      }}
    >
      <text style={text}>height: "5%"</text>
    </view>
  </view>
);

Position relative

By default elements take part in automatic layout calculation.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <view style={{ backgroundColor: zinc[900] }}>
      <view style={{ padding: 20, backgroundColor: zinc[800] }}>
        <text style={text}>Hello, welcome to my layout</text>
      </view>
      <view style={{ padding: 20, backgroundColor: zinc[700] }}>
        <text style={text}>Components have automatic layout</text>
      </view>
      <view
        style={{
          flexDirection: "row",
          padding: 20,
          backgroundColor: zinc[600],
        }}
      >
        <view style={{ padding: 20, backgroundColor: "#eb584e" }}>
          <text style={text}>One</text>
        </view>
        <view style={{ padding: 20, backgroundColor: "#ef8950" }}>
          <text style={text}>Two</text>
        </view>
        <view style={{ padding: 20, backgroundColor: "#efaf50" }}>
          <text style={text}>Three</text>
        </view>
      </view>
    </view>
    <view
      style={{
        padding: 20,
        marginTop: 20,
        width: 200,
        aspectRatio: 16 / 9,
        backgroundColor: zinc[800],
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <text style={text}>aspectRatio: 16 / 9</text>
    </view>
  </view>
);

Position absolute and z index

Position absolute makes element skip taking part in the layout calculation and positions it relatively to the parent. zIndex is used to declare that element should skip the order and be higher or lower than siblings. Here it is not used, showing what happens if we do not interfere with the order of elements in the layout.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const wrapper: Style = {
  flexDirection: "row",
  gap: 40,
  padding: 40,
  backgroundColor: zinc[800],
};

const text: TextStyle = {
  fontFamily: font,
  color: "#fff",
};

const box: Style = {
  width: 120,
  height: 120,
  padding: 20,
};

const absoluteBox: Style = {
  backgroundColor: "#ef8950",
  position: "absolute",
  // zIndex: 1,
  right: 0,
  bottom: 0,
};

layout.add(
  <view style={container}>
    <view style={wrapper}>
      <view style={[box, { backgroundColor: "#eb584e" }]}>
        <text style={text}>1</text>
      </view>
      <view style={[box, absoluteBox]}>
        <text style={text}>2</text>
      </view>
      <view style={[box, { backgroundColor: "#efaf50" }]}>
        <text style={text}>3</text>
      </view>
    </view>
  </view>
);

Left, right, top, bottom

If element has position: relative, it will take part in the layout together with siblings and then will be offset by the coordinates.

If element has position: absolute, it will not take part in the layout with siblings and will be positioned relative to the parent's edges according to those coordinates.

If two opposing coordinates are specified (e.g. left and right) and element has no size specified in that dimension (width: undefined), the element will be stretched to fill the space between them.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
  gap: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

const wrapper: Style = {
  flexDirection: "row",
  gap: 20,
  padding: 20,
  backgroundColor: zinc[800],
};

const box: Style = {
  width: 120,
  height: 120,
  padding: 20,
  gap: 10,
};

layout.add(
  <view style={container}>
    <view style={wrapper}>
      <view style={[box, { backgroundColor: "#eb584e" }]}></view>
      <view style={[box, { backgroundColor: "#ef8950", top: -20, left: -20 }]}>
        <text style={text}>top: -20</text>
        <text style={text}>left: -20</text>
      </view>
      <view style={[box, { backgroundColor: "#efaf50" }]}></view>
    </view>
    <view
      style={{
        backgroundColor: zinc[700],
        left: 200,
        right: 200,
        height: 100,
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <text style={text}>left: 200, right: 200, height: 100</text>
    </view>
  </view>
);

Mapping over array

As in regular JSX, it's possible to map over an array of elements.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const headerCell: Style = {
  paddingVertical: 12,
  paddingHorizontal: 16,
  backgroundColor: zinc[700],
};

const headerText: TextStyle = {
  fontFamily: font,
  fontSize: 14,
  color: "#fff",
};

const cellText: TextStyle = {
  fontFamily: font,
  color: zinc[400],
  fontSize: 14,
};

const data = [
  { id: 1, name: "One", createdAt: "2021-01-01" },
  { id: 2, name: "Two", createdAt: "2021-01-02" },
  { id: 3, name: "Three", createdAt: "2021-01-03" },
  { id: 4, name: "Four", createdAt: "2021-01-04" },
  { id: 5, name: "Five", createdAt: "2021-01-05" },
  { id: 6, name: "Six", createdAt: "2021-01-06" },
  { id: 7, name: "Seven", createdAt: "2021-01-07" },
  { id: 8, name: "Eight", createdAt: "2021-01-08" },
  { id: 9, name: "Nine", createdAt: "2021-01-09" },
  { id: 10, name: "Ten", createdAt: "2021-01-10" },
  { id: 11, name: "Eleven", createdAt: "2021-01-11" },
  { id: 12, name: "Twelve", createdAt: "2021-01-12" },
  { id: 13, name: "Thirteen", createdAt: "2021-01-13" },
  { id: 14, name: "Fourteen", createdAt: "2021-01-14" },
  { id: 15, name: "Fifteen", createdAt: "2021-01-15" },
];

const columns: { key: keyof (typeof data)[0]; title: string }[] = [
  { key: "id", title: "ID" },
  { key: "name", title: "Name" },
  { key: "createdAt", title: "Created" },
];

layout.add(
  <view style={container}>
    <view style={{ flexDirection: "row" }}>
      {columns.map(({ key, title }) => {
        return (
          <view style={{ alignItems: "stretch" }}>
            <view style={headerCell}>
              <text style={headerText}>{title}</text>
            </view>
            {data.map((item, rowIndex) => {
              let content = String(item[key]);
              if (key === "createdAt") {
                content = new Date(content).toLocaleDateString();
              }

              return (
                <view
                  style={{
                    paddingVertical: 12,
                    paddingHorizontal: 16,
                    backgroundColor: rowIndex % 2 === 0 ? zinc[800] : zinc[900],
                  }}
                >
                  <text style={cellText}>{content}</text>
                </view>
              );
            })}
          </view>
        );
      })}
    </view>
  </view>
);

Polygons

Example of drawing arbitrary shapes – here a map from OpenStreetMap data with building numbers overlayed on top of their shapes.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
const RADIUS = 6378137.0;

function degreesToMeters(lat: number, lng: number): { x: number; y: number } {
  return {
    x: (RADIUS * lng * Math.PI) / 180.0,
    y: RADIUS * Math.atanh(Math.sin((lat * Math.PI) / 180.0)),
  };
}

let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;

const shapes = map.features
  .filter((f) => {
    if (f.geometry.type !== "Polygon" && f.geometry.type !== "LineString") {
      return false;
    }

    return true;
  })
  .map((f) => {
    const isRoad = f.properties.highway !== undefined;

    const coordinates: [number, number][] = isRoad
      ? (f.geometry.coordinates as [number, number][])
      : (f.geometry.coordinates[0] as [number, number][]);

    return {
      name: isRoad ? f.properties.name : f.properties["addr:housenumber"],
      type: isRoad ? "road" : "building",
      points: coordinates.map(([lon, lat]) => {
        const { x, y } = degreesToMeters(lat, lon);
        minX = Math.min(minX, x);
        minY = Math.min(minY, y);
        maxX = Math.max(maxX, x);
        maxY = Math.max(maxY, y);
        return { x, y };
      }),
    };
  })
  .map((polygon) => {
    const points = polygon.points.map(({ x, y }) => {
      return [
        ((x - minX) / (maxX - minX)) * 800,
        (1 - (y - minY) / (maxY - minY)) * 600,
      ] as [number, number];
    });

    return {
      name: polygon.name,
      type: polygon.type,
      center: {
        x: points.reduce((acc, [x]) => acc + x, 0) / points.length,
        y: points.reduce((acc, [, y]) => acc + y, 0) / points.length,
      },
      points,
    };
  });

const container: Style = {
  width: "100%",
  height: "100%",
  backgroundColor: zinc[800],
};

const absolute: Style = {
  position: "absolute",
  width: "100%",
  height: "100%",
};

const text: TextStyle = {
  fontFamily: font,
  color: "#fff",
  fontSize: 14,
};

layout.add(
  <view style={container}>
    {shapes.map((shape) => {
      if (shape.type === "building") {
        return (
          <view style={absolute}>
            <view
              style={{
                position: "absolute",
                left: Math.min(...shape.points.map((p) => p[0])),
                top: Math.min(...shape.points.map((p) => p[1])),
              }}
            >
              <shape
                type="polygon"
                points={shape.points.reverse()}
                color={zinc[600]}
              />
            </view>
            <view
              style={{
                position: "absolute",
                left: shape.center.x,
                top: shape.center.y,
                zIndex: 1,
              }}
            >
              {shape.name ? (
                <view
                  style={{
                    backgroundColor: "rgba(0, 0, 0, 0.5)",
                    width: 32,
                    height: 32,
                    borderRadius: 16,
                    justifyContent: "center",
                    alignItems: "center",
                  }}
                >
                  <text style={text}>{shape.name}</text>
                </view>
              ) : null}
            </view>
          </view>
        );
      }

      if (shape.type === "road") {
        return (
          <view style={absolute}>
            <view
              style={{
                position: "absolute",
                left: Math.min(...shape.points.map((p) => p[0])),
                top: Math.min(...shape.points.map((p) => p[1])),
              }}
            >
              <shape
                type="line"
                points={shape.points.reverse()}
                thickness={4}
                color={zinc[700]}
              />
            </view>
          </view>
        );
      }
    })}
  </view>
);

Border radius and border width

Border radius can be specified for each corner individually, or for all corners at once. Border width similarly can be specified for each edge or for all at once. You also need to specify borderColor to see the border.

Show code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const text: TextStyle = {
  fontFamily: font,
  fontSize: 14,
  color: "#fff",
};

layout.add(
  <view
    style={{
      padding: 20,
      gap: 20,
    }}
  >
    <view
      style={{
        backgroundColor: zinc[700],
        paddingHorizontal: 16,
        paddingVertical: 12,
        borderBottomWidth: 2,
        borderColor: "--yellow",
        borderRadiusTop: 8,
      }}
    >
      <text style={text}>This is a view with a border</text>
    </view>
    <view
      style={{
        backgroundColor: zinc[600],
        width: 240,
        height: 120,
        padding: 20,
        borderColor: zinc[400],
        borderBottomWidth: 2,
        borderLeftWidth: 4,
        borderRightWidth: 4,
        borderTopWidth: 8,
        borderRadiusTopLeft: 2,
        borderRadiusTopRight: 4,
        borderRadiusBottomLeft: 8,
        borderRadiusBottomRight: 16,
        gap: 8,
      }}
    >
      <text style={text}>This is a view where each</text>
      <text style={text}>corner and edge have different</text>
      <text style={text}>border and radius</text>
    </view>
  </view>
);

Generating font atlas

The goal is to allow for both runtime and build step generation.

Worth knowing:

Runtime

The idea is to render font atlas on the fly using browser's canvas API. It will provide some bandwidth savings (especially if you use a system font and load a lot of characters – potentially megabytes of download saved) but might take a bit longer and is more fragile as it requires browser support for JS font-face manipulation and properly functioning <canvas>.

import { TTF, FontAtlas, Font } from "red-otter";

async function loadFont() {
  // Add font to the document so we will use browser to rasterize the font.
  const fontFace = new FontFace("Inter", 'url("/inter.ttf")');
  await fontFace.load();
  document.fonts.add(fontFace);

  // Download font file for parsing.
  const file = await fetch("/inter.ttf");
  const buffer = await file.arrayBuffer();

  const ttf = new TTF(buffer);
  if (!ttf.ok) {
    throw new Error("Failed to parse font file.");
  }

  // Render font atlas.
  const atlas = new FontAtlas(ttf);
  const { canvas, spacing } = atlas.render();

  const image = new Image();
  image.src = canvas.toDataURL();

  return new Font(spacing, image);
}

See full app on GitHub.

On CI

Another option is to generate font atlas on CI as a build step of your application. As a side effect, this will make font look precisely the same on all devices (as font rasterization happens on the server).

Example setup for CI rendering:

.
├── ci
│   ├── run.ts        # Vercel CI runs this script as part of `yarn build`.
│   │                 # The script runs puppeteer and Vite, loads `ci` page
│   │                 # and downloads font files to `/public`.
│   ├── index.html    # Vite will use this file to show the font atlas page.
│   ├── main.ts       # Renders font atlas.
│   └── public
│       └── inter.ttf
├── index.html        # Final page. Both HTML files are identical.
├── main.ts           # Renders on the final page, loading fonts from `/public`.

CI runtime script can look like this:

ci/main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { FontAtlas, TTF } from "red-otter";

async function run(): Promise<void> {
  const start = performance.now();

  const fontFace = new FontFace("Inter", 'url("/inter.ttf")');
  await fontFace.load();
  document.fonts.add(fontFace);

  const font = await fetch("/inter.ttf");
  const buffer = await font.arrayBuffer();

  const ttf = new TTF(buffer);
  if (!ttf.ok) {
    throw new Error("Failed to parse font file.");
  }

  const atlas = new FontAtlas(ttf);
  const { canvas, spacing } = atlas.render();

  const div = document.getElementById("app");
  if (!div) {
    throw new Error("Could not find #app element.");
  }

  const span = document.createElement("span");
  span.setAttribute("style", "display: none");
  span.innerText = JSON.stringify(spacing);

  div.appendChild(canvas);
  div.appendChild(span);

  console.debug(
    `Font atlas generated in ${(performance.now() - start).toFixed(2)}ms.`
  );
}

run();

index.html is very simple:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Font atlas</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/main.ts"></script>
  </body>
</html>

Finally, there's a script that runs Vite server and Puppeteer to render the page and save the results to the filesystem.

ci/run.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";

import { createServer } from "vite";
import chromium from "@sparticuz/chromium";
import { launch, PuppeteerLaunchOptions } from "puppeteer-core";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const PNG_FILE = `${__dirname}/../public/font-atlas.png`;
const JSON_FILE = `${__dirname}/../public/spacing.json`;
const BINARY_FILE = `${__dirname}/../public/spacing.dat`;
const UV_FILE = `${__dirname}/../public/uv.dat`;

const BUNDLER_PORT = 3456;

function printSize(size: number): string {
  if (size < 1024) {
    return `${size} B`;
  } else if (size < 1024 * 1024) {
    return `${(size / 1024).toFixed(2)} kB`;
  } else {
    return `${(size / 1024 / 1024).toFixed(2)} MB`;
  }
}

async function getPuppeteerOptions(): Promise<Partial<PuppeteerLaunchOptions>> {
  if (process.env.CI === "1") {
    return {
      executablePath: await chromium.executablePath(),
      args: [...chromium.args, "--no-sandbox"],
      headless: chromium.headless,
    };
  } else if (process.platform === "darwin") {
    return {
      executablePath:
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
      args: ["--no-sandbox"],
      headless: true,
    };
  } else {
    throw new Error("Unsupported OS.");
  }
}

function saveFile(
  filePath: string,
  data: string | Int16Array | Float32Array,
  encoding: "utf-8" | "binary"
): void {
  fs.writeFileSync(filePath, data, { encoding });
  const fileOnDisk = fs.statSync(filePath);
  console.debug(`Saved ${filePath} ${printSize(fileOnDisk.size)}`);
}

async function run(): Promise<void> {
  const server = await createServer({
    root: path.resolve(__dirname, "."),
    server: { port: BUNDLER_PORT },
  });
  await server.listen();
  console.debug(`Vite dev server started on port ${BUNDLER_PORT}.`);

  const browser = await launch(await getPuppeteerOptions());
  console.debug("Chromium launched.");

  const page = await browser.newPage();
  page
    .on("console", (message) => {
      const type = message.type();
      console.debug(`${type}: ${message.text()}`);
    })
    .on("pageerror", ({ message }) => console.debug(message))
    .on("response", (response) => {
      const status = response.status().toString();
      console.debug(`${`HTTP ${status}`} ${response.url()}`);
    })
    .on("requestfailed", (request) => {
      console.debug(`${request.failure().errorText} ${request.url()}`);
    });

  // Because of CORS it has to be served by a server.
  await page.goto(`http://localhost:${BUNDLER_PORT}`);

  await page.waitForSelector("canvas");
  const canvas = await page.$("canvas");

  if (!canvas) {
    throw new Error("Canvas not found.");
  }

  console.debug("Page loaded.");

  await canvas.screenshot({ path: PNG_FILE, omitBackground: true });

  console.debug(`Saved ${PNG_FILE}.`);

  const spacing = await page.$eval("span", (element) => {
    return JSON.parse(element.innerHTML);
  });

  const { glyphs, uvs, ...metadata } = spacing;

  const spacingJson = JSON.stringify(metadata, null, 2);
  saveFile(JSON_FILE, spacingJson, "utf-8");

  const glyphBinary = new Int16Array(glyphs);
  saveFile(BINARY_FILE, glyphBinary, "binary");

  const uvBinary = new Float32Array(uvs);
  saveFile(UV_FILE, uvBinary, "binary");

  await browser.close();
  await server.close();
}

run();

Call it as part of yarn build:

{
  "scripts": {
    "build": "yarn font-atlas && vite build",
    "font-atlas": "yarn run ts-node --esm --swc --experimentalSpecifierResolution=node ci/run.ts"
  }
}

Example of logs from running in the Vercel CI:

$ /vercel/path0/node_modules/.bin/ts-node -O '{"module":"nodenext"}' ci/run.ts
Vite dev server started on port 3456.
Chromium launched.
HTTP 200 http://localhost:3456/
HTTP 200 http://localhost:3456/main.ts
HTTP 200 http://localhost:3456/@vite/client
HTTP 200 http://localhost:3456/@fs/vercel/path0/node_modules/vite/dist/client/env.mjs
debug: [vite] connecting...
debug: [vite] connected.
HTTP 200 http://localhost:3456/@fs/vercel/path0/node_modules/.vite/deps/red-otter.js?v=c4488a07
HTTP 200 http://localhost:3456/inter.ttf
HTTP 304 http://localhost:3456/inter.ttf
log: Font atlas generated in 1155.50ms.
Page loaded.
Saved /vercel/path0/ci/../public/font-atlas.png.
Saved /vercel/path0/ci/../public/spacing.json.
Saved /vercel/path0/ci/../public/spacing.dat.
Saved /vercel/path0/ci/../public/uv.dat.

Full example on GitHub.

Why JSX

Compare code written with the direct API, probably a simplest form of immediate mode GUI:

direct-api.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

// Those code blocks are useless but increase readability.
layout.view(container);
{
  layout.view({ backgroundColor: zinc[900] });
  {
    layout.view({ padding: 20, backgroundColor: zinc[800] });
    layout.text("Hello, welcome to my layout", text);
    layout.end();

    layout.view({ padding: 20, backgroundColor: zinc[700] });
    layout.text("Components have automatic layout", text);
    layout.end();

    layout.view({
      flexDirection: "row",
      padding: 20,
      backgroundColor: zinc[600],
    });
    {
      layout.view({ padding: 20, backgroundColor: "#eb584e" });
      layout.text("One", text);
      layout.end();

      layout.view({ padding: 20, backgroundColor: "#eb584e" });
      layout.text("Two", text);
      layout.end();

      layout.view({ padding: 20, backgroundColor: "#eb584e" });
      layout.text("Three", text);
      layout.end();
    }
    layout.end();
  }
  layout.end();
}
layout.end();

Then with calling HyperScript-style function manually. Basically using JSX in plain JavaScript:

hyperscript.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  h(
    "view",
    { style: container },
    h(
      "view",
      { style: { backgroundColor: zinc[900] } },
      h(
        "view",
        { style: { padding: 20, backgroundColor: zinc[800] } },
        h("text", { style: text }, "Hello, welcome to my layout")
      ),
      h(
        "view",
        { style: { padding: 20, backgroundColor: zinc[700] } },
        h("text", { style: text }, "Components have automatic layout")
      ),
      h(
        "view",
        {
          style: {
            flexDirection: "row",
            padding: 20,
            backgroundColor: zinc[600],
          },
        },
        h(
          "view",
          { style: { padding: 20, backgroundColor: "#eb584e" } },
          h("text", { style: text }, "One")
        ),
        h(
          "view",
          { style: { padding: 20, backgroundColor: "#eb584e" } },
          h("text", { style: text }, "Two")
        ),
        h(
          "view",
          { style: { padding: 20, backgroundColor: "#eb584e" } },
          h("text", { style: text }, "Three")
        )
      )
    )
  )
);

And finally JSX, which is easily supported by probably all bundlers and IDEs:

jsx.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const container: Style = {
  width: "100%",
  height: "100%",
  padding: 20,
};

const text: TextStyle = {
  fontFamily: font,
};

layout.add(
  <view style={container}>
    <view style={{ backgroundColor: zinc[900] }}>
      <view style={{ padding: 20, backgroundColor: zinc[800] }}>
        <text style={text}>Hello, welcome to my layout</text>
      </view>
      <view style={{ padding: 20, backgroundColor: zinc[700] }}>
        <text style={text}>Components have automatic layout</text>
      </view>
      <view
        style={{
          flexDirection: "row",
          padding: 20,
          backgroundColor: zinc[600],
        }}
      >
        <view style={{ padding: 20, backgroundColor: "#eb584e" }}>
          <text style={text}>One</text>
        </view>
        <view style={{ padding: 20, backgroundColor: "#ef8950" }}>
          <text style={text}>Two</text>
        </view>
        <view style={{ padding: 20, backgroundColor: "#efaf50" }}>
          <text style={text}>Three</text>
        </view>
      </view>
    </view>
  </view>
);

JSX has couple advantages here. It is the shortest. Arguably this syntax makes it more readable (which is for me not always the case with XML). And something that is harder to reason about just looking at the code – it is much easier for me to refactor than function calls above. Very clear boundaries of components (opening and closing tags) make it easier to move blocks around consciously.

API reference

Style

Styling available for views. 44 properties in total.

Property
Type
Description
width
string | number

Undefined means that view should hug its content.

String can only be a percentage value (e.g. '50%'). It is defined relative to the parent view.

Numerical value is defined in pixels.

height
string | number

Undefined means that view should hug its content.

String can only be a percentage value (e.g. '50%'). It is defined relative to the parent view.

Percentage value does not take into account paddings or gaps.

Numerical value is defined in pixels.

flexDirection
"row" | "column"

Direction of children layout.

justifyContent
"flex-start" | "center" | "flex-end" | "space-between" | "space-around" | "space-evenly"

How children are aligned along the main axis.

alignItems
"flex-start" | "center" | "flex-end" | "stretch"

How children are aligned along the cross axis.

alignSelf
"flex-start" | "center" | "flex-end" | "stretch"

Override parent's alignItems property for this child.

flex
number

How space is distributed among children along the main axis.

position
"absolute" | "relative"

Position absolute makes the view skip taking part in the layout.

gap
number

Space between children along the main axis.

zIndex
number

Z-index of the view. Higher value means that the view will be drawn on top of values with lower z-index.

Default value is 0.

backgroundColor
string

Supported formats are: hex, RGB, HSL, HSV.

Hex can be in short form (e.g. #fff) or long form (e.g. #ffffff).

RGB (rgb(255, 0, 0)) can also have alpha channel: rgba(255, 0, 0, 0.5).

HSL (hsl(60, 100%, 50%)) can also have alpha channel: hsla(30, 60%, 90%, 0.8). Commas are optional (e.g. hsl(60 100% 50%)). Alpha channel can also be separated by / (e.g. hsla(30 60% 90% / 0.8)).

Exactly the same rules apply to HSV as to HSL.

You can pass readCSSVariables to options argument in Layout() constructor and then you can use CSS variables in color values by their names, ie. var(--my-color) is accessed by my-color.

display
"flex" | "none"

Whether the view should be visible or not.

aspectRatio
number

top
number

If view is positioned absolute, this property is used to define its position relative to the parent view.

If view is positioned relative, this property is an offset from the calculated layout position (but it doesn't affect layout of siblings).

If width is not defined and both left and right are set, then the element will stretch to fill the space between the two offsets. Similarly for height.

left
number

See: top property.

right
number

See: top property.

bottom
number

See: top property.

borderColor
string

Color of the border. See backgroundColor for supported formats.

borderWidth
number

Width of the border. Default value is <code>0</code>.

borderTopWidth
number

Border width at the top edge of the view.

borderRightWidth
number

Border width at the right edge of the view.

borderBottomWidth
number

Border width at the bottom edge of the view.

borderLeftWidth
number

Border width at the left edge of the view.

borderRadius
number

Corner radius. Default value is <code>0</code>.

borderRadiusTop
number

Overrides borderRadius property.

borderRadiusBottom
number

Overrides borderRadius property.

borderRadiusTopLeft
number

Overrides borderRadius property and borderRadiusTop property.

borderRadiusTopRight
number

Overrides borderRadius property and borderRadiusTop property.

borderRadiusBottomLeft
number

Overrides borderRadius property and borderRadiusBottom property.

borderRadiusBottomRight
number

Overrides borderRadius property and borderRadiusBottom property.

padding
number

Space around children. More specific properties override it.

paddingHorizontal
number

Overrides padding property.

paddingVertical
number

Overrides padding property.

paddingLeft
number

Overrides margin and marginHorizontal properties.

paddingRight
number

Overrides margin and marginHorizontal properties.

paddingTop
number

Overrides margin and marginVertical properties.

paddingBottom
number

Overrides margin and marginVertical properties.

margin
number

Space around children. More specific properties take precedence.

marginHorizontal
number

Overrides margin property, less important than marginLeft or marginRight.

marginVertical
number

Overrides margin property, less important than marginTop or marginBottom.

marginLeft
number

Overrides margin and marginHorizontal properties.

marginRight
number

Overrides margin and marginHorizontal properties.

marginTop
number

Overrides margin and marginVertical properties.

marginBottom
number

Overrides margin and marginVertical properties.

Layout

Layout is a tree of views. Use it via JSX API (<view> etc.) or direct API (view() and text()).

constructor(context: IContext, options?: LayoutOptions)

Takes a context instance which is used to retrieve HTML canvas size.

view(style: Style): void

Adds a new view to the layout. Any subsequent calls to view() and text() will add children to this view. Call end() to return to the parent view.

Alternative to JSX API. Used in combination with frame() and end().

Usage:

layout.view(containerStyle);
layout.text("Hello", font, 12, "#000", 10, 10);
layout.end();

end(): void

Makes the parent view the current view, so that subsequent calls to view() and text() will add children to the parent view instead.

text(
  text: string,
  font: Font,
  fontSize: number,
  color: string,
  x?: number,
  y?: number,
  trimStart?: number,
  trimEnd?: number
): void

Adds a new text view to the layout.

Alternative to JSX API.

add(node: TreeNode<FixedView>): void

Add a subtree to the layout. Can be used interchangeably with direct API (view() and text()) if needed.

Can be also called multiple times.

Usage:

layout.add(
  <view style={container}>
    <text style={{ fontFamily: font, fontSize: 12, color: "#fff" }}>Hello</text>
  </view>
);

getRoot(): TreeNode<FixedView>

calculate(): void

Calculates layout tree by applying all sizing and direction properties.

render(): void

Render the tree to the context. Most of the time it means that this step is what takes UI to appear on the screen.

Font

Class that holds font spacing data and font atlas image. For loading font file see FontAtlas.

constructor(
  files:
    | {
        spacingMetadataJsonURL: string;
        spacingBinaryURL: string;
        fontAtlasTextureURL: string;
        UVBinaryURL: string;
      }
    | {
        spacingMetadata: FontAtlasMetadata;
        spacingBinaryURL: string;
        fontAtlasTextureURL: string;
        UVBinaryURL: string;
      }
)
constructor(spacing: Spacing, fontImage: HTMLImageElement)

Initialize font by providing URLs to font files or by providing spacing data and font atlas image, which are all already loaded.

load(): Promise<void>

Load font data from provided URLs. Not needed if font was initialized with preloaded data.

getTextShape(
  text: string,
  fontSize: number
): {
  boundingRectangle: { width: number; height: number };
  positions: Vec2[];
  sizes: Vec2[];
}

Calculates shape information for a given text string and font size.

getGlyphs(): Map<number, Glyph>

Returns a map of all glyphs in the font.

getUV(code: number): Vec4

Returns texture coordinates for a given character code.

getMetadata(): FontAtlasMetadata

Returns metadata for the font.

getFontImage(): HTMLImageElement

Returns the font image.

loadFontImageAsync(): Promise<HTMLImageElement>

Context

Context is a low level drawing API, resembling Canvas API from browsers. Used by layout engine to draw elements. This is a reference implementation that should work well in most cases.

constructor(canvas: HTMLCanvasElement, font: Font)

Creates new context.

getWebGLContext(): WebGL2RenderingContext

Get WebGL2 context.

getCanvas(): HTMLCanvasElement

Get canvas element.

getFont(): Font

Get font.

line(points: Vec2[], thickness: number, color: Vec4): void

Draw line connecting given list of points.

polygon(points: Vec2[], color: Vec4): void

Triangulates and draws given polygon. Assumes that polygon is convex. Vertices must be in clockwise order.

triangles(points: Vec2[], color: Vec4): void

Draw given list of triangles.

rectangle(
  position: Vec2,
  size: Vec2,
  color: Vec4,
  borderRadius?: Vec4,
  borderWidth?: Vec4,
  borderColor?: Vec4
): void

Draw rectangle. Border radius is optional and needs to be specified for each corner, in order: top-left, top-right, bottom-right, bottom-left. Border width is specified for each edge, in order: top, right, bottom, left.

Color and border color are normalized RGBA vectors, ie. new Vec4(1, 0, 0, 1) is red.

clear(): void

Clears the screen.

setProjection(x: number, y: number, width: number, height: number): void

Sets projection matrix to orthographic with given dimensions. Y axis is flipped - point (0, 0) is in the top left.

text(
  text: string,
  position: Vec2,
  fontSize: number,
  color: Vec4,
  trimStart?: Vec2,
  trimEnd?: Vec2
): void

Writes text on the screen.

Optionally accepts trimStart and trimEnd, which represent top left and bottom right corners of the text bounding box. Text will be trimmed to fit inside the box.

flush(): void

Renders to screen and resets buffers.

loadTexture(image: HTMLImageElement): WebGLTexture

Load texture to GPU memory.

setAttribute(
  index: number,
  size: number,
  normalized: boolean,
  stride: number,
  offset: number
): number

pushVertexData(
  position: Vec2,
  uv: Vec2,
  color: Vec4,
  corner: Vec2,
  size: Vec2,
  radius: Vec4,
  borderWidth: Vec4,
  borderColor: Vec4
): void

FontAtlas

Takes array of glyph sizing quads and calculates tight packing of them into a texture atlas.

constructor(ttf: TTF, alphabet?: string)

Alphabet is the subset of characters that will be included in the atlas. If not specified, all characters in the font will be included.

render(): { canvas: HTMLCanvasElement; spacing: Spacing }

Returns a canvas with the font atlas rendered on it.

TTF

Parses TTF font files to read information about available characters, their sizes and spacing information.

constructor(data: ArrayBuffer)

Vec2

A 2D vector.

constructor(x: number, y: number)

add(other: Vec2): Vec2

subtract(other: Vec2): Vec2

length(): number

normalize(): Vec2

scale(scalar: number): Vec2

cross(other: Vec2): number

dot(other: Vec2): number

distance(other: Vec2): number

lerp(other: Vec2, t: number): Vec2

equalsEpsilon(other: Vec2, epsilon: number): boolean

equals(other: Vec2): boolean

toString(): string

Vec3

A 3-dimensional vector.

constructor(x: number, y: number, z: number)

add(other: Vec3): Vec3

subtract(other: Vec3): Vec3

length(): number

normalize(): Vec3

scale(scalar: number): Vec3

cross(other: Vec3): Vec3

dot(other: Vec3): number

distance(other: Vec3): number

lerp(other: Vec3, t: number): Vec3

equalsEpsilon(other: Vec3, epsilon: number): boolean

equals(other: Vec3): boolean

toString(): string

Vec4

A 4-dimensional vector.

constructor(x: number, y: number, z: number, w: number)

add(other: Vec4): Vec4

subtract(other: Vec4): Vec4

length(): number

normalize(): Vec4

scale(scalar: number): Vec4

cross(other: Vec4): Vec4

dot(other: Vec4): number

distance(other: Vec4): number

lerp(other: Vec4, t: number): Vec4

xyz(): Vec3

equalsEpsilon(other: Vec4, epsilon: number): boolean

equals(other: Vec4): boolean

toString(): string

Mat4

constructor(data: number[])

identity(): Mat4

scale(x: number, y: number, z: number): Mat4

translate(x: number, y: number, z: number): Mat4

xRotation(angle: number): Mat4

yRotation(angle: number): Mat4

zRotation(angle: number): Mat4

rotate(x: number, y: number, z: number): Mat4

orthographic(
  left: number,
  right: number,
  bottom: number,
  top: number,
  near: number,
  far: number
): Mat4

perspective(fov: number, aspect: number, near: number, far: number): Mat4

fov is in radians.

lookAt(position: Vec3, target: Vec3, up: Vec3): Mat4

translate(offset: Vec3): Mat4

rotate(angle: Vec3): Mat4

scale(scale: Vec3): Mat4

transpose(): Mat4

multiplyVec4(vec: Vec4): Vec4

multiply(other: Mat4): Mat4

invert(): Mat4

Limitations

This is a GPU-based renderer. Not everything is worthwhile to express in shaders. There is a lot of edge cases in border radius, borders and hiding overflow.

This is not full HTML. Following design of Yoga layout engine, red-otter only implements Flexbox layout and makes all views position: relative by default, which limits what kind of layouts are possible to express (but as React Native shows, this should not be a problem for most apps).

What is missing

Talking about roadmap, here is an unordered list of things that need a lot attention before the 1.0 release.

Testing

To write unit tests for layout, you will need a class implementing IContext interface. Here is basic working example:

MockContext.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class MockContext implements IContext {
  getCanvas(): HTMLCanvasElement {
    return {
      clientWidth: 800,
      clientHeight: 600,
    } as HTMLCanvasElement;
  }
  line() {
    // noop
  }
  polygon() {
    // noop
  }
  triangles() {
    // noop
  }
  rectangle() {
    // noop
  }
  clear() {
    // noop
  }
  setProjection() {
    // noop
  }
  text() {
    // noop
  }
  flush() {
    // noop
  }
  loadTexture() {
    return {} as WebGLTexture;
  }
  getFont() {
    return {} as Font;
  }
  getWebGLContext() {
    return {} as WebGL2RenderingContext;
  }
}

Credits

  • Highlights.js for syntax highlighting.
  • Code theme GitHub Dark from Highlight.js.
  • Colors Zinc from Tailwind CSS.
  • Drawing Text with Signed Distance Fields in Mapbox GL.
  • How to draw styled rectangles using the GPU and Metal.
  • This website was written in plain HTML and CSS with a build step script for extracting code examples from the source code.

    © Tomasz Czajęcki 2023