Perch
Building modsTransients

Transients

Popups that show for a moment and retract: when to make one, how they behave, and why never to re-create the system ones.

4 min readUpdated Sep 9, 2026

A transient is a widget whose job is to appear for a couple of seconds and leave. Perch’s own volume and brightness HUDs, “AirPods connected”, “Download finished” and the incoming-call banner are transients: ordinary widgets with urgency = urgency.transient and a surface.transient placement.

--!strict
local deploy = widget.new { id = "deploy", label = "Deploy finished", urgency = urgency.transient,
  fields = { ok = field.boolean(true), icon = field.string(""), title = field.string(""), sub = field.string("") } }
deploy:place(surface.transient, displays.hud, { timeout_ms = 3500 })
deploy:register()

How they behave

  • They lead alone. A present transient takes the whole notch; ambients and events hide until it retracts and come back by themselves.
  • They outrank everything, including the Priority Stack order, and cannot be dragged below the divider.
  • They show even while Perch is hiding for a game.
  • They retract by timeout. Each w:present(data) starts or restarts the placement’s timeout_ms; w:push updates the HUD without restarting it. With timeout_ms = 0 your mod calls w:retire() itself.
  • Envelope. The default size is wide (330 by 42); envelope = ui.envelope.tall gives the 265 by 60 call-style pill.

Four of Perch's own transients: volume, brightness, a Bluetooth device connected, an indeterminate download

Raising one

A transient is for a moment your mod knows about and Perch does not: a deploy that finished, a match that started, a build that broke.

--!strict
type deploy_t = { id: string, state: string, project: string, seconds: number }
local last_id: string = ""

local function check()
  perch.net.http.get("https://api.example.com/deploys/latest", function(res: http_response_t?, err: string?)
    local d: deploy_t? = if res and res.status == 200 then perch.json.decode(res.body) else nil
    if d and d.id ~= last_id then
      last_id = d.id
      deploy:present({
        ok = d.state == "success",
        icon = if d.state == "success" then "check_circle" else "warning_circle",
        title = if d.state == "success" then "Deploy finished" else "Deploy failed",
        sub = d.project .. " in " .. d.seconds .. "s",
      })
    end
  end)
end

The HUD display:

--!strict
-- displays.luau
export type fields_t = { ok: field_ref_t<boolean>, icon: field_ref_t<string>, title: field_ref_t<string>, sub: field_ref_t<string> }

local function hud(f: fields_t): node_t
  return ui.row { gap = ui.gap.s, align = ui.align.center, pad = { 0, 14, 0, 12 },
    ui.icon { glyph = f.icon, set = "duotone", size = ui.size.s, color = ui.color.live,
      style = { f.ok:is_false():set { color = ui.color.red } } },
    ui.text { text = f.title, role = ui.role.title, flex = 0 },
    ui.text { text = f.sub, role = ui.role.label, color = ui.color.half, flex = 1, align = ui.align.finish },
  }
end

return { hud = hud }

The custom deploy HUD rendered in a wide pill

Timeouts Perch uses

Kind of momenttimeout_ms
a value changing under the user’s hand (volume, brightness)2000
something connected or was set2600 to 3000
something finished3500
something that needs reading (a message, a battery warning)5000

Mod-owned retraction

With timeout_ms = 0 the HUD stays until you retire it. Use it when the moment has a real end you know about, and always retire it: a transient that never leaves holds the notch.

w:place(surface.transient, displays.hud, { timeout_ms = 0 })

local hide = timer.new(4000, { once = true })
hide.on_fire:connect(function() w:retire() end)

A transient can take input: the builtin incoming-call pill carries two buttons whose on_click functions accept or decline the ring and retire the pill.

A widget with another urgency may also place surface.transient; it then has a popup form that self-retracts but competes as its own urgency rather than leading alone. If something deserves to interrupt, make it a transient widget of its own.

esc
Type to search
navigate open