| icon | lucide/boxes |
|---|
Tip
See the minimal text/button loop in examples/basic, then the
broader input, meter, semantic-mode, and scroll-view composition in
examples/workbench. examples/themes shows
ui.tabs in a conventional horizontal content region.
Glyph components return virtual nodes. Components are plain Lua functions; there is no class system.
Component constructors shallow-copy the props table before applying defaults.
Reusing or later changing a caller-owned props table therefore does not change
an existing node's top-level props. Nested values such as style tables are
shared rather than deep-copied; replace or copy those explicitly when they need
independent mutation.
ui.text("Hello", {
wrap = true,
width = 240,
textStyle = "paragraph",
style = { color = { 1, 1, 1, 1 } },
})Use wrap = true with a known width for text that may overflow.
Use textStyle to select a theme typography preset such as h1, h2,
paragraph, caption, or code. Convenience helpers set that prop for you:
ui.h1("Mission Briefing")
ui.h2("Objectives")
ui.p("Hold the zone until extraction.")
ui.caption("Autosaved 4 seconds ago")Rich/game text is opt-in with ui.richText, which uses the configured SYSL-Text
backend when available:
ui.richText("Status: [color=#7cffae]online[/color] [font=mono]stable[/font]", {
wrap = true,
width = 320,
height = 64,
textVerticalAlign = "center",
})ui.richText(value, props) is sugar for ui.text(value, { format = "sysl" }).
On a plain ui.text, rich = true is the same shorthand (format = "sysl"); an
explicit format always wins.
textVerticalAlign = "top" | "center" | "bottom" offsets plain or SYSL-backed
text inside an explicit text node height. It is visual-only and does not change
layout, hit testing, or focus geometry.
Configure the backend with an app-provided SYSL module:
ui.richTextBackend.configure({
sysl = require("slog-text"),
defaults = { font = love.graphics.getFont() },
configure = function(Text)
Text.configure.font_table({ mono = monoFont })
end,
})Glyph disables SYSL function commands after configuration by default. Apps that
intentionally want scripting tags should own that risk in app code.
The typography example uses a development copy from dev/vendor; app code should
provide or install its own SYSL module.
For localized text, use ui.textKey after configuring ui.i18n:
ui.textKey("hud.ready", {
textFallback = "Ready",
})Rich localized text uses ui.richTextKey.
ui.box(props, children) is a visual/container primitive. It does not lay out children unless you provide a layout mode with display, or use ui.row, ui.column, or ui.stack.
ui.box({
width = 200,
height = 80,
style = { background = { 0.1, 0.1, 0.12, 1 } },
})ui.image(props) draws a Love2D image/canvas-like object that your app has
already loaded. Glyph owns layout, fit, tint, opacity, clipping, and stencil
integration; asset loading stays in your game.
local portrait = love.graphics.newImage("assets/portrait.png")
ui.image({
source = portrait,
width = 120,
height = 80,
fit = "cover", -- "contain" | "cover" | "stretch" | "none"
align = "center",
valign = "center",
tint = { 1, 1, 1, 1 },
opacity = 1,
filter = "nearest",
clip = { kind = "circle" },
interactive = false,
})filter may be "linear" or "nearest" for both min/mag filtering, or a
table such as { min = "nearest", mag = "linear", anisotropy = 2 }. Glyph
applies image filters only for the draw and restores the image's previous
filter afterward.
Use quad for atlas cells:
ui.image({
source = atlas,
quad = itemQuad,
width = 32,
height = 32,
fit = "contain",
})Use ui.spriteSheet when the atlas is a uniform grid:
local sheet = ui.spriteSheet(atlas, {
frameWidth = 16,
frameHeight = 24,
})
ui.image({
source = atlas,
quad = sheet:quad(12),
width = 32,
height = 48,
fit = "contain",
})For animated sprite-backed UI, pass anim8 explicitly per sheet or configure it
once through ui.spriteSheetBackend:
local sheet = ui.spriteSheet(atlas, {
frameWidth = 16,
frameHeight = 24,
anim8 = anim8,
})
local glow = sheet:animation({ "1-4", 1 }, 0.12)
glow:update(dt)
ui.image({
source = atlas,
quad = sheet:currentQuad(glow),
width = 32,
height = 48,
})If no explicit size is provided, image nodes measure from the quad viewport or
from source:getWidth() / source:getHeight(). Missing sources draw nothing
and measure as explicit size or 0x0.
ui.path(props) draws a Glyph-native vector path. It accepts either normalized
Lua commands or SVG path d data for common path commands. This is SVG path data
only, not a full SVG document renderer.
ui.path({
d = "M10 70 C40 10 90 110 130 35 Q160 10 180 70",
width = 220,
height = 120,
fit = "contain",
stroke = { 0.1, 0.9, 0.75, 1 },
strokeWidth = 4,
progress = charge, -- 0..1 stroke reveal
})Lua path commands use normalized command arrays:
local badge = {
{ "M", 8, 40 },
{ "L", 52, 8 },
{ "L", 96, 40 },
{ "L", 76, 92 },
{ "L", 28, 92 },
{ "Z" },
}
ui.path({
path = badge,
width = 120,
height = 120,
mode = "both",
fill = { 0.2, 0.5, 1, 0.18 },
stroke = { 0.55, 0.8, 1, 1 },
strokeWidth = 3,
})Supported SVG commands are M/m, L/l, H/h, V/v, C/c, Q/q, and
Z/z. Arcs, gradients, CSS styling, masks, transforms, holes, and winding rules
are out of scope for v1.
Filled paths target simple closed single-contour shapes. Concave contours are triangulated with Love2D's math backend when available, so stars and chevrons fill correctly; self-intersecting outlines and holes are still app-owned.
Paths can morph between compatible command sequences, or resample both outlines when the shapes differ:
ui.path({
d = "M10 10 L90 10 L90 90 L10 90 Z",
morphTo = "M50 4 L96 50 L50 96 L4 50 Z",
morph = pulse,
morphMode = "resample",
mode = "both",
fill = { 1, 0.7, 0.18, 0.2 },
stroke = { 1, 0.7, 0.18, 1 },
})ui.path.parse(d), ui.path.bounds(path), ui.path.flatten(path, opts), and
ui.path.length(path, opts) expose the same parser and geometry helpers for app
code.
Use ui.row and ui.column for normal flex-style flow.
ui.row({ gap = 8, width = "100%" }, {
ui.input({ flex = 1, value = filter, onChange = setFilter }),
ui.button({ label = "Clear", onClick = clearFilter }),
})Use ui.grid for uniform repeated cells such as inventory slots, cards, menu
buttons, and skill nodes.
ui.grid({ columns = 4, cellWidth = 72, cellHeight = 72, gap = 8 }, slotNodes)Responsive grids use minCellWidth and optional maxColumns:
ui.grid({ width = "100%", minCellWidth = 140, maxColumns = 5, gap = 10 }, cards)Use ui.grid.pointToCell(bounds, gridProps, x, y) with onLayout bounds when
drag/drop or pointer selection needs a row-major cell index.
Use ui.stack for layered UI.
ui.stack({ width = "100%", height = "100%" }, {
ui.box({ position = "absolute", inset = 0, interactive = false, draw = drawBackground }),
ui.column({ position = "absolute", top = 24, left = 24 }, {
ui.text("HUD"),
}),
})Later children draw above earlier children unless zIndex changes the order.
Use ui.portal for floating overlays that should escape later sibling branches
inside the current render root.
ui.portal({
left = pointerX - 32,
top = pointerY - 32,
width = 64,
height = 64,
zIndex = 500,
interactive = false,
}, {
ui.image({ source = atlas, quad = potionQuad, fit = "contain" }),
})ui.portal defaults to position = "absolute", zScope = "root", and stack
layout. It is useful for drag previews, tooltips, menus, and HUD callouts, but
it does not create a scene or modal layer.
ui.button({
label = "Run",
onClick = run,
style = {
background = { 0.1, 0.5, 0.9, 1 },
color = { 1, 1, 1, 1 },
hover = { background = { 0.15, 0.6, 1, 1 } },
},
})Buttons are focusable by default.
Note
The default theme gives focused buttons a two-pixel contrasting border. Primary buttons use a dark focus border against the amber surface. The primary palette keeps normal-size text at or above a 4.5:1 contrast ratio in its idle, hover, pressed, and focused states.
Buttons can resolve labels from i18n keys:
ui.button({ labelKey = "actions.confirm", onClick = confirm })Buttons and other nodes can run triggerable feedback sequences:
ui.button({
label = "Launch",
feedback = {
press = "button.squash",
release = "button.release",
activate = "button.pop",
},
})See Feedback for sequence steps and app-owned FX events.
ui.input({
value = query,
placeholder = "Filter logs...",
onChange = setQuery,
flex = 1,
})Inputs are controlled: keep the value in state and update it through onChange.
Use placeholderKey for localized placeholder text.
Pointer presses place the cursor at the nearest glyph boundary. Left, Right,
Backspace, and Delete preserve UTF-8 codepoint boundaries, and consecutive
events are applied to the pending controlled value even when they arrive before
the next render. If an app replaces the value with shorter text, Glyph clamps
the cursor to the new value. Set padding to adjust both text and cursor insets.
ui.meter(props) draws generic value displays such as health, mana, cooldown,
durability, progress, or debug telemetry. It is intentionally not a game-specific
healthBar component.
ui.meter({
value = hp,
min = 0,
max = maxHp,
animate = { duration = 0.2, ease = "quadout" },
width = 180,
height = 14,
shape = { kind = "skew", skew = 12 },
trackStyle = { background = { 0, 0, 0, 0.35 } },
fillStyle = { background = { 0.1, 0.9, 0.55, 1 } },
})Meters support:
kind = "linear" | "radial" | "arc"direction = "right" | "left" | "up" | "down"for linear metersshapefor rectangular, skewed, polygon, circle, ellipse, or blob fillssegments,gap,thickness,startAngle, andendAnglestyle.backgroundandstyle.borderColorfor the track, with clipped fills that stay inside the track shape- radial and arc meters draw open arcs and honor
fillStyle.backgroundorfillStyle.color label, children overlays,trackStyle,fillStyle,overfillStyle, andbackgroundStylelabelKey,labelParams, andlabelCacheKeyfor localized labelsanimate = trueor an animation table for opt-in, interruptible value interpolation. Tables supportduration,ease,initial,initialValue, andinitialDuration. The default duration is0.2seconds withease = "quadout"; initial values do not animate unlessinitial = true.
Meter animation changes only the drawn fill and function-based label. Layout,
hit testing, and accessibility continue to use the current value. Bind
animate to the app's motion preference when players can reduce motion.
Custom meter drawing reads the same interpolated number from
ctx.visualValue:
ui.meter({
value = charge,
max = 100,
animate = { duration = 0.24, ease = "expoout", initial = true },
draw = function(_, x, y, width, height, _, _, ctx)
local ratio = ctx.visualValue / ctx.props.max
ctx:rect("fill", x, y, width * ratio, height)
end,
})Radial and arc meters use the same value and animation options:
ui.meter({
kind = "arc",
value = charge,
max = 100,
width = 72,
height = 72,
thickness = 8,
startAngle = math.rad(135),
endAngle = math.rad(405),
fillStyle = { color = { 1, 0.8, 0.2, 1 } },
})ui.scrollView({ width = "100%", height = 300 }, rows)Scroll views clamp scrolling to content bounds and support optional scroll indicators. When content overflows, the indicator track is clickable and the thumb is draggable; the thumb uses a small visual press/release animation without changing layout or hit-test geometry.
Wheel scrolling defaults to 24 pixels per wheel unit. Tune the feel per scroll
view with scrollPixelsPerStep for the base distance and scrollSpeed for a
multiplier:
ui.scrollView({
width = "100%",
height = 300,
scrollPixelsPerStep = 16,
scrollSpeed = 1.5,
}, rows)scrollSensitivity is accepted as an alias for scrollSpeed when that term
better matches the setting exposed by your game or tool.
Use virtualList when a list may contain many rows but each row has a fixed
height. It renders only the visible range plus an overscan buffer, while spacer
nodes preserve the full scroll height and scrollbar behavior.
Use a numeric height when possible. If the surrounding layout is flexible,
provide visibleCount so Glyph can compute the mounted range before layout.
ui.virtualList({
key = "combat-log",
width = "100%",
height = 320,
itemCount = #events,
itemHeight = 28,
visibleCount = 12,
overscan = 4,
itemKey = function(index)
return events[index].id
end,
renderItem = function(index)
return EventRow(events[index])
end,
onRangeChange = function(first, last, info)
mountedRows = info.mounted
end,
})Programmatic scrolling uses the same stable key:
ui.scrollTo("combat-log", 280)
ui.scrollToItem("combat-log", 42, 28, { align = "center" })
local offset = ui.getScrollOffset("combat-log")Tip
Give virtual lists a stable key so the scroll offset can be reused across
frames. Use itemKey when rows can be inserted, removed, or reordered.
Uncontrolled tabs:
ui.tabs({ defaultActive = 1 }, {
{ label = "Logs", content = LogsPanel() },
{ label = "Stats", content = StatsPanel() },
})Tabs also accept labelKey:
ui.tabs({}, {
{ labelKey = "tabs.logs", content = LogsPanel() },
{ labelKey = "tabs.stats", content = StatsPanel() },
})Controlled tabs:
ui.tabs({
active = activeTab,
tabWidth = 92,
tabPadding = { x = 10, y = 4 },
onChange = setActiveTab,
}, tabs)Use tabWidth, per-tab width, or tabPadding when tabs should read as a
segmented control with stable button sizes.
In a height-constrained shell, use flex = 1 on ui.tabs and on each pane root
to keep the tab strip and content viewport fixed while switching between panes
with different intrinsic heights. The tab strip itself never shrinks below
tabHeight. Put an oversized pane body in scrollView instead of allowing it
to resize the surrounding shell.
The default theme keeps selection and focus separate: an active tab uses the accent fill, while an active-and-focused tab retains that fill and adds the two-pixel focus border.
ui.panel is a small convenience component for framed tool sections:
ui.panel({ title = "Logs", width = "100%", flex = 1 }, {
ui.text("Ready"),
})Panel titles use the h2 typography preset by default. Use titleTextStyle to
select another preset, and titleKey for localized panel titles.
Panels accept the full shared component prop set. Stable key values,
interactive, layout callbacks such as onLayout and onBounds, lifecycle
animation through enter and exit, and feedback or audio metadata are
forwarded to the panel node. Glyph shallow-copies the supplied props, then adds
the panel defaults display = "column", gap = 8, and padding = 10 without
mutating the caller's table; explicit spacing values, including 0, are kept.
All nodes accept semantic props such as role, accessibilityLabel,
accessibilityDescription, accessibilityValueText, accessibilityHidden, and
accessibilityLive. Buttons, inputs, meters, tabs, text, and panels have
best-effort defaults, and semantic strings can use i18n key props. See
Accessibility for adapter events and snapshot APIs.
