Show HN: JavaScript web apps with WASM layout and GPU rendering in 280 KB
This document was written by hand. No part of it was generated or modified by AI.
Uniform provides tools for building responsive and beautiful user interfaces. Despite implementing a full replacement for native UI tools, including text, layout, and rendering, a complete web application using Uniform is less than 280 KB (compressed).
Uniform does not have a visual identity. Instead, it makes it as easy as possible to express any visual identity. Its goal is to allow designers to implement their ideas precisely: pixel-perfect layout, smooth animations, and the flexibility to customize the tools when necessary. By taking low-level control, it behaves consistently across all supported platforms.
Uniform is easy to use, and has different APIs for different needs. A JavaScript API is optimized to enable the fastest possible iteration speeds. With no build step, changes are visible immediately. It calls directly into the C core of the library, which compiles to WASM on the web, and native code on all other platforms.
There is also a simple, clean C API. It provides greater performance, but is just as usable as the JavaScript API. The library can be statically linked, or compiled from source together with the rest of the application. Compilation from source, including shaders, takes less than 1 second.
Uniform is currently in Preview, which means the API may change, and not all features are done. It supports iOS and macOS, where it renders via Metal, and the web, where it renders via WebGPU. Native support for Windows, Linux, and Android, where it will render via Vulkan, is planned.
Powerful Layout
Uniform combines the best parts of constraint-based layout with an auto-layout system using stacks. Constraints are defined between parent and child, and work by pinning an anchor from the child to an anchor of the parent, plus an optional offset.
Stacks support spacing between items, padding around the stack content, alignment, and distribution. Each item can define how it behaves as part of a stack: it either fills the available space, fits tightly around its content, or has a fixed size.
Layout is performed in fixed-point arithmetic, edges are snapped to pixel boundaries, and consistent spacing ensured.
For text, Uniform supports text layout with character-level information accessible to the user, making custom text selection and visual effects possible. Custom fonts are supported and can be set, along with size, weight, and color, for specific ranges within a string.
Built for Custom Design
Most applications require exact control over visual design and behavior. Shipping pre-built components and providing selected customization points does not fulfill this requirement. Simple components like buttons are defined from scratch. Custom visuals, gestures, and behaviors take only a few lines of code.
For more complex components like scrolling lists or text fields, the library provides the core functionality like event handling, gestures, physics, and animations. The user just reads the calculated result of what should be drawn in what location, and creates the visual elements with the appearance they want.
Shapes and Effects
Uniform uses SDF shapes as a central concept. SDF is short for Signed Distance Field. It means that every pixel has information about its distance to the closest edge, as well as the corresponding normal. This information can be used to enable advanced visual effects. Glass uses it to add refraction effects near the edge, vary the blur radius across the shape, and render realistic shadows. It also allows shapes to blend together.
Continuous Curves
The library renders perfectly smooth curves: continuous position, continuous slope, continuous curvature, and continuous curvature slope. These curves are used by default for rounded rectangles, which transition smoothly down to pills and circles. They can also be used for arbitrary polylines or polygons.
Low-Latency Input Processing
There is a platform-independent interface for touch, pencil, keyboard, and mouse input. For full control, raw events can be received on each interface element, hit-tested against the node hierarchy.
For touch input, there is a powerful gesture system. Gestures enable negotiations between components to resolve what actions are performed. Common gestures like tap, long-press, pan, or pinch come with the library. These bundled gestures are implemented using the public API, so custom gestures have exactly the same level of control and interoperate seamlessly.
Advanced Display Technologies
Uniform supports extended dynamic range rendering with a simple interface. Solid colors, gradients, images, and more can just use values above 1.0 and will be rendered brighter on capable displays. The compositor can render with high bit depth, or apply dithering for improved color transitions on standard bit depth targets. High refresh rates are supported, too.
Simple API
Interface elements are created with a single function call. The result is an immutable node. Nodes can be organized into a hierarchy. Layout and rendering operate on such hierarchies and behave like pure functions. Creating and laying out nodes is a cheap operation that can be performed as often as needed.
While most layouts can be realized with the built-in constraint and stack based tools, Uniform is designed to support any layout. The ability to perform layout on individual nodes and hierarchies, potentially repeatedly or with complex dependencies, makes this possible.
Native Integration
Uniform does not compete with platform-provided UI frameworks. It does not include copies of native controls. Instead, it provides tools to build controls that should eventually exceed native or browser quality. Because the library is shipped directly with the application, improvements can be delivered to all users immediately.
At the platform boundaries, Uniform strives for perfect integration. Windows resize artifact-free and rendering happens with no additional latency compared to native UI.
Fast Iteration Speeds
For creative work, iteration times are highest priority. Uniform embraces this at every level. For rapid prototyping, the JavaScript API eliminates any build steps. C headers are minimal, and compiling the library from scratch takes less than a second. At runtime, every language calls into the common C core. For most use cases, choosing an interface is a matter of preference, not performance.
Light and Efficient
Uniformβs core is written in C, and its shaders in the Slang Shading Language. It makes minimal use of third-party libraries. Eventually, all core technologies should be controlled by the project. Through this approach, binary sizes are small and runtime resource consumption low.
The library works great for games and professional apps with high performance requirements, and just as much for simple apps and prototypes.
Easy Integration
A JavaScript application can be written in a single file. The file is processed by the Uniform runtime. The runtime uses JavaScriptCore to execute JIT-compiled code on desktop, and interpreted code on mobile. In both cases, JavaScript functions are forwarded to native functions.
For deployment to desktop and mobile platforms, the runtime executable and the user file can be bundled and shipped, including to app stores. For deployment on the web, no additional runtime is needed, and the library code is included directly.
For C, or languages with a C FFI, the library provides header files, plus a binary archive per target platform. When building for the web, the application and server can be bundled into a single executable, with no external dependencies. Updating production reduces to copying a file.
The following code launches the application, opens a window, and draws a gradient background. It runs on macOS, iOS, and the web.
function onUpdate(window) {
const targetDesc = app.windowGetTargetDesc(window);
const root = ui.frame({
size: ui.sizeFix(targetDesc.pxSize[0], targetDesc.pxSize[1]),
fill: ui.fillGradient('down', ui.gray10, ui.gray40),
});
ui.layout(root);
const commandBuffer = gpu.commandBufferCreate(app.getDevice(app.windowGetApp(window)), '');
const target = app.windowAcquireTarget(window);
ui.render(root, commandBuffer, target);
ui.release(root);
app.windowPresent(window, commandBuffer, target);
}
function onStart(a) {
ui.init(app.getDevice(a));
app.windowCreate(a, {
onUpdate: onUpdate,
});
}
app.run({
onStart: onStart,
});
void on_update(struct app_window* window) {
struct app_target_desc target_desc = app_window_get_target_desc(window);
struct ui_node root = ui_frame(&(struct ui_frame) {
.size = ui_size_fix(target_desc.px_size.x, target_desc.px_size.y),
.fill = ui_fill_gradient(ui_gradient_direction_down, ui_gray10, ui_gray40),
});
ui_layout(root);
struct gpu_command_buffer* command_buffer = gpu_command_buffer_create(app_get_device(app_window_get_app(window)), "");
struct gpu_texture* target = app_window_acquire_target(window);
ui_render(root, command_buffer, target, (struct ui_render_desc) {});
ui_release(root);
app_window_present(window, command_buffer, target);
}
void on_start(struct app* app) {
ui_init(app_get_device(app));
app_window_create(app, &(struct app_window_desc) {
.on_update = on_update,
});
}
int main(void) {
app_run(&(struct app_desc) {
.on_start = on_start,
});
}
app_run initializes long-lived application state. It is called once before any of the other app_ functions. Once its work is completed, it calls the user-provided on_start function.
In on_start, the GPU device is available via app_get_device. The UI context is created and set as the default, so UI functions do not need to provide it explicitly. Then, a window is opened via app_window_create.
On macOS, it creates a window and positions and sizes it according to the user-provided parameters. On iOS, it uses the default scene and window, which fills the screen. On the web, it creates a canvas element in the DOM. The function returns a handle to the window. Using that, the user can render content into the window.
Most commonly, drawing is performed at the refresh rate of the display. To be notified of display refreshes, a callback on_update can be provided when creating the window. on_update is called once per display refresh, commonly 60 or 120 times per second.
The user can request information about the current state of the app or window by calling app_ or app_window_ functions. For instance, the properties of the drawing surface are accessible via app_window_get_target_desc(). Using that, the root node of the user interface is created.
Next, the root node is laid out. ui_layout takes the requested position, size, and layout of each node and calculates the layout result for a specific target resolution. In this case, the size of the root node was specified as exactly the size of the target, so it fills it completely.
For rendering, a GPU command buffer and the current drawing surface of the window are acquired. Both are given as parameters to ui_render, which writes commands into the command buffer. It returns immediately after the work is enqueued and does not wait for rendering to complete.
The root node is no longer needed and gets released. If it had children, they would get released as well. Finally, app_window_present enqueues a command to present the drawing surface and commits the command buffer for asynchronous execution on the GPU.
Note that the code as written here is not optimally energy-efficient. on_update is called on every display refresh, and each time, we layout and render exactly the same content. For an app that mostly draws static content, it might be ideal to only render when any part of it changed. In this instance, the window size is the only thing that influences the rendered result. If it stayed constant, the function could return early and the previous render would simply stay on screen.
Next, the code above is extended to draw a slightly more complicated interface.
const isDark = app.windowGetIsDark(window);
const root = ui.frame({
size: ui.sizeFix(targetDesc.pxSize[0], targetDesc.pxSize[1]),
fill: ui.fillGradient('down', isDark ? ui.gray10 : ui.white, isDark ? ui.gray40 : ui.gray80),
});
const background = ui.frame({
parent: root,
pos: ui.posCenter(),
layout: ui.layoutStackX('center', 'center', 0, ui.padXy(24, 12)),
fill: ui.fillSolid(isDark ? ui.white : ui.black),
corners: ui.corners(16),
});
ui.text({
parent: background,
str: ui.str('Uniform', isDark ? ui.black : ui.white, 26),
});
bool is_dark = app_window_get_is_dark(window);
struct ui_node root = ui_frame(&(struct ui_frame) {
.size = ui_size_fix(target_desc.px_size.x, target_desc.px_size.y),
.fill = ui_fill_gradient(ui_gradient_direction_down, is_dark ? ui_gray10 : ui_white, is_dark ? ui_gray40 : ui_gray80),
});
struct ui_node background = ui_frame(&(struct ui_frame) {
.parent = root,
.pos = ui_pos_center(),
.layout = ui_layout_stack_x(ui_dist_center, ui_align_center, 0, ui_pad_xy(24, 12)),
.fill = ui_fill_solid(is_dark ? ui_white : ui_black),
.corners = ui_corners(16),
});
ui_text(&(struct ui_text) {
.parent = background,
.str = ui_str("Uniform", is_dark ? ui_black : ui_white, 26),
});
The code is now reading the current theme from the window and using it to customize the appearance of the content. A background node is added as a child of the root node and positioned at its center. It has a layout argument, which specifies the position and size of its children.
A stack layout has parameters for distribution (positions along the stack axis), alignment (positions on the cross-axis), spacing (space between the items), and padding (space around the whole content). In this case, the content is centered, and since there is only a single child, the stack direction and spacing have no visual impact.
The background node does not specify a size. By default, the size of a stack is determined automatically based on the size of its content.
The nodes are drawn in order of their hierarch
Comments
No comments yet. Start the discussion.