Custom UI
July 26, 2026 · View on GitHub
Hollow exposes a small widget runtime. The same widget model powers the top bar, the bottom bar, sidebars, and overlays (modals, prompts, pickers, notifications).
For the API see hollow.ui.
For recipes see UI recipes.
Mental model
A widget is a table:
{
render = function(ctx) ... end, -- required
on_event = function(name, e) ... end, -- optional
on_key = function(key, mods) ... end, -- overlays only
on_mount = function() ... end, -- optional
on_unmount = function() ... end, -- optional
height = 24, -- bars
width = 200, -- sidebars / overlays
side = "left", -- sidebars
align = "top_right", -- overlays
backdrop = true, -- overlays
chrome = { bg = "#1f1f28", border = "#3a3a52" }, -- overlays
}
Sidebar and overlay render(ctx) functions return one explicit
hollow.ui.row or hollow.ui.column. Bars return their specialized item
lists. The runtime draws the widget, then delivers on_event callbacks
for clicks and lifecycle events.
ctx provides:
ctx.term.tab,ctx.term.pane,ctx.term.tabsctx.term.workspace,ctx.term.workspacesctx.size(window size in pixels)ctx.time.epoch_msandctx.time.iso
Node primitives
Four primitive nodes cover almost everything:
hollow.ui.span(text, style?) -- a styled run of text
hollow.ui.spacer() -- a flex spacer (pushes siblings apart)
hollow.ui.icon(name, style?) -- a font icon by name
hollow.ui.group(children, style?) -- nested children
A shorthand for inline colored text is hollow.ui.text(value, style?).
hollow.ui.row(children, opts?) lays out inline nodes and
hollow.ui.column(rows) lays out rows vertically.
A span style is a table:
{ fg = "#dcd7ba", bg = "#1f1f28", bold = true, italic = true, underline = true,
strikethrough = true, dim = true, id = "my-id", on_click = function() end }
id plus on_click makes a node clickable. The runtime delivers the
event as on_event("click", { id = "my-id", ... }).
The four surfaces
Top bar
The top bar is the always-on status strip above the panes.
The shipped base config configures it; you can either tweak via
hollow.ui.topbar.configure({...}) or replace it entirely with
hollow.ui.topbar.mount(widget).
hollow.ui.topbar.configure({
workspace = true, -- or a { format, style } table
tabs = true,
cwd = true,
key_legend = false,
time = { format = "%H:%M:%S" },
height = 24,
})
-- or full replacement:
hollow.ui.topbar.mount(hollow.ui.topbar.new({
render = function(ctx)
return {
hollow.ui.workspace.topbar_button({ text = " workspaces " }),
hollow.ui.spacer(),
hollow.ui.bar.time("%H:%M"),
}
end,
}))
Bar-level primitives live under hollow.ui.bar and can be reused by
other bars:
hollow.ui.bar.tabs()
hollow.ui.bar.workspace()
hollow.ui.bar.time("%H:%M")
hollow.ui.bar.key_legend()
hollow.ui.bar.custom({ id = "weather", render = function(ctx) ... end })
Bottom bar
A second status strip at the bottom of the window. Same widget model as the top bar.
hollow.ui.bottombar.mount(hollow.ui.bottombar.new({
height = 22,
render = function() return { hollow.ui.bar.time("%H:%M") } end,
}))
Sidebar
A side panel. Can be left or right, fixed width, and optionally reserves terminal space (shrinks the tiled layout) instead of drawing over it.
hollow.ui.sidebar.mount(hollow.ui.sidebar.new({
side = "right",
width = 32, -- in terminal columns
reserve = false, -- true: shrinks the tiled layout
hidden = false,
render = function(ctx)
return hollow.ui.column({
hollow.ui.row({ hollow.ui.text("title", { bold = true, fg = "#7e9cd8" }) }),
hollow.ui.row({ hollow.ui.text(ctx.term.pane.cwd or "", { fg = "#727169" }) }),
})
end,
}))
Overlays
A modal stack. Overlays receive on_key(key, mods) before normal
keymaps, so they can swallow keys.
hollow.ui.overlay.push(hollow.ui.overlay.new({
align = "center",
backdrop = { color = "#000000", alpha = 96 },
chrome = { bg = "#1f1f28", border = "#3a3a52", radius = 6 },
width = 60,
render = function(ctx) return hollow.ui.column({ ... }) end,
on_key = function(key, mods)
if key == "<Esc>" then
hollow.ui.overlay.pop()
return true
end
end,
}))
Overlays with buttons, text input, or list navigation use composition
helpers from the same hollow.ui namespace:
local m = hollow.ui.modal({
render = function(theme)
return hollow.ui.dialog({
title = "Pick one",
body = { hollow.ui.text("Choose an option:") },
footer = {
{ id = "choose", text = "Choose", kind = "primary", on_click = function()
choose(items[nav.index]); m.close()
end },
},
}, theme)
end,
keys = hollow.ui.keys(nav, {
escape = function() m.close() end,
enter = function() confirm(selected); m.close() end,
}),
width = 50,
})
Behaviors (hollow.ui.list_nav, hollow.ui.scroll_nav,
hollow.ui.text_input) encapsulate state and key handlers, while
hollow.ui.dialog produces a renderable column. See
hollow.ui for the full reference.
Built on top of overlays and the composition helpers:
hollow.ui.notify.show / .info / .warn / .error / .clearhollow.ui.input.open / .closehollow.ui.confirm.open / .closehollow.ui.select.open / .closehollow.ui.command_palette.open / .close
These accept align, backdrop, chrome, theme, width, and
height options. The notify family auto-dismisses after ttl
milliseconds.
Widget theming
Widgets read colors from the active theme.
theme.widgets.all is the shared palette for all built-in widgets; each
widget can also override per-kind (e.g. theme.widgets.select).
hollow.ui.select.open({
items = { "pwsh", "wsl" },
theme = {
panel_bg = "#1f1f28",
selected_bg = "#2d4f67",
fg = "#dcd7ba",
},
chrome = { bg = "#1f1f28", border = "#3a3a52", radius = 6 },
-- ...
})
Useful widget tokens: panel_bg, panel_border, divider, title,
fg, muted, input_bg, input_fg, selected_bg, selected_fg,
detail, notify_fg, radius, padding, margin, backdrop,
notify_levels (per-level accent colors).
Workspace switcher
The workspace switcher is a high-level widget built on
hollow.ui.select and hollow.workspace.
hollow.ui.workspace.configure({
prompt = "Workspaces",
sources = {
{ resolver = "local", roots = { "C:/code" } },
{ resolver = "wsl", domain = "UbuntuWSL", roots = { "/home/me/projects" } },
{ resolver = "ssh", domain = "tower", roots = { "/home/me/projects" } },
},
project_roots = { "C:/code" },
format_item = function(item)
return {
hollow.ui.span(item.is_active and "* " or " "),
hollow.ui.span(item.name, { bold = item.is_active }),
hollow.ui.span(item.cwd and (" " .. item.cwd) or "", { fg = "#727169" }),
}
end,
})
hollow.ui.workspace.open_switcher()
hollow.ui.workspace.create({ on_confirm = function(name) ... end })
hollow.ui.workspace.rename()
hollow.ui.workspace.close()
Discovery sources:
| Resolver | Behavior |
|---|---|
local | Scans roots with hollow.read_dir |
wsl | Lists directories through wsl.exe |
ssh | Lists directories through hollow.term.run_domain_process |
cwd_resolver = "wsl_unc" translates Windows UNC paths like
\\wsl$\Ubuntu\home\me\Projects back to Linux paths before launching.
See also
hollow.ui— full API- UI recipes — drop-in widgets and patterns
- Themes — palette and widget theme tokens
- Workspace switcher — picker details