Layout
Perry provides layout containers that arrange child widgets using the
platform’s native layout system. Every snippet below is excerpted from
docs/examples/ui/layout/snippets.ts —
CI compiles and runs it on every PR.
Layout helpers are free functions: widgetAddChild(parent, child),
stackSetAlignment(stack, value), widgetSetEdgeInsets(w, top, left, bottom, right), etc. Stack constructors take a numeric spacing followed by a child
array; everything else (alignment, distribution, padding, sizing) is applied
post-construction via the free functions on the widget handle.
VStack
Arranges children vertically (top to bottom).
const stack = VStack(16, [
Text("First"),
Text("Second"),
Text("Third"),
])
VStack(spacing, children) — the first argument is the gap in points between
children.
HStack
Arranges children horizontally (left to right).
const row = HStack(8, [
Button("Cancel", noop),
Spacer(),
Button("OK", noop),
])
ZStack
Layers children on top of each other (back to front). ZStack() takes no
constructor children — populate it with widgetAddChild:
const layered = ZStack()
widgetAddChild(layered, ImageFile("background.png"))
widgetAddChild(layered, Text("Overlay text"))
ScrollView
A scrollable container. Built empty, then filled via scrollviewSetChild:
// ScrollView() takes no args; populate it with `scrollviewSetChild`.
const sv = ScrollView()
const inner = VStack(8, [Text("a"), Text("b"), Text("c")])
scrollviewSetChild(sv, inner)
LazyVStack
A vertically scrolling list that lazily renders items. More efficient than
ScrollView + VStack for thousands of rows — on macOS this is backed by
NSTableView so only rows in the visible rect are realized.
// `render(index)` is invoked lazily — only rows in the visible rect are realized.
const lazy = LazyVStack(1000, (index: number) => Text(`Row ${index}`))
When the underlying data changes, call lazyvstackUpdate(handle, newCount) to
refresh. Override the default 44pt row height with lazyvstackSetRowHeight.
NavStack
A navigation container that supports push/pop navigation. Push a new view
with navstackPush(stack, view, title); pop with navstackPop(stack):
const home = VStack(16, [
Text("Home Screen"),
Button("Go to Details", () => {
navstackPush(nav, Text("Details!"), "Details")
}),
])
const nav = NavStack()
widgetAddChild(nav, home)
Spacer
A flexible space that expands to fill available room.
const toolbar = HStack(8, [
Text("Left"),
Spacer(),
Text("Right"),
])
Use Spacer() inside HStack or VStack to push widgets apart.
Divider
A visual separator line.
const sections = VStack(12, [
Text("Section 1"),
Divider(),
Text("Section 2"),
])
Nesting Layouts
Layouts can be nested freely. This complete example is verified by CI:
// demonstrates: nested VStack/HStack + Spacer + Divider
// docs: docs/src/ui/layout.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm
import { App, VStack, HStack, Text, Button, Spacer, Divider } from "perry/ui"
App({
title: "Layout Example",
width: 800,
height: 600,
body: VStack(16, [
// Header
HStack(8, [
Text("My App"),
Spacer(),
Button("Settings", () => {}),
]),
Divider(),
// Content
VStack(12, [
Text("Welcome!"),
HStack(8, [
Button("Action 1", () => {}),
Button("Action 2", () => {}),
]),
]),
Spacer(),
// Footer
Text("v1.0.0"),
]),
})
Child Management
Containers support dynamic child management via free functions:
const list = VStack(16, [])
widgetAddChild(list, Text("appended")) // append
widgetAddChildAt(list, Text("prepended"), 0) // insert at index
widgetReorderChild(list, 1, 0) // move from→to
const removeMe = Text("temporary")
widgetAddChild(list, removeMe)
widgetRemoveChild(list, removeMe) // remove
widgetClearChildren(list) // remove all
| Function | Description |
|---|---|
widgetAddChild(parent, child) | Append a child widget |
widgetAddChildAt(parent, child, index) | Insert a child at a specific position |
widgetRemoveChild(parent, child) | Remove a specific child |
widgetReorderChild(widget, fromIndex, toIndex) | Move a child to a new position |
widgetClearChildren(widget) | Remove all children |
Stack Alignment
Control how children are aligned within a stack using stackSetAlignment:
const centered = VStack(16, [
Text("Centered"),
Text("Content"),
])
stackSetAlignment(centered, 9) // CenterX
VStack alignment (cross-axis = horizontal):
| Value | Name | Effect |
|---|---|---|
| 5 | Leading | Children align to the leading (left) edge |
| 9 | CenterX | Children centered horizontally |
| 7 | Width | Children stretch to fill the stack’s width |
HStack alignment (cross-axis = vertical):
| Value | Name | Effect |
|---|---|---|
| 3 | Top | Children align to the top |
| 12 | CenterY | Children centered vertically |
| 4 | Bottom | Children align to the bottom |
Stack Distribution
Control how children share space within a stack using stackSetDistribution:
const buttons = HStack(8, [
Button("Cancel", noop),
Button("OK", noop),
])
stackSetDistribution(buttons, 1) // FillEqually — both buttons get equal width
| Value | Name | Behavior |
|---|---|---|
| 0 | Fill | Default. First resizable child fills remaining space |
| 1 | FillEqually | All children get equal size |
| 2 | FillProportionally | Children sized proportionally to their intrinsic content |
| 3 | EqualSpacing | Equal gaps between children |
| 4 | EqualCentering | Equal distance between child centers |
Fill Parent
Pin a child’s edges to its parent container:
const banner = Text("Full width banner")
widgetMatchParentWidth(banner)
const banneredPage = VStack(16, [banner, Text("Normal width")])
widgetMatchParentWidth(widget)— stretch to fill parent’s widthwidgetMatchParentHeight(widget)— stretch to fill parent’s height
Content Hugging
Control whether a widget resists being stretched beyond its intrinsic size:
const tight = Text("I stay small")
widgetSetHugging(tight, 750) // High priority — resist stretching
const stretchy = Text("I stretch")
widgetSetHugging(stretchy, 1) // Low priority — stretch to fill
- High priority (250–750+): widget resists stretching, stays at its natural size
- Low priority (1–249): widget stretches to fill available space
Overlay Positioning
For absolute positioning, add overlay children to any container:
// Overlay parent must be a ZStack — macOS NSView allows `addSubview` on
// any view, but GTK4 can only float children above siblings inside
// `gtk::Overlay` (which is what ZStack is backed by).
const container = ZStack()
widgetAddChild(container, VStack(16, [Text("Main content")])) // main child
const badge = Text("3")
setCornerRadius(badge, 10)
widgetSetBackgroundColor(badge, 1.0, 0.231, 0.188, 1.0) // RGBA red
widgetAddOverlay(container, badge)
widgetSetOverlayFrame(badge, 280, 10, 20, 20) // x, y, width, height
Overlay children are positioned absolutely relative to their parent — similar
to CSS position: absolute.
Split Views
Create resizable split panes for sidebar layouts:
const split = SplitView()
const sidebar = VStack(8, [Text("Navigation"), Text("Item 1"), Text("Item 2")])
const content = VStack(16, [Text("Main Content")])
splitViewAddChild(split, sidebar)
splitViewAddChild(split, content)
The user can drag the divider to resize panes. On macOS this maps to
NSSplitView.
Stacks with Built-in Padding
Create a stack with padding in a single call. The order is top, left, bottom, right (CSS-shorthand-style), not top/right/bottom/left:
// VStackWithInsets(spacing, top, left, bottom, right) — note: order is
// top/left/bottom/right (CSS-style), not top/right/bottom/left.
const card = VStackWithInsets(12, 16, 16, 16, 16)
widgetAddChild(card, Text("Padded content"))
widgetAddChild(card, Text("More content"))
HStackWithInsets(spacing, top, left, bottom, right) is the horizontal
counterpart. Equivalent to creating a stack and then calling
widgetSetEdgeInsets, but more concise. Children are added via
widgetAddChild rather than the constructor array.
Detaching Hidden Views
By default, hidden children still occupy space in a stack. To collapse them:
const collapsible = VStack(8, [Text("Always visible"), Text("Sometimes hidden")])
stackSetDetachesHidden(collapsible, 1) // Hidden children leave no gap
// You can then toggle a child:
const sometimesHidden = Text("toggle me")
widgetSetHidden(sometimesHidden, 1) // 1 = hidden, 0 = visible
Common Layout Patterns
Centered content
const page = VStack(16, [Text("Title"), Text("Subtitle")])
stackSetAlignment(page, 9) // CenterX
Search row that fills the width
const searchInput = TextField("Search...", (v: string) => search.set(v))
widgetMatchParentWidth(searchInput)
const results = VStack(8, [])
const searchPage = VStack(12, [searchInput, results])
Floating badge / overlay
// Wrap the icon in a ZStack so the badge can float above it on every
const icon = ZStack()
widgetAddChild(icon, ImageSymbol("bell"))
const dotBadge = Text("3")
widgetAddOverlay(icon, dotBadge)
widgetSetOverlayFrame(dotBadge, 20, -5, 16, 16)
Toolbar with spacers
const titleBar = HStack(8, [
Button("Back", noop),
Spacer(),
Text("Page Title"),
Spacer(),
Button("Settings", noop),
])
Next Steps
- Styling — Colors, padding, sizing
- Widgets — All available widgets
- State Management — Dynamic UI with state