Perch
Building modsComponents

Components

The complete display vocabulary — all twenty components, their props, and a working snippet for each.

8 min readUpdated Jul 21, 2026

Twenty components. Containers hold children; the rest are leaves (with noted exceptions). Every component also takes every shared prop — layout, style, state, interaction, motion.

Containers

col / row

Flex column / row.

PropValues
gapxs s m l or px
alignstart center end baseline stretch
justifystart center end between evenly
{ type = "col", gap = 6, align = "center", children = { ... } }

box

A flex container (same gap/align/justify) meant to be painted — give it bg, radius, ring, border. Dial faces, presence dots, and panels are boxes.

{ type = "box", w = 44, h = 44, radius = 999, bg = "deep", border = "rim-strong" }

grid

CSS grid. cols (1–12), gap (column px), row_gap (px), align, justify. Fixed children or a repeater — bind (array) + item + max (default 64) — never both. The date calendar is 38 cells of one repeater.

{ type = "grid", cols = 7, row_gap = 1, bind = "cells", max = 44, item = {
    type = "text", bind = "d", size = 10, align = "center",
} }

frame

Absolute positioning. Every child must carry at = {x, y} or at = {x, y, w, h} in design px. A child with anchor = "center" treats its at as the center point. This is how numerals sit on a dial and badges sit on avatars.

{ type = "frame", w = 26, h = 26, children = {
    { type = "image", at = { 2, 2, 22, 22 }, radius = 999, bind = "avatar" },
    { type = "box", at = { 16, 16, 9, 9 }, radius = 999, bg = "live",
      ring = { 2, "island" } },
} }

spacer

An empty filler. Pair with flex (or a show token) to shove neighbours apart.

{ type = "spacer", flex = 1 }
content leaves

text

The workhorse. bind/value is the string; role picks a type-ramp class (title, value, h2, huge, label, body…); explicit metrics override the role.

PropValues
size / weight6–72 px / 100–900
tracking / line-0.1–0.3 em / 6–100 px
lines1–4 — clamps and ellipsizes
align / capsleft center right / uppercase
prefix / suffixglued literal text
mapa value → string lookup table
{ type = "text", bind = "temp", suffix = "°", size = 25, weight = 700 }
{ type = "text", bind = "k", map = { hdr = "—", day = "" } }

icon

A Phosphor glyph by name. bind/value = glyph; set = "duotone" draws the two-tone face (default is the line icon); size = xs s m l or px (6–72); color.

{ type = "icon", value = "gear-six", set = "duotone", size = "s", color = "half" }

image

A bound (or static) picture — album art, avatars, app tiles. bind/value = URL (https://, file://, data:); fit = cover | contain. It’s a container of one fallback child (a glyph or monogram) shown while imageless.

Loading is preload-and-retry: the old image holds until the new one decodes; retries run ~15 s with cache-busters. A declared bg gradient shows through while empty, and a viz with palette = "art" in the same widget wears the loaded image’s extracted palette.

{ type = "image", bind = "artUrl", fit = "cover", w = 100, h = 100, radius = 25,
  flip = "media-art", children = { note_glyph } }

shape

Retained vector art from sanitized SVG paths. vb = {w, h} (viewBox, required). Either static paths (1–128) or a named shapes set (≤16) picked by bind/value. Each path: d (path data, ≤2000 chars), fill, stroke, width (0–10), cap, join. fill = "current" follows the node’s color.

-- static glyph
{ type = "shape", vb = { 24, 24 }, w = 21, h = 21,
  paths = { { d = "M8 5v14l11-7z", fill = "current" } } }
-- data-picked from a set (the weather icon family)
{ type = "shape", bind = "icon", shapes = WX, vb = { 64, 44 }, w = 34, h = 23 }
data leaves

bar

Horizontal progress / seek. bind = fraction 0–1; negative = indeterminate shimmer.

PropMeaning
heightpx; also sets the radius
track / fillcolors
hot_atfraction threshold that flips the fill to its hot look
snapfast width transitions for drag-rate feeds (the volume HUD)
knoba draggable dot
seekdrag scrubs locally; ONE command fires on release with {frac}
{ type = "bar", bind = "posFrac", height = 5, track = "well", knob = true,
  seek = true, action = "seek:{frac}" }

ring

Circular progress, a container of centered children. style = conic (a conic sweep — the pomodoro) or stroke (an SVG dial — downloads). size/thickness (px), color (progress; defaults to the node’s color so style_when recolors it live), track, cap. bind = fraction 0–1; negative = indeterminate spin.

{ type = "ring", bind = "frac", style = "conic", size = 88, thickness = 5,
  color = "accent-warm", children = {
    { type = "text", bind = "mmss", size = 21, weight = 700 },
} }

ticker

Live time text that ticks view-side — the interval dies with the node, so there are no Lua wakeups.

Modebindformat
clock{ off = <UTC-offset-min>, h24 = <bool> } or a plain offseth:mm ap :ss
elapsedwall-epoch msm:ss
countdownwall-epoch msm:ss

Optional h24_bind re-reads the format preference live; size, weight, role, caps as on text.

{ type = "ticker", bind = "pill", mode = "clock", format = "h:mm", size = 17 }
{ type = "ticker", bind = "started", mode = "elapsed", size = 10 }  -- call timer

viz

An N-bar audio visualizer. bind = an array of levels 0–1.

PropDefaultMeaning
bars51–32
bar_w / gap4 / 1px
floor0.18minimum level
growscale or height
gradient2–4 fixed vertical stops
palette"art" — wears the nearest image’s extracted palette
{ type = "viz", bind = "bars", bars = 5, palette = "art", h = 20 }

spark

A mini line chart with a soft area fade (96×30). bind = a points array, or { points = {...}, up = <bool> } where up picks stroke vs stroke_down — the green/red financial defaults. area = false drops the fade.

{ type = "spark", bind = "series", stroke = "live", stroke_down = "red" }

gauge

The live seconds scale (the clock package’s second hand). One prop: look. Niche by design — reach for draw before inventing gauge uses.

list

A repeater over an array — the full mechanics live in the display system.

PropMeaning
bind / item / maxthe array / per-element tree / cap (default 16)
dir / gap / justify / alignlayout (row or col)
keyitem field enabling keyed in-place updates
overlap{ item_w, max_gap? } — spread-then-overlap facepiles with island separator rings
{ type = "list", bind = "faces", key = "name",
  overlap = { item_w = 35, max_gap = 10 }, justify = "center", item = face() }
interactive leaves

button

A clickable that fires its action string to your on_command. Content: icon (Phosphor name) and/or text, or custom glyph children (a shape).

{ type = "button", action = "startPause", w = 30, h = 30, bg = "recessed",
  children = { { type = "shape", vb = {24,24},
                 paths = { { d = "M8 5v14l11-7z", fill = "current" } } } } }

Any node can carry action directly — a whole card can be clickable without a button dressing.

field

An editable input. multiline (textarea vs input), placeholder, max (length). bind seeds it; edits return through on_widget_state (debounced 800 ms, flushed on blur). A data push never clobbers a focused edit.

{ type = "field", bind = "text", multiline = true, placeholder = "Notes…" }

draw

A metered immediate-mode canvas for the long tail no primitive covers — gauges, graphs, dials. bind = the trigger field; on_draw(host, d, w, h) = a Lua painter recording ops on host.g. Redraw is data-driven only — the painter runs when the bound field changes, never per frame, so idle cost stays structurally zero.

{ type = "draw", w = 64, h = 64, bind = "reading", on_draw = logic.draw_gauge }

When you want to build a thing, read the builtin that already does it — the recipe map points at the best worked example of each mechanic.

esc
Type to search
navigate open