Gooey: A GPU-accelerated UI framework for Zig Gooey, a GPU-accelerated UI framework for the Zig programming language, has been released as an open-source project targeting macOS, Linux, and browser platforms. The framework provides declarative UI components, GPU rendering through Metal and Vulkan, and zero external Zig dependencies, enabling developers to build cross-platform applications with native performance. Gooey supports features including animations, text rendering, accessibility, and native file dialogs, with early-stage API development ongoing. A GPU-accelerated UI framework for Zig, targeting macOS Metal , Linux Vulkan/Wayland , and Browser WASM/WebGPU . Join the Gooey discord https://discord.gg/bmzAZnZJyw Early Development: API is evolving. Example app built with Gooey — chat-zig https://github.com/duanebester/chat-zig , an Anthropic Claude client using the Zig 0.16 std.Io stack for async HTTP: GPU Rendering - Metal macOS , Vulkan Linux with MSAA anti-aliasing WebGPU/WASM is blocked upstream on Zig 0.16 — see WASM wasm Declarative UI - Component-based layout with ui. primitives and flexbox-style system Cx/UI Separation - Cx for state, handlers, and focus; ui. for layout primitives Pure State Pattern - Testable state methods with automatic re-rendering Animation System - Built-in animations with easing, animateOn triggers Entity System - Dynamic entity creation/deletion with auto-cleanup Retained Widgets - TextInput, TextArea, Checkbox, Scroll containers Text Rendering - CoreText macOS , FreeType/HarfBuzz Linux , Canvas WASM Custom Shaders - Drop in your own Metal/GLSL shaders Drag & Drop - Type-safe drag sources and drop targets with pointer events control Liquid Glass - macOS 26.0+ Tahoe transparent window effects Actions & Keybindings - Contextual action system with keymap Theming - Built-in light/dark mode support Images & SVG - Load images and render SVG icons with styling File Dialogs - Native file open/save dialogs macOS, Linux, WASM Clipboard - Native clipboard support on all platforms IME Support - Input method editor for international text input Accessibility - Built-in screen reader support VoiceOver, Orca, ARIA with semantic roles and live regions Zero Dependencies - No external Zig packages; builds against system frameworks/libraries only the Objective-C runtime bindings are vendored in-tree Requirements: Zig 0.16.0+ Dependencies: None. Gooey has zero external Zig package dependencies — build.zig.zon lists no dependencies. It links only against platform system frameworks/libraries see platform notes below . macOS: macOS 12.0+ Linux: Wayland compositor, Vulkan drivers, FreeType, HarfBuzz, Fontconfig, libpng, D-Bus zig build run Showcase demo zig build run-counter Counter example zig build run-todo Todo app state, handlers, TextInput, lists zig build run-animation Animation demo zig build run-pomodoro Pomodoro timer zig build run-glass Liquid glass effect zig build run-spaceship Space dashboard with shader zig build run-dynamic-counters Entity system demo zig build run-layout Flexbox, shrink, text wrapping zig build run-actions Keybindings demo zig build run-select Dropdown select component zig build run-tooltip Tooltip component zig build run-modal Modal dialogs zig build run-images Image loading and styling zig build run-file-dialog Native file dialogs zig build run-uniform-list Virtualized list 10k items zig build run-virtual-list Variable-height list zig build run-data-table Virtualized table 10k rows zig build run-code-editor Code editor with syntax highlighting zig build test Run tests A small todo app that touches a representative slice of the API: a pure, UI-free state model; cx.update / cx.updateWith / cx.command handlers; a bound TextInput ; Checkbox and Button ; list iteration; and unit tests that exercise the state with no UI in play. The full, runnable source lives in src/examples/todo.zig /duanebester/gooey/blob/main/src/examples/todo.zig zig build run-todo . Its state model is covered by the tests shown at the bottom, which run as part of zig build test . js const std = @import "std" ; const gooey = @import "gooey" ; const ui = gooey.ui; const Cx = gooey.Cx; const Button = gooey.components.Button; const Checkbox = gooey.components.Checkbox; const TextInput = gooey.components.TextInput; const MAX TODOS = 64; const TEXT CAP = 128; const draft input id = "new-todo"; // State is pure — no UI knowledge, fully testable. const Todo = struct { buf: TEXT CAP u8 = u8{0} TEXT CAP, len: usize = 0, done: bool = false, fn text self: const Todo const u8 { return self.buf 0..self.len ; } }; const Filter = enum { all, active, done }; const AppState = struct { todos: MAX TODOS Todo = Todo{.{}} MAX TODOS, count: usize = 0, draft: const u8 = "", // two-way bound to the TextInput filter: Filter = .all, // Pure logic — what the tests below drive. fn pushTodo self: AppState, value: const u8 void { const trimmed = std.mem.trim u8, value, " \t\r\n" ; if trimmed.len == 0 return; if self.count = MAX TODOS return; const slot = &self.todos self.count ; const n = @min trimmed.len, TEXT CAP ; @memcpy slot.buf 0..n , trimmed 0..n ; slot.len = n; slot.done = false; self.count += 1; } pub fn toggle self: AppState, index: usize void { if index = self.count return; self.todos index .done = self.todos index .done; } pub fn remove self: AppState, index: usize void { if index = self.count return; var i = index; while i + 1 < self.count : i += 1 self.todos i = self.todos i + 1 ; self.count -= 1; } pub fn setFilter self: AppState, filter: Filter void { self.filter = filter; } pub fn clearCompleted self: AppState void { var write: usize = 0; var read: usize = 0; while read < self.count : read += 1 { if self.todos read .done { self.todos write = self.todos read ; write += 1; } } self.count = write; } fn remaining self: const AppState u32 { var n: u32 = 0; for self.todos 0..self.count | t| { if t.done n += 1; } return n; } fn visible self: const AppState, t: const Todo bool { return switch self.filter { .all = true, .active = t.done, .done = t.done, }; } // Command — needs framework access the binding only flows widget - state, // so we reach the retained input widget to clear it after adding . pub fn addTodo self: AppState, g: gooey.Window void { self.pushTodo self.draft ; self.draft = ""; if g.widgetState gooey.widgets.TextInputState, draft input id |input| { input.clear ; } } }; var state = AppState{}; const App = gooey.App AppState, &state, render, .{ .title = "Todos", .width = 480, .height = 560, } ; comptime { = App; // Force analysis also wires @export on WASM . } pub fn main init: std.process.Init void { return App.main init ; } fn render cx: Cx void { const s = cx.state AppState ; const size = cx.windowSize ; cx.render ui.box .{ .width = size.width, .height = size.height, .direction = .column, .padding = .{ .all = 24 }, .gap = 16, .background = ui.Color.rgb 0.96, 0.96, 0.97 , }, .{ ui.text "Todos", .{ .size = 28 } , // Input row: TextInput binds to state.draft; Add is a command. ui.hstack .{ .gap = 8, .alignment = .center }, .{ TextInput{ .id = draft input id, .placeholder = "What needs doing?", .bind = &s.draft, .fill width = true }, Button{ .label = "Add", .on click handler = cx.command AppState.addTodo }, } , // Filters: each button packs its enum value into the handler arg. ui.hstack .{ .gap = 8 }, .{ FilterButton{ .label = "All", .filter = .all, .active = s.filter == .all }, FilterButton{ .label = "Active", .filter = .active, .active = s.filter == .active }, FilterButton{ .label = "Done", .filter = .done, .active = s.filter == .done }, } , // The list, or an empty-state hint. ui.when s.count == 0, .{ ui.text "Nothing yet — add your first todo above.", .{ .size = 14 } , } , TodoItems{}, ui.spacer , ui.hstack .{ .gap = 12, .alignment = .center }, .{ ui.textFmt "{d} left", .{s.remaining }, .{ .size = 14 } , ui.spacer , Button{ .label = "Clear completed", .variant = .secondary, .size = .small, .on click handler = cx.update AppState.clearCompleted }, } , } ; } // Iteration lives in a component because each row needs cx for its handlers. const TodoItems = struct { pub fn render : @This , cx: Cx void { const s = cx.state AppState ; for s.todos 0..s.count , 0.. | todo, index| { if s.visible todo continue; cx.render TodoRow{ .index = index, .done = todo.done, .label = todo.text } ; } } }; const TodoRow = struct { index: usize, done: bool, label: const u8, pub fn render self: @This , cx: Cx void { // A background + cross-axis centering means this is a box with // .direction = .row , not an hstack — stacks carry only gap/ // alignment/padding. cx.render ui.box .{ .direction = .row, .gap = 12, .alignment = .{ .cross = .center }, .padding = .{ .all = 10 }, .background = ui.Color.white, .corner radius = 8, }, .{ Checkbox{ .checked = self.done, .on click handler = cx.updateWith self.index, AppState.toggle }, ui.text self.label, .{ .size = 16 } , ui.spacer , Button{ .label = "Delete", .variant = .danger, .size = .small, .on click handler = cx.updateWith self.index, AppState.remove }, } ; } }; const FilterButton = struct { label: const u8, filter: Filter, active: bool, pub fn render self: @This , cx: Cx void { cx.render Button{ .label = self.label, .size = .small, .variant = if self.active .primary else .secondary, .on click handler = cx.updateWith self.filter, AppState.setFilter , } ; } }; // State is testable without UI. test "remove keeps the list contiguous" { var s = AppState{}; s.pushTodo "a" ; s.pushTodo "b" ; s.pushTodo "c" ; s.remove 1 ; // drop "b" try std.testing.expectEqual @as usize, 2 , s.count ; try std.testing.expectEqualStrings "a", s.todos 0 .text ; try std.testing.expectEqualStrings "c", s.todos 1 .text ; } test "remaining and clearCompleted" { var s = AppState{}; s.pushTodo "a" ; s.pushTodo "b" ; s.toggle 0 ; try std.testing.expectEqual @as u32, 1 , s.remaining ; s.clearCompleted ; try std.testing.expectEqual @as usize, 1 , s.count ; try std.testing.expectEqualStrings "b", s.todos 0 .text ; } Gooey separates concerns between Cx context and ui layout primitives : | Module | Purpose | Examples | |---|---|---| cx. | State, handlers, animations, focus | cx.state , cx.update , cx.animate , cx.changed , cx.render | ui. | Layout containers and primitives | ui.box , ui.rect , ui.hstack , ui.vstack , ui.text , ui.when | js fn render cx: Cx void { const s = cx.state AppState ; cx.render ui.box .{ .width = 100 }, .{ ui.text "Hello", .{} , // Conditional rendering ui.when s.show extra, .{ ui.text "Extra content", .{} , } , // Iterate over items ui.each &s.items, struct { fn render item: Item, : usize @TypeOf ui.text "", .{} { return ui.text item.name, .{} ; } }.render , } ; } Key primitives: ui.box - Container with flexbox layout ui.rect - Childless box dividers, spacers, colored blocks ui.hstack / ui.vstack - Horizontal/vertical stacks ui.text / ui.textFmt - Text rendering ui.when cond, children - Conditional rendering ui.maybe optional, fn - Render if optional has value ui.each items, fn - Render for each item ui.scroll id, style, children - Scrollable container ui.spacer - Flexible space | Method | Signature | Use Case | |---|---|---| cx.update | fn State void | Pure state mutations | cx.updateWith | fn State, Arg void | Mutations with argument | cx.command | fn State, Gooey void | Framework access focus, quit, entities | cx.commandWith | fn State, Gooey, Arg void | Framework access with argument | cx.defer | fn State, Gooey void | Run after current event completes | cx.deferWith | fn State, Gooey, Arg void | Deferred with argument | Note:The state type is inferred automatically from the method pointer's first parameter — no need to pass it separately. The With variants updateWith , commandWith , deferWith let you pass data to your handler. The argument is captured at handler creation time and passed when invoked: // In a list render callback - capture the index .on click handler = cx.updateWith index, State.selectItem , // The handler receives the captured value pub fn selectItem self: State, index: u32 void { self.selected = index; } The 8-byte limit: Arguments are packed into a u64 for zero-allocation storage. This means your argument must be ≤8 bytes. If it exceeds this, you'll get a compile error: error: updateWith: argument type 'MyLargeStruct' exceeds 8 bytes. Use a pointer or index instead. What fits in 8 bytes: | Type | Size | ✓/✗ | |---|---|---| u8 , i8 , bool | 1 byte | ✓ | u16 , i16 | 2 bytes | ✓ | u32 , i32 , f32 | 4 bytes | ✓ | u64 , i64 , f64 | 8 bytes | ✓ | usize 64-bit | 8 bytes | ✓ | T any pointer | 8 bytes | ✓ | struct { x: u32, y: u32 } | 8 bytes | ✓ | 2 u32 | 8 bytes | ✓ | struct { a: u32, b: u32, c: u32 } | 12 bytes | ✗ | Workarounds for larger data: // Option 1: Use an index into your data .on click handler = cx.updateWith row index, State.selectRow , // Option 2: Use a pointer if the data outlives the handler .on click handler = cx.updateWith &self.items i , State.editItem , // Option 3: Store data in state, pass an ID pub fn openFile self: State, file id: u32 void { const file = self.files.get file id orelse return; // ... use file.path, file.name, etc. } Use defer when you need to run code after the current event handler completes. This is essential for: Modal dialogs - They run their own event loop, which would deadlock if called during event handling File pickers - Same reason as modals Heavy operations - Defer work to avoid blocking the current frame // In a command handler, use g.deferCommand : pub fn openFolder self: State, g: Gooey void { = self; g.deferCommand State, State.openFolderDeferred ; } fn openFolderDeferred self: State, g: Gooey void { = g; // Safe to open modal dialog here - we're outside event handling const file dialog = gooey.file dialog; if file dialog.promptForPaths allocator, .{ .directories = true } |result| { defer result.deinit ; const path = result.paths 0 ; self.loadDirectory path ; } } // With an argument same 8-byte limit applies : pub fn deleteItem self: State, g: Gooey, index: u32 void { = self; g.deferCommandWith State, u32, index, State.confirmDelete ; } fn confirmDelete self: State, g: Gooey, index: u32 void { = g; if dialog.confirm "Delete item?" { self.items.remove index ; } } The deferred command queue holds up to 32 commands and is flushed after each event cycle. Run expensive work — network requests, file I/O, heavy computation — off the UI thread using Zig 0.16's std.Io . The framework owns no executor of its own: background tasks are spawned with cx.io .async ... , hand their results back through a bounded std.Io.Queue T , and the render loop drains that queue each frame. Background tasks never touch UI state directly — they only push typed results — so there are no locks on your state. This is the same pattern src/image/loader.zig uses for async image URL fetches. js // A typed result the background task hands back to the render loop. const Fetch = union enum { ok: const u8, failed, }; const State = struct { // Fixed-capacity, statically-backed channel — no allocation after init. result buffer: 16 Fetch = undefined, result queue: std.Io.Queue Fetch = undefined, // Owns the in-flight task s so they can be cancelled together. fetch group: std.Io.Group = .init, response: const u8 = "", // Kick off background work from a handler — runs off the UI thread. pub fn startFetch self: State, cx: Cx void { const url = "https://api.example.com/data"; self.result queue = .init &self.result buffer ; // io is passed twice: once to drive async , and again inside the // args tuple so the task body can push into the queue. self.fetch group.async cx.io , fetchData, .{ cx.io , url, &self.result queue } ; // Auto-cancel on window close so a late task can't write into freed state. cx.registerCancelGroup &self.fetch group ; } }; // Background task — never touches UI state, only pushes a typed result. fn fetchData io: std.Io, url: const u8, queue: std.Io.Queue Fetch void { const body = httpGet io, url catch { queue.putOneUncancelable io, .failed catch {}; return; }; queue.putOneUncancelable io, .{ .ok = body } catch {}; } fn render cx: Cx void { const s = cx.state State ; // Non-blocking drain — safe to call every frame from render . var buffer: 16 Fetch = undefined; for cx.drainQueue Fetch, &s.result queue, &buffer |result| switch result { .ok = |body| s.response = body, .failed = {}, } // ... build UI from s.response ... } Key pieces: — the cx.io std.Io instance threaded through the framework from main . Pass it to async , queue, and timing calls. or cx.io .async fn, .{args} — spawn background work. Pass group.async io, fn, .{args} io inside the args tuple too if the task needs to push into a queue.— bounded, lock-free, statically-backed channel. Tasks push with std.Io.Queue T putOneUncancelable io, value ; capacity is fixed at init no allocation afterward .— non-blocking drain into your buffer; returns an empty slice when nothing is ready, so it's safe to call every frame. cx.drainQueue T, &queue, &buffer — owns one or more in-flight tasks so they can be cancelled together. std.Io.Group — auto-cancel a group on window close pair with cx.registerCancelGroup &group cx.unregisterCancelGroup if the work finishes normally . For per-entity lifecycles, use cx.entities.attachCancel id, &group to cancel when the entity is removed. Note:This rides on Zig 0.16's std.Io , so the threaded backend is unavailable on WASM single-threaded — see WASM . The earlier cx.dispatchBackground / dispatchOnMainThread / dispatchAfter APIs were removed in the std.Io migration; the Io.Queue + Io.Group pattern above replaces them. See . docs/zig-0.16-io-migration.md By default, Gooey uses the platform's system sans-serif font e.g., DejaVu Sans on Linux, SF Pro on macOS, system-ui on web . You can set a custom font at app init or switch fonts at runtime. Set .font in your app config to use any font installed on the system: js const App = gooey.App AppState, &state, render, .{ .title = "My App", .font = "Inter", .font size = 16.0, // optional, defaults to 16.0 } ; Omitting .font uses the platform default. On Linux, any font discoverable by Fontconfig works — install fonts via your package manager e.g., sudo apt install fonts-inter or drop .ttf / .otf files into ~/.local/share/fonts/ . Change the font on the fly from any event handler: js fn onSettingsChanged cx: Cx void { const s = cx.state AppState ; cx.setFont s.font name, s.font size catch {}; } This clears the glyph and shape caches and triggers a re-render automatically. All text in the UI updates immediately. | Platform | Font Discovery | System Sans-Serif | |---|---|---| | Linux | Fontconfig | sans-serif typically DejaVu Sans or Noto Sans | | macOS | CoreText | SF Pro | | Web | CSS font stack | system-ui, -apple-system, sans-serif | Note:Gooey currently uses a single global font. Per-component font families e.g., mixing a serif body font with a monospace code font are not yet supported — components expose font size but not font family . Gooey ships with two built-in themes — Theme.light Catppuccin Latte and Theme.dark Catppuccin Macchiato . Set the active theme before rendering: fn render cx: Cx void { cx.setTheme if s.dark mode &Theme.dark else &Theme.light ; // ... } Define a light/dark pair of Theme values and swap between them the same way as the built-ins. Every field has a semantic role so components resolve colors automatically without per-component overrides: js const my light = gooey.Theme{ .bg = Color.rgb 0.97, 0.97, 0.98 , .surface = Color.rgb 0.93, 0.93, 0.95 , .overlay = Color.rgb 0.88, 0.88, 0.91 , .primary = Color.rgb 0.20, 0.50, 0.90 , .secondary = Color.rgb 0.45, 0.48, 0.58 , .accent = Color.rgb 0.55, 0.25, 0.85 , .success = Color.rgb 0.20, 0.65, 0.30 , .warning = Color.rgb 0.85, 0.60, 0.10 , .danger = Color.rgb 0.82, 0.24, 0.24 , .text = Color.rgb 0.15, 0.15, 0.20 , .subtext = Color.rgb 0.35, 0.37, 0.45 , .muted = Color.rgb 0.55, 0.57, 0.65 , .border = Color.rgba 0.55, 0.57, 0.65, 0.3 , .border focus = Color.rgb 0.20, 0.50, 0.90 , .radius sm = 4, .radius md = 8, .radius lg = 16, .font size base = 14, }; const my dark = gooey.Theme{ .bg = Color.rgb 0.10, 0.10, 0.12 , .surface = Color.rgb 0.15, 0.15, 0.18 , .overlay = Color.rgb 0.20, 0.20, 0.24 , .primary = Color.rgb 0.40, 0.70, 1.00 , .secondary = Color.rgb 0.45, 0.48, 0.58 , .accent = Color.rgb 0.75, 0.55, 0.95 , .success = Color.rgb 0.45, 0.85, 0.55 , .warning = Color.rgb 0.95, 0.80, 0.35 , .danger = Color.rgb 0.95, 0.40, 0.40 , .text = Color.rgb 0.92, 0.92, 0.95 , .subtext = Color.rgb 0.70, 0.72, 0.80 , .muted = Color.rgb 0.50, 0.52, 0.60 , .border = Color.rgba 0.50, 0.52, 0.60, 0.3 , .border focus = Color.rgb 0.40, 0.70, 1.00 , .radius sm = 4, .radius md = 8, .radius lg = 16, .font size base = 14, }; fn render cx: Cx void { cx.setTheme if s.dark mode &my dark else &my light ; // ... } The font size base field default 14 is the single source of truth for text sizing across components. Components scale relative to it — for example, Button derives its per-size font sizes as: | Button size | Font size | |---|---| .small | base - 2 12 | .medium | base 14 | .large | base + 2 16 | Set it once in your theme and every component scales consistently — no per-component font size overrides needed: js const large text theme = gooey.Theme{ // ...colors... .font size base = 18, // small=16, medium=18, large=20 }; Gooey includes ready-to-use components: // Button variants Button{ .label = "Save", .variant = .primary, .on click handler = cx.update State.save } Button{ .label = "Cancel", .variant = .secondary, .size = .small, .on click handler = ... } Button{ .label = "Delete", .variant = .danger, .on click handler = ... } // Single-line text input with binding TextInput{ .id = "email", .placeholder = "Enter email...", .bind = &s.email, .width = 250, } // Multi-line text area TextArea{ .id = "notes", .placeholder = "Enter notes...", .bind = &s.notes, .width = 400, .height = 200, } Checkbox{ .id = "terms", .checked = s.agreed to terms, .on click handler = cx.update State.toggleTerms , } // RadioButton - individual buttons for custom layouts RadioButton{ .label = "Email", .is selected = s.contact method == 0, .on click handler = cx.updateWith @as u8, 0 , State.setContactMethod , } // RadioGroup - grouped buttons with handlers array RadioGroup{ .id = "priority", .options = &.{ "Low", "Medium", "High" }, .selected = s.priority, .handlers = &.{ cx.updateWith @as u8, 0 , State.setPriority , cx.updateWith @as u8, 1 , State.setPriority , cx.updateWith @as u8, 2 , State.setPriority , }, .direction = .row, // or .column .gap = 16, } js const State = struct { selected fruit: ?usize = null, pub fn selectFruit self: State, index: usize void { self.selected fruit = index; } }; // In render: Select{ .id = "fruit-select", .options = &.{ "Apple", "Banana", "Cherry", "Date" }, .selected = s.selected fruit, .placeholder = "Choose a fruit...", .on select = cx.onSelect State.selectFruit , .width = 200, } The widget manages open/close state internally — no toggle/close handlers or per-option handler arrays needed. Just provide on select and a single handler that receives the selected index. Legacy API:The explicit is open / on toggle handler / on close handler / handlers fields are still supported for full manual control. js const State = struct { show confirm: bool = false, pub fn openConfirm self: State void { self.show confirm = true; } pub fn closeConfirm self: State void { self.show confirm = false; } }; // Trigger button Button{ .label = "Delete Item", .variant = .danger, .on click handler = cx.update State.openConfirm } // Modal with custom content Modal ConfirmContent { .id = "confirm-dialog", .is open = s.show confirm, .on close = cx.update State.closeConfirm , .child = ConfirmContent{ .message = "Are you sure you want to delete?", .on confirm = cx.update State.doDelete , .on cancel = cx.update State.closeConfirm , }, .animate = true, .close on backdrop = true, } // Wrap any component with a tooltip Tooltip Button { .text = "Click to save your changes", .child = Button{ .label = "Save", .on click handler = ... }, .position = .top, // .top, .bottom, .left, .right } // With custom styling Tooltip IconButton { .text = "This field is required", .child = HelpIcon{}, .position = .right, .max width = 200, .background = Color.rgb 0.2, 0.2, 0.25 , } // Simple image from path gooey.Image{ .src = "assets/logo.png" } // With explicit sizing gooey.Image{ .src = "photo.jpg", .width = 200, .height = 150 } // Rounded avatar gooey.Image{ .src = "avatar.png", .size = 48, .rounded = true } // Cover image fills container, may crop gooey.Image{ .src = "banner.jpg", .width = 800, .height = 200, .fit = .cover } // With effects gooey.Image{ .src = "icon.png", .size = 64, .grayscale = 1.0, // 0.0 = color, 1.0 = grayscale .tint = gooey.Color.blue, // Color overlay .opacity = 0.8, .corner radius = 8, } js const gooey = @import "gooey" ; const Svg = gooey.Svg; const Icons = gooey.Icons; // Using built-in icon paths Svg{ .path = Icons.star, .size = 24, .color = Color.gold } Svg{ .path = Icons.check, .size = 20, .color = Color.green } Svg{ .path = Icons.close, .size = 16, .color = Color.red } // Stroked icon outline only Svg{ .path = Icons.star outline, .size = 24, .stroke color = Color.white, .stroke width = 2 } // Both fill and stroke Svg{ .path = Icons.favorite, .size = 24, .color = Color.red, .stroke color = Color.black, .stroke width = 1 } // Available icons: arrow back, arrow forward, menu, close, more vert, // check, add, remove, edit, delete, search, star, star outline, favorite, // info, warning, error icon, play, pause, skip next, skip prev, volume up, // visibility, visibility off, folder, file, download, upload ProgressBar{ .progress = s.completion, // 0.0 to 1.0 .width = 200, .height = 8, .corner radius = 4, } // Individual tabs for custom navigation cx.render ui.hstack .{ .gap = 4 }, .{ Tab{ .label = "Home", .is active = s.tab == 0, .on click handler = cx.updateWith @as u8, 0 , State.setTab , }, Tab{ .label = "Settings", .is active = s.tab == 1, .on click handler = cx.updateWith @as u8, 1 , State.setTab , .style = .underline, // .pills default , .underline, .segmented }, } Virtualized list for efficiently rendering large datasets with uniform item heights. Only visible items are rendered, regardless of total count. The render callback receives Cx for full access to state and handlers. js const State = struct { list state: UniformListState = UniformListState.init 10 000, 32.0 , // count, item height selected: ?u32 = null, pub fn scrollToTop self: State void { self.list state.scrollToTop ; } pub fn scrollToMiddle self: State void { self.list state.scrollToItem 5000, .center ; } pub fn selectItem self: State, index: u32 void { self.selected = index; } }; // In render function: fn render cx: Cx void { const s = cx.state State ; cx.uniformList "my-list", &s.list state, .{ .fill width = true, .grow height = true, }, renderItem ; } fn renderItem index: u32, cx: Cx void { const s = cx.stateConst State ; const theme = cx.theme ; const is selected = if s.selected |sel| sel == index else false; // Color is available via: const Color = gooey.Color; const text color = if is selected Color.white else theme.text; cx.render ui.box .{ .fill width = true, .height = 32, .background = if is selected theme.primary else null, .hover background = theme.overlay, .on click handler = cx.updateWith index, State.selectItem , }, .{ ui.text "Item", .{ .color = text color } , } ; } Virtualized list supporting variable item heights. Heights are cached after rendering for efficient scroll calculations. Ideal for chat messages or expandable rows. The callback must return the rendered height. js const State = struct { list state: VirtualListState = VirtualListState.init 1000, 48.0 , // count, default height }; // In render function - callback returns item height: fn render cx: Cx void { const s = cx.state State ; cx.virtualList "chat-list", &s.list state, .{ .grow height = true }, renderMessage ; } fn renderMessage index: u32, cx: Cx f32 { const s = cx.stateConst State ; const msg = s.messages index ; const height: f32 = if msg.has image 120.0 else 48.0; cx.render ui.box .{ .fill width = true, .height = height, .on click handler = cx.updateWith index, State.selectMessage , }, .{ ui.text msg.text, .{} , } ; return height; // Return actual rendered height for caching } Instead of hardcoding heights or guessing character widths, use cx.measureText to get pixel-accurate dimensions from the platform text shaper CoreText/HarfBuzz/browser : js fn renderMessage index: u32, cx: Cx f32 { const s = cx.stateConst State ; const msg = s.messages index ; const padding: f32 = 32.0; const max bubble width: f32 = 400.0; // Measure with wrapping — uses the real shaper so kerning matches rendering const m = cx.measureText msg.text, .{ .max width = max bubble width, .font size = 15, // null = use the current font size } catch | | TextMeasurement{ .width = 0, .height = 48, .line count = 1 }; const height = m.height + padding; cx.render ui.box .{ .fill width = true, .height = height, }, .{ ui.text msg.text, .{ .size = 15, .wrap = .word } , } ; return height; } measureText returns a TextMeasurement with .width , .height , and .line count . Virtualized 2D table with both vertical and horizontal virtualization. Supports column resizing, sorting, and selection. Uses a callbacks struct for header and cell rendering. js const State = struct { table state: DataTableState = blk: { var t = DataTableState.init 10 000, 32.0 ; // row count, row height t.addColumn .{ .width px = 80, .sortable = true } catch unreachable; // ID t.addColumn .{ .width px = 200, .sortable = true } catch unreachable; // Name t.addColumn .{ .width px = 100 } catch unreachable; // Status break :blk t; }, pub fn onHeaderClick self: State, col: u32 void { = self.table state.toggleSort col ; // Re-sort your data based on table state.sort column and direction } pub fn onRowClick self: State, row: u32 void { self.table state.selection.row = row; } }; // In render function: fn render cx: Cx void { const s = cx.state State ; const theme = cx.theme ; cx.dataTable "my-table", &s.table state, .{ .fill width = true, .grow height = true, .row hover background = theme.overlay, .row selected background = theme.primary, }, .{ .render header = renderHeader, .render cell = renderCell, } ; } fn renderHeader col: u32, cx: Cx void { const s = cx.stateConst State ; const theme = cx.theme ; // Add sort indicator if this column is sorted const name = COLUMN NAMES col ; const label = if s.table state.sort column == col if s.table state.sort direction == .ascending name ++ " ▲" else name ++ " ▼" else name; cx.render ui.box .{ .fill width = true, .fill height = true, .on click handler = cx.updateWith col, State.onHeaderClick , }, .{ ui.text label, .{ .weight = .semibold, .color = theme.text } , } ; } fn renderCell row: u32, col: u32, cx: Cx void { const theme = cx.theme ; cx.render ui.box .{ .fill width = true, .fill height = true, .padding = .{ .symmetric = .{ .x = 8, .y = 0 } }, }, .{ switch col { 0 = ui.textFmt "{d}", .{row}, .{ .color = theme.text } , 1 = ui.text data row .name, .{ .color = theme.text } , 2 = ui.text data row .status, .{ .color = theme.text } , else = ui.text "—", .{} , }, } ; } Gooey provides utilities for form validation with touched-state tracking: js const validation = gooey.validation; // Single validators const err = validation.required value ; // Non-empty check const err = validation.email value ; // Email format const err = validation.minLength value, 8 ; // Minimum length const err = validation.maxLength value, 100 ; // Maximum length const err = validation.numeric value ; // Digits only const err = validation.alphanumeric value ; // Letters and numbers const err = validation.matches value, other ; // Values must match // Password strength const err = validation.hasUppercase value ; // At least one uppercase const err = validation.hasLowercase value ; // At least one lowercase const err = validation.hasDigit value ; // At least one number const err = validation.hasSpecialChar value ; // At least one special char // Chain multiple validators - returns first error or null const err = validation.all password, .{ validation.required, validation.minLengthValidator 8 , validation.hasUppercase, validation.hasDigit, } ; Create validators with custom messages for internationalization: js // Define a locale struct with custom validators const french = struct { pub const required = validation.requiredMsg "Ce champ est requis" ; pub const email = validation.emailMsg "Adresse e-mail invalide" ; pub const minLength8 = validation.minLengthMsg 8, "Au moins 8 caractères" ; pub const hasUppercase = validation.hasUppercaseMsg "Au moins une majuscule" ; }; // Use in validation - works with all combinator const err = validation.all value, .{ french.required, french.email, } ; // Available message factories: // validation.requiredMsg msg // validation.emailMsg msg // validation.minLengthMsg min, msg // validation.maxLengthMsg max, msg // validation.numericMsg msg // validation.alphanumericMsg msg // validation.hasUppercaseMsg msg // validation.hasLowercaseMsg msg // validation.hasDigitMsg msg // validation.hasSpecialCharMsg msg // validation.matchesMsg msg Use error codes when you need to programmatically handle errors e.g., focus first invalid field : js // Returns ?ErrorCode instead of ? const u8 const code = validation.requiredCode value ; if code == .required { cx.setFocus "username" ; // Focus first invalid field } // Available error codes: // .required, .min length, .max length, .invalid email, // .not numeric, .not alphanumeric, .mismatch, // .no uppercase, .no lowercase, .no digit, .no special char // Find first invalid field - call individual Code functions in sequence // there's no allCode combinator; this pattern keeps the API simple pub fn getFirstInvalidField s: const State ? const u8 { if validation.requiredCode s.username = null return "username"; if validation.emailCode s.email = null return "email"; if validation.minLengthCode s.password, 8 = null return "password"; return null; } Note:Unlike all for error messages, there's no allCode combinator. For multi-field validation with error codes, call individual Code functions in sequence as shown above. This keeps the API simple while covering the common "focus first invalid field" use case. When you need different messages for visual display vs screen readers: js // Structured result with separate messages const result = validation.requiredResult value, .{ .message = "Required", // Terse for visual display .accessible message = "The email field is required. Please enter your email address.", } ; if result |r| { r.code // ErrorCode for programmatic handling r.displayMessage // Message for visual display r.screenReaderMessage // Message for screen readers falls back to display } // Use with ValidatedTextInput for full a11y control gooey.ValidatedTextInput{ .id = "email", .error result = validation.requiredResult s.email, .{ .message = "Required", .accessible message = "The email address field is required", } , .show error = s.touched email, } All-in-one form field with label, input, error display, and help text: js const State = struct { email: const u8 = "", touched email: bool = false, pub fn validateEmail self: const State ? const u8 { return gooey.validation.all self.email, .{ gooey.validation.required, gooey.validation.email, } ; } pub fn onEmailBlur self: State void { self.touched email = true; } }; // In render: gooey.ValidatedTextInput{ .id = "email", .label = "Email Address", .required indicator = true, // Shows " " after label .placeholder = "you@example.com", .bind = &s.email, .error message = s.validateEmail , // Simple string error .show error = s.touched email, // Only show after interaction .help text = "We'll never share your email", .on blur handler = cx.update State.onEmailBlur , .width = 300, } // Or with structured result for different a11y messages: gooey.ValidatedTextInput{ .id = "email", .label = "Email Address", .error result = validation.emailResult s.email, .{ .message = "Invalid email", .accessible message = "Please enter a valid email address in the format name@example.com", } , .show error = s.touched email, } js // Track errors for multiple fields var errors = validation.FormErrors 4 .init ; errors.set 0, validation.required s.username ; errors.set 1, validation.email s.email ; errors.set 2, validation.minLength s.password, 8 ; errors.set 3, validation.matches s.confirm, s.password ; if errors.isValid { // Submit form } else { // errors.firstErrorIndex returns index of first invalid field } // Track touched state var touched = validation.TouchedFields 4 .init ; touched.touch 0 ; // Mark field 0 as touched if touched.isTouched 0 { ... } touched.touchAll ; // Mark all on submit touched.reset ; // Clear on form reset Run zig build run-form-validation for a complete example. Built-in animation support with easing functions: js // Simple animation runs once on mount const fade = cx.animate "fade-in", .{ .duration ms = 500 } ; // fade.progress goes 0.0 - 1.0 // Animation that restarts when a value changes const pulse = cx.animateOn "counter-pulse", s.count, .{ .duration ms = 200, .easing = Easing.easeOutBack, } ; // Continuous animation const spin = cx.animate "spinner", .{ .duration ms = 1000, .mode = .ping pong, // or .loop } ; // Use animation values cx.render ui.box .{ .background = Color.white.withAlpha fade.progress , .width = gooey.lerp 100.0, 150.0, pulse.progress , }, .{...} ; Available Easings: linear , easeIn , easeOut , easeInOut , easeOutBack , easeOutCubic , easeInOutCubic cx.changed detects when a value changes between frames — replacing the common pattern of module-level var last foo: ?T = null with manual diffing: // Invalidate caches when dependencies change if cx.changed "dark mode", s.dark mode or cx.changed "window width", size.width { s.invalidateCachedHeights ; } Semantics: First call for a given key → returns false no previous value Same value as last frame → returns false Different value → returns true and stores the new value Works with any value type: bool , f32 , i32 , enums, small structs. // Theme change if cx.changed "theme", s.theme { s.rebuildStyles ; } // Window resize triggers layout recalc const size = cx.windowSize ; if cx.changed "width", size.width { s.onResize size.width ; } // Enum state if cx.changed "view", s.current view { s.scrollToTop ; } Keys are comptime strings hashed to u32 same approach as the animation system . Up to 64 tracked values per app. Cross-platform file open/save dialogs via gooey.file dialog : js const file dialog = gooey.file dialog; // Open dialog if file dialog.promptForPaths allocator, .{ .files = true, .prompt = "Attach", .allowed extensions = &.{ "txt", "png", "pdf" }, } |result| { defer result.deinit ; for result.paths |path| { // ... } } // Save dialog if file dialog.promptForNewPath allocator, .{ .suggested name = "untitled.txt", .prompt = "Save", } |path| { defer allocator.free path ; // ... } macOS : NSOpenPanel / NSSavePanel blocking Linux : XDG Desktop Portal via D-Bus blocking WASM : Returns null — use gooey.platform.web.file dialog for the async callback API Use file dialog.supported comptime bool for feature detection. File dialogs block the thread, so call them from a deferred command deferred-commands to avoid deadlocks during event handling. Dynamic creation and deletion with automatic cleanup: js const Counter = struct { count: i32 = 0, pub fn increment self: Counter void { self.count += 1; } }; const AppState = struct { counters: 10 gooey.Entity Counter = ..., // Command method - needs Gooey access for entity operations pub fn addCounter self: AppState, g: gooey.Gooey void { const entity = g.createEntity Counter, .{ .count = 0 } catch return; self.counters self.counter count = entity; self.counter count += 1; } }; // In render - use entityCx for entity-scoped handlers var entity cx = cx.entityCx Counter, counter entity orelse return; Button{ .label = "+", .on click handler = entity cx.update Counter.increment } // Read entity data if cx.gooey .readEntity Counter, entity |data| { ui.textFmt "{d}", .{data.count}, .{} ; } Flexbox-inspired layout with shrink behavior and text wrapping: cx.render ui.box .{ .direction = .row, // or .column .gap = 16, .padding = .{ .all = 24 }, // or .symmetric, .each .alignment = .{ .main = .space between, .cross = .center }, .fill width = true, .grow = true, }, .{...} ; // Childless boxes — use ui.rect for dividers, spacers, colored blocks ui.rect .{ .width = 1, .height = 18, .background = t.border } // divider ui.rect .{ .grow = true } // spacer ui.rect .{ .width = 40, .height = 40, .background = color, .corner radius = 4 } // Shrink behavior - elements shrink when container is too small cx.render ui.box .{ .width = 150, .min width = 60 }, .{...} ; // Text wrapping ui.text "Long text...", .{ .wrap = .words } ; // .none, .words, .newlines Add custom post-processing shaders for visual effects. Shaders are cross-platform with MSL for macOS and WGSL for web: js // MSL shader macOS pub const plasma msl = \\void mainImage thread float4& fragColor, float2 fragCoord, \\ constant ShaderUniforms& uniforms, \\ texture2d