Perch
Building modsYour first widget

Your first widget

Build a world clock — live data, a timer, settings, and a button — and learn the whole loop on the way.

5 min readUpdated Jul 21, 2026

We’ll build worldclock: a page widget showing a city’s local time with an analog face, a settings page, and a compact notch pill. It exercises the entire loop — declaration, displays, logic, data flow, settings — in under a hundred lines.

The package

  • worldclock/
    • mod.jsonthe manifest — pure data
    • main.luathe entry — wires displays to logic
    • displays.luathe visual trees
    • logic.luathe handlers

Only mod.json and the entry are contractual. Splitting the entry into displays.lua + logic.lua via require is convention — the loader runs one entry file (default main.lua) and expects it to return { displays = <table of trees>, on_* = <handlers> }.

1 — Declare it

{
  "id": "worldclock",
  "title": "World Clock",
  "version": "1.0",
  "api": 1,
  "scopes": ["timer"],
  "palette": { "brand": "#88c0d0" },
  "settings": [
    { "key": "zone", "label": "Time zone", "kind": "text", "default": "Asia/Tokyo" },
    { "key": "h24", "label": "24-hour", "kind": "toggle", "default": true }
  ],
  "widgets": [
    {
      "id": "worldclock", "label": "World clock", "urgency": "ambient",
      "contexts": {
        "expanded": { "display": "card" },
        "notch_secondary": { "display": "pill" }
      },
      "preview": { "city": "Tokyo", "time": "17:42", "sub": "UTC+9" }
    }
  ]
}

Reading it:

  • scopes is your grant list, shown verbatim on the Workshop page. We need timer for the tick; perch.time.* is pure and always granted.
  • palette declares up to 4 identity colors. Trees reference them as color = "@brand"; the active theme may remap them.
  • settings becomes a real settings-rail page, stored per-mod, live-applied through on_configure.
  • preview is demo data for the settings picker’s live miniature.

2 — The displays

return {
  -- the 370×100 expanded slot
  card = {
    type = "row", gap = 14, align = "center", pad = { 0, 16, 0, 16 },
    children = {
      { type = "draw", w = 64, h = 64, bind = "hands", on_draw = require("logic").draw_face },
      { type = "col", gap = 2, flex = 1, children = {
        { type = "text", bind = "city", role = "title" },
        { type = "text", bind = "time", size = 25, weight = 700, color = "@brand" },
        { type = "text", bind = "sub", size = 11, color = "tertiary" },
      } },
    },
  },
  -- the trailing notch pill
  pill = {
    type = "row", gap = 6, align = "center",
    children = {
      { type = "icon", value = "clock", size = "s", color = "half" },
      { type = "text", bind = "time", size = 13, weight = 700 },
    },
  },
}

Everything here is structure: which nodes exist, how they lay out, what field each one binds. There is no formatting logic, no conditionals beyond data, no DOM. If a value needs computing, Lua computes it and pushes the answer.

3 — The logic

local M = {}
local zone, h24 = "Asia/Tokyo", true

local function payload()
  local t = perch.time.now(zone)          -- {hour,min,sec,year,mon,day,dow,off}
  local city = zone:match("([^/]+)$"):gsub("_", " ")
  local off = t.off >= 0 and ("UTC+" .. t.off // 60) or ("UTC" .. -(-t.off // 60))
  return {
    city = city,
    time = perch.time.format(h24 and "%H:%M" or "%I:%M %p", zone),
    sub = off,
    hands = { h = t.hour % 12, m = t.min },
  }
end

function M.on_start(host)
  host.set_presence("worldclock", true, payload())
  host.timer("tick", 30 * 1000)           -- repeating; floored at 1000 ms
end

function M.on_timer(host)
  host.push_data("worldclock", payload())
end

function M.on_configure(host, s)
  zone = s.zone or zone
  h24 = s.h24
  host.push_data("worldclock", payload()) -- live-apply
end

-- Runs ONLY when `hands` changes — draw is data-driven, never per-frame.
function M.draw_face(host, d, w, h)
  local g = host.g
  g.circle(w / 2, h / 2, w / 2 - 2, { fill = "raised", stroke = "rim" })
  local ha = (d.hands.h + d.hands.m / 60) / 12 * 2 * math.pi
  local ma = d.hands.m / 60 * 2 * math.pi
  g.line(w / 2, h / 2, w / 2 + 14 * math.sin(ha), h / 2 - 14 * math.cos(ha),
         { stroke = "primary", width = 3, cap = "round" })
  g.line(w / 2, h / 2, w / 2 + 22 * math.sin(ma), h / 2 - 22 * math.cos(ma),
         { stroke = "@brand", width = 2, cap = "round" })
end

return M

The shape of every handler: receive host, compute view-ready values (finished strings, angles, booleans), push them. The perch global and the host argument are the same table — two spellings of one API.

4 — The entry

local displays = require("displays")
local logic = require("logic")

local package = { displays = displays }
for name, handler in pairs(logic) do
  if name:sub(1, 3) == "on_" then package[name] = handler end
end
return package

5 — Run it

PERCH_MOD_DEV=~/perch-dev perch

Place the widget on a page. Change the zone in its settings page and watch the card re-render instantly — on_configure fires, pushes, and only the changed fields repaint. That’s the whole loop.

What you now know

ConceptWhere it goes deeper
Manifest fields, palette, settingsThe package format
Contexts beyond expandedWidgets & contexts
The 20 display componentsComponents
Every perch.* functionLua API
esc
Type to search
navigate open