Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

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
FunctionDescription
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):

ValueNameEffect
5LeadingChildren align to the leading (left) edge
9CenterXChildren centered horizontally
7WidthChildren stretch to fill the stack’s width

HStack alignment (cross-axis = vertical):

ValueNameEffect
3TopChildren align to the top
12CenterYChildren centered vertically
4BottomChildren 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
ValueNameBehavior
0FillDefault. First resizable child fills remaining space
1FillEquallyAll children get equal size
2FillProportionallyChildren sized proportionally to their intrinsic content
3EqualSpacingEqual gaps between children
4EqualCenteringEqual 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 width
  • widgetMatchParentHeight(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