Build an interface
Notifications, dialogs, windows, Dashboard sections and settings panels, and every element you can put in them.
A plugin can show things in four places: a notification in the corner, a dialog over the app, a window of its own, and a box on the Dashboard (plus a settings panel on its card in the Plugins screen). The first two are one call. The last three are made of elements: you describe what to show with a few ui.* functions, and ForgeCast draws it with its own components.
You never write HTML or CSS, and a plugin can't add scripts. That is on purpose: what a plugin draws always looks like part of ForgeCast, in light and dark theme, and it can't do anything the app doesn't allow.
Notifications
ui.notify (needs ui.notify) shows a short message and returns at once. Give it a string, or a table:
ui.notify("Done")
ui.notify({
title = "New member",
message = chat.author.name,
level = "success", -- "info" (default), "success", "warning" or "error"
duration = 8000, -- milliseconds on screen (default 5000; 0 keeps it until dismissed)
actions = { -- up to three buttons
{ label = "Say thanks", on_click = function() print("clicked") end },
},
})A notification button's on_click works once.
Dialogs
ui.modal (needs ui.modal) opens a dialog over the app and calls you back when the person closes it:
ui.modal({
title = "Reset the counter?",
body = "This can't be undone.", -- text, or a whole interface (see below)
buttons = {
{ id = "cancel", label = "Cancel" },
{ id = "reset", label = "Reset", primary = true }, -- also: danger = true
},
on_result = function(result)
-- result.button the id of the button pressed (nil if it was closed another way)
-- result.dismissed true when it was closed without a button (Esc, a click outside)
-- result.values what the inputs inside the dialog held, by name
if result.button == "reset" then count = 0 end
end,
})Other options: width ("sm", "md", "lg"), and dismissable = false to force a button. ui.modal returns an id; ui.close(id) closes it from code.
Three shortcuts cover the common questions:
ui.alert("Saved.") -- a message and an OK button
ui.confirm("Delete it?", function(yes) if yes then delete() end end, { yes = "Delete", danger = true })
ui.prompt("Your name?", "Ann", function(text) -- text is nil when cancelled
if text then greet(text) end
end)A dialog's body can be a whole interface, with inputs. Whatever was typed arrives in result.values:
ui.modal({
title = "Add a word",
body = ui.column({ ui.input({ name = "word", placeholder = "a word to watch", autofocus = true }) }),
buttons = { { id = "cancel", label = "Cancel" }, { id = "add", label = "Add", primary = true } },
on_result = function(r)
if r.button == "add" then add_word(r.values.word) end
end,
})How an interface works
An interface is a tree of elements. Each element is a call like ui.column{ … }. Inside the braces, the properties are name = value, and the children are just listed:
ui.column({
gap = 8, -- a property
ui.heading("Today"), -- a child
ui.row({ gap = 6, ui.text("Messages:"), ui.badge({ text = "42", tone = "accent" }) }), -- a child with children
})To show it, give the tree to something that draws: window:render(tree), section:render(tree), a settings panel's :render(tree), or a dialog's body. Each call replaces what was there. There is no "update one label": you keep your data in Lua variables, and call your draw function again when it changes.
local count = 0
local section = dashboard.section({ id = "counter", title = "Counter" })
local function draw()
section:render(ui.row({
gap = 8,
ui.heading(tostring(count)),
ui.button("+1", function() count = count + 1; draw() end), -- a handler: runs when it is clicked
}))
end
draw()- Handlers.
on_click,on_changeandon_submitare functions. They get one argument, the eventev:ev.valueis the new value of an input, switch, select or slider, andev.nameis the element'sname. - Forms. Give inputs a
name. Every handler in the same interface getsev.values, a table with the current value of every named input. So a "Save" button doesn't need to know about the inputs; it readsev.values.words. - Nothing to draw.
falseandnilin a list of children are skipped, socond and ui.text("x") or falsedraws it only whencondis true. - Inputs keep what was typed when you redraw, as long as the
valueyou give them is the same. Change thevalue, or give the element a differentkey, and it starts fresh. The usual way to empty a box after "Add" is to bump akey:
ui.input({ name = "username", key = input_key }) -- input_key = input_key + 1 before drawing: the box is empty again- Redraw when something changes, not on every tick. A busy chat can send dozens of lines a second. If you draw from a chat handler, collect changes and draw from a timer (
timer.every(1000, draw)) instead. - Limits. One interface can hold up to 2,000 elements, 24 levels deep, about 400 KB of data in all; text up to 20,000 characters per piece. Go over and the call fails with a message that says which.
The elements
Every element is a function in ui. Properties not listed for an element are ignored. The reference has one table with every property.
Layout
| Element | What it does | Properties |
|---|---|---|
ui.column{…} | Children top to bottom. | gap, padding, align, justify, grow |
ui.row{…} | Children left to right. | gap, padding, align, justify, wrap, grow |
ui.card{…} | A box, with an optional title. | title, description, gap, grow |
ui.scroll{…} | A scrolling area. | max_height, gap |
ui.divider() | A thin line. | |
ui.spacer() | Empty space that grows: pushes what follows to the end of a row or column. | |
ui.list{…} | Children, each in its own bordered row. | empty (what to say when there are no children), gap |
align is "start", "center", "end" or "stretch"; justify is "start", "center", "end" or "between". gap and padding are pixels.
ui.card({
title = "Chat right now",
description = "last five minutes",
ui.row({ justify = "between", align = "center", ui.text("Messages"), ui.badge({ text = "128" }) }),
ui.divider(),
ui.row({ ui.text("Members"), ui.spacer(), ui.text({ text = "31", bold = true }) }),
})Text and pictures
| Element | What it does | Properties |
|---|---|---|
ui.text("hi") or ui.text{…} | A line or paragraph of text. | text, size (xs sm md lg), muted, bold, mono, color |
ui.heading("Title", {level = 1}) | A heading. | text, level (1 to 3) |
ui.badge{…} | A small coloured label. | text, tone (neutral success warning error info accent) |
ui.code{…} | A block of fixed-width text. | text |
ui.link("Docs", url) | A link that opens in the browser. https only. | label, url |
ui.image{…} | A picture from an https address. | src, alt, width, height, rounded |
ui.progress{…} | A bar. | value (0 to 1), label, tone |
color on text is a name (success, warning, error, info, accent, muted) or a "#rrggbb" value.
Buttons and inputs
| Element | What it does | Properties |
|---|---|---|
ui.button("Add", fn) or ui.button{…} | A button. | label, on_click, variant (primary outline ghost danger), icon, disabled |
ui.input{…} | A text box. | name, label, placeholder, value, on_change, on_submit (Enter), autofocus, disabled, grow |
ui.textarea{…} | A box for several lines. | as input, plus rows |
ui.checkbox{…} | A tick box. | name, label, checked, on_change, disabled |
ui.switch{…} | An on/off switch. | as checkbox |
ui.select{…} | A drop-down. | name, label, options, value, on_change, disabled |
ui.slider{…} | A slider. | name, label, min, max, step, value, on_change |
Button icons: plus, trash, check, x, refresh, play, copy, dice, external, bell, settings, star, user, send. options for a select are strings, or { value = "wild", label = "Wild!" }.
A small form that reads everything at once:
local box = dashboard.section({ id = "shoutout", title = "Shout-out" })
box:render(ui.column({
gap = 8,
ui.input({ name = "who", label = "Who", placeholder = "@someone" }),
ui.select({ name = "style", label = "Style", value = "loud", options = { "quiet", "loud", { value = "wild", label = "Wild!" } } }),
ui.slider({ name = "times", label = "How many times", min = 1, max = 5, value = 1 }),
ui.checkbox({ name = "pin", label = "Keep it on screen" }),
ui.button({
label = "Send",
variant = "primary",
icon = "send",
on_click = function(ev)
local v = ev.values
ui.notify({ title = "Shout-out to " .. (v.who or "nobody"), message = v.style .. " x" .. v.times .. (v.pin and ", pinned" or "") })
end,
}),
}))Data
| Element | What it does | Properties |
|---|---|---|
ui.table{…} | A table. | columns, rows, empty |
ui.chat{…} | Chat lines drawn like the Dashboard's chat. | messages, compact, max_height, empty |
columns is a list of names ({ "Name", "Count" }) or { key = "n", label = "Name" }. rows is a list of rows, each a list of cells ({ "Ann", 3 }) or, when the columns have keys, a table keyed by column ({ n = "Ann", count = 3 }). A table shows up to 500 rows.
ui.table({
columns = { "Chatter", "Messages" },
rows = { { "@ann", 12 }, { "@bob", 7 } },
empty = "Nobody has chatted yet.",
})
ui.chat({ messages = chats.get({ limit = 20 }), compact = true, max_height = 360 })Where interfaces go
Windows
window.create (needs ui.window) opens a window of its own, which can sit next to OBS or on another monitor:
local w = window.create({
key = "stats", -- a stable name: asking again for an open key just brings it forward
title = "Stream stats",
width = 320, height = 240, -- default 420 x 520
min_width = 240,
resizable = true,
always_on_top = true,
content = ui.text("loading…"), -- the first thing it shows (optional)
on_close = function() print("the window was closed") end,
})
w:render(ui.column({ ui.heading("Viewers: 120") })) -- draw again
w:set_title("Stream stats (live)")
w:focus()
w:close()A plugin can have 6 windows open at once. key is made of lowercase letters, digits, hyphens and underscores (30 characters at most). When the person closes the window, on_close runs and the place is free again. A plugin window has no access to the network or the account: it only draws what the plugin describes.
Dashboard sections
dashboard.section (needs ui.dashboard) adds a box to the Dashboard:
local box = dashboard.section({ id = "my-box", title = "My box", content = ui.text("hello") })
box:render(ui.text("changed")) -- the title stays
box:remove() -- take it off the DashboardGive it a stable id (lowercase letters, digits, hyphens and underscores, 30 characters at most) so it keeps its place after a reload. A plugin may add 10 sections.
The plugin's settings panel
plugin.settings (needs ui.dashboard) shows an interface on the plugin's own card in the Plugins screen, which is where people expect to find options:
local settings = plugin.settings({ title = "Options" })
settings:render(ui.column({
ui.switch({ name = "sound", label = "Play a sound", checked = true }),
ui.button({ label = "Save", on_click = function(ev) storage.set("sound", ev.values.sound) end }),
}))The Keyword alerts example is a complete plugin built around one.
Design tips
- Draw from one function.
local function draw() … end, called once at the start and again after any change, keeps the screen and your data in step. - Name your inputs, and save with a button. It is clearer than reacting to every keystroke.
- Say what's empty.
ui.listandui.tabletakeempty = "…"; use it. - Keep text short. Windows and sections are small. Use
ui.scroll{ max_height = … }around anything that can grow. - Use
mutedandsize = "sm"for captions, andbadge/progresstones to show state. They follow the app's theme.

