UI & widgets
Build HUDs and menus on scene.ui with Panel, Button, Label, and ProgressBar widgets — anchoring, layout, clicks, and keyboard focus.
UI & widgets
Every scene owns a screen-fixed UI layer, scene.ui, that is rendered automatically on top of the world. It lives in screen space (origin top-left, 0..width × 0..height), so its contents never scroll or zoom with the camera. Add anything to it with scene.ui.addChild(...).
On top of that layer, ExoJS ships a small set of widgets — Panel, Button, Label, ProgressBar, a Stack layout container, a ScrollContainer with Scrollbars for overflowing content, the form controls Checkbox, Toggle, Slider, Dropdown, TextInput and TextArea, and a hover Tooltip helper — for HUDs, menus, and pause screens without hand-built hit-tests.
Building blocks
Each widget takes an options object and exposes the handful of properties you tend to change at runtime:
import { Button, Label, Panel, ProgressBar } from '@codexo/exojs';
const score = new Label('Score: 0', { fontSize: 24 });
score.text = 'Score: 120';
const health = new ProgressBar({ width: 240, height: 14, value: 1 });
health.value = 0.6; // fill fraction, clamped to [0, 1]
const panel = new Panel({ width: 280, height: 160, cornerRadius: 16 });
const start = new Button({ label: 'Start', width: 160, height: 44 });
start.onClick.add(() => console.log('clicked'));
A Label wraps a runtime Text, so it accepts the same style options (fontSize, fillColor, align, …). A Panel is a rounded background you can add children to. A Button is a clickable panel with a centered label and hover / pressed / disabled states.
Form controls
Four widgets cover settings screens and in-game options: a Checkbox, a Toggle, a Slider and a Dropdown. All four are focusable, usable from the keyboard alone, and report changes through an onChange signal:
class OptionsScene extends Scene {
override init(): void {
const options = new Stack({ direction: 'column', spacing: 12, padding: 16 });
const fullscreen = new Checkbox({ label: 'Fullscreen' });
fullscreen.onChange.add(checked => console.log('fullscreen', checked));
const vsync = new Toggle({ label: 'V-Sync', checked: true });
const volume = new Slider({ width: 240, min: 0, max: 1, value: 0.8, step: 0.05 });
volume.onChange.add(value => console.log('volume', value));
const quality = new Dropdown({
width: 240,
items: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
selectedIndex: 1,
});
quality.onChange.add(value => console.log('quality', value));
options.addChild(fullscreen).addChild(vsync).addChild(volume).addChild(quality);
options.anchorIn(this.ui, 'center');
this.ui.addChild(options);
}
}Checkbox and Toggle are the same two-state control with a different look — checked, toggle(), Enter / Space to flip — and both size themselves to their control plus their optional label until you call setSize(). A Slider clamps its value into [min, max] and rounds it to step (0, the default, is continuous); the arrow keys step it, Home / End jump to the ends, and dragging the thumb or clicking the groove works everywhere the pointer goes, not only inside the widget.
A Dropdown is generic over the value type: items pairs each label with the value onChange reports, so selectedValue comes back typed. With the list open the arrows move the highlight and Enter picks it; with it closed they change the selection directly, so a value can be changed without ever opening the list.
A Dropdown’s open list is a child of the dropdown, so it draws above whatever the dropdown draws above — and is clipped by the same ancestors. Inside a ScrollContainer, put the dropdown where its list has room, or it will be cut off at the clip edge.
Text fields
A TextInput is a single-line text field drawn on the canvas like every other widget, with a real caret, selection, word jumps, undo and clipboard support:
class LoginScene extends Scene {
override init(): void {
const form = new Stack({ direction: 'column', spacing: 12, padding: 16 });
const name = new TextInput({
width: 260,
placeholder: 'Player name',
maxLength: 24,
enterKeyHint: 'next',
});
const password = new TextInput({
width: 260,
placeholder: 'Password',
maskChar: '•',
enterKeyHint: 'go',
});
// The value the gate sees is the value the edit would produce, so a paste
// that would break the rule is dropped whole rather than truncated.
const code = new TextInput({
width: 260,
inputMode: 'numeric',
filter: candidate => /^\d*$/.test(candidate),
});
const notes = new TextArea({
width: 260,
height: 120,
placeholder: 'Notes',
maxLength: 500,
});
notes.onChange.add(value => console.log(value.split('\n').length, 'lines'));
name.onSubmit.add(() => password.focus());
password.onSubmit.add(value => console.log('sign in', name.value, value.length));
code.onChange.add(value => console.log('code', value));
form.addChild(name).addChild(password).addChild(code).addChild(notes);
form.anchorIn(this.ui, 'center');
this.ui.addChild(form);
}
}Keyboard input does not come from key codes. The field owns a hidden transport element supplied by the platform adapter, and edits arrive as intents from the browser’s own editing pipeline (beforeinput), so dead keys, IME composition, autocorrect and paste behave the way they do in any other field on the page. Enter fires onSubmit, every edit fires onChange, and Escape and Tab release focus without being consumed.
maxLength, filter and maskChar are enforced in the editing model rather than in the key handler, so a paste cannot slip past them: filter sees the value the edit would produce and refuses the whole edit, never a truncated part of it. A masked field keeps the plain value in value and renders one mask character per grapheme; copy and cut are refused while a mask is set, so the transport can never hand the clipboard a password.
inputMode and enterKeyHint are passed to the transport, which is what decides the on-screen keyboard layout and the label on its confirm key on a phone.
A TextArea is the multi-line field on the same core. Enter inserts a line break instead of confirming, Up / Down move by line and keep the column the caret last chose, PageUp / PageDown move by as many lines as the field shows, and Home / End go to the ends of the current line. The content scrolls in both axes to keep the caret visible and is clipped to the field. Its lines are the ones the value states - a long line scrolls sideways rather than wrapping.
A host with no text-input transport — the offscreen-canvas adapter, for instance — makes the field render and take focus but reject every edit. It is a capability, not a guarantee: check that the field is reachable before making it the only way to enter something.
Theming
A widget does not carry its own colours: it paints a skin it resolves from the theme of the nearest themed ancestor — the layer’s UIRoot, or any widget above it that set overrides. A UITheme holds one UISkinSet per role (panel, button, label, progressBarTrack, progressBarFill, scrollbarTrack, scrollbarThumb, checkbox, checkboxMark, toggleTrack, toggleKnob, sliderTrack, sliderFill, sliderThumb, dropdownList, dropdownItem, textFieldSurface, placeholder, selection, caret), and each skin carries a UIBackground descriptor, a text style, and insets. Insets are layout input, so a theme change re-lays out as well as repaints.
createUITheme resolves a patch against the built-in default, which reproduces the widgets’ stock look. Assign the result to scene.ui.theme to restyle a whole layer, or call widget.setTheme(patch) to restyle one subtree:
import { Color, createUITheme, Panel } from '@codexo/exojs';
const dark = createUITheme({
panel: {
normal: {
background: { kind: 'fill', color: new Color(18, 20, 28, 1), borderColor: new Color(255, 255, 255, 0.08), borderWidth: 1, cornerRadius: 12 },
},
},
button: {
hover: {
background: { kind: 'fill', color: new Color(90, 160, 250, 1), borderColor: Color.transparent, borderWidth: 0, cornerRadius: 8 },
},
},
});
const dialog = new Panel({ width: 280, height: 160 });
dialog.setTheme({ panel: { normal: { background: dark.panel.normal.background } } });
dialog.setFill({ color: new Color(40, 44, 60, 1) }); // this panel only, on top of its skin
A background descriptor is either a fill (the rounded rectangle above), { kind: 'none' }, a nine-slice that stretches a texture without distorting its corners, or a sprite that stretches or tiles flat art.
Textured skins
Anywhere a background is taken you can state it the way you have it - a colour, a texture, an atlas region, or a full descriptor. A texture becomes a nine-slice; a colour becomes a fill override on top of the skin, so the skin’s corner radius and border survive it:
import { Button, Color, Panel, ProgressBar, type Texture } from '@codexo/exojs';
declare const frame: Texture;
declare const idle: Texture;
declare const hot: Texture;
const panel = new Panel({ width: 280, height: 160, background: frame });
const start = new Button({ label: 'Start', skin: { normal: idle, hover: hot, disabled: new Color(70, 76, 90, 1) } });
Without slices, a texture is sliced at a third of its source size per axis - a usable starting point, not a substitute for stating the slices a skin was drawn for. Pass them (and, where the corners should be drawn at a different scale, border) alongside the background: panel.setBackground(frame, { slices: 8, border: 16 }). For flat art that has no frame to preserve, pass fit instead: { fit: 'stretch' } or { fit: 'tile' }.
A ProgressBar decides how its bar follows the value with fillMode. The default 'clip' paints the bar’s art at full width and shows the leading fraction of it, which is what keeps a textured bar undistorted; 'scale' paints it at the value’s width instead. A fill has no art to distort and is always painted at the value’s width:
import { ProgressBar, type Texture } from '@codexo/exojs';
declare const barArt: Texture;
const health = new ProgressBar({ width: 240, height: 14, value: 0.6, barBackground: barArt, slices: 6 });
health.fillMode = 'scale'; // squash the art with the value instead of cutting it
Per-widget style always goes through a setter — setFill, setBackground, setTextStyle — so a change repaints (and re-lays out where it can move something) immediately. Reading panel.color gives the value in effect, including the theme’s; panel.fillOverrides gives only what this panel overrides.
Anchoring to the screen
Widgets extend Widget, which adds an explicit layout size and screen-edge anchoring. widget.anchorIn(scene.ui, anchor, offsetX, offsetY) pins a widget to a corner or edge and re-applies the position whenever the canvas resizes:
class HudScene extends Scene {
override init(): void {
const score = new Label('Score: 0', { fontSize: 24 });
score.anchorIn(this.ui, 'top-left', 24, 20); // top-left, 24x20 px margin
this.ui.addChild(score);
const health = new ProgressBar({ width: 240, height: 14, value: 1 });
health.anchorIn(this.ui, 'bottom-right', -24, -24); // 24 px from the bottom-right
this.ui.addChild(health);
}
}The anchor accepts 'top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', and 'bottom-right'.
Anchored widgets need no resize handler
anchorIn re-pins the widget every time the canvas resizes, so a HUD laid out with it never needs a resize handler of your own — set the anchor once and the position tracks the viewport for you.
Stacking widgets
A Stack flows its children in a row or column with even spacing and sizes itself to fit. Use it for menus and button groups:
class MenuScene extends Scene {
override init(): void {
const menu = new Stack({ direction: 'column', spacing: 10, padding: 14 });
menu.addItem(new Button({ label: 'Resume' }));
menu.addItem(new Button({ label: 'Restart' }));
menu.addItem(new Button({ label: 'Quit' }));
const panel = new Panel();
panel.setSize(menu.uiWidth, menu.uiHeight);
panel.addChild(menu);
panel.anchorIn(this.ui, 'center');
this.ui.addChild(panel);
}
}Stack re-flows on its own: adding, removing or resizing a child, and setting direction, spacing, padding or align, all re-place the items immediately. align decides the cross axis - 'start', 'center', 'end', or 'stretch', which resizes the items to the stack’s cross extent.
A stack sizes itself to its content until you give it a box with setSize. From then on it keeps that box, and stack.setGrow(child, factor) hands the leftover space along the flow direction to the growing children, split by factor:
import { Button, Stack } from '@codexo/exojs';
const sidebar = new Stack({ direction: 'column', spacing: 8, align: 'stretch' });
const list = new Button({ label: 'Inventory' });
sidebar.addChild(list, new Button({ label: 'Close' }));
sidebar.setSize(220, 400); // an explicit box is what leaves space to distribute
sidebar.setGrow(list, 1); // the list takes everything the other items leave
Docking to the edges
A DockContainer pins children to 'top', 'right', 'bottom' or 'left' and gives what is left to 'center'. Each edge child is sized across its band and keeps its own extent along the other axis, so a docked HUD survives a resize with one setSize call:
class DockedHudScene extends Scene {
override init(): void {
const hud = new DockContainer({ width: this.ui.screenWidth, height: this.ui.screenHeight });
const topBar = new Stack({ direction: 'row', spacing: 12, padding: 8, align: 'center' });
topBar.addChild(new Label('Score: 0', { fontSize: 20 }), new ProgressBar({ width: 180, height: 12, value: 1 }));
const sidebar = new Stack({ direction: 'column', spacing: 8, padding: 8 });
sidebar.setSize(200, 0);
sidebar.addChild(new Button({ label: 'Map' }), new Button({ label: 'Quests' }));
hud.dock(topBar, 'top');
hud.dock(sidebar, 'right');
hud.dock(new Panel(), 'center');
this.ui.addChild(hud);
this.ui.onResize.add((width, height) => hud.setSize(width, height));
}
}Bands are taken in docking order: docking 'top' before 'left' gives the top band the full width and the left band only what is left below it. Reverse the two calls and the left band runs the full height instead.
Scrolling content
A ScrollContainer clips its content to a fixed width × height and scrolls it with the mouse wheel — useful for inventories, logs, or any list longer than its box. Add children to scroll.content, not to the container itself:
class InventoryScene extends Scene {
override init(): void {
const scroll = new ScrollContainer({
width: 280,
height: 320,
direction: 'vertical',
background: new Color(20, 24, 32, 0.9),
scrollbars: 'auto',
});
scroll.anchorIn(this.ui, 'center');
for (let i = 0; i < 20; i++) {
const item = new Label(`Item ${i}`, { fontSize: 16 });
item.setPosition(12, i * 28);
scroll.content.addChild(item);
}
this.ui.addChild(scroll);
}
}scroll.scrollTo(x, y) and scroll.scrollBy(dx, dy) move the content programmatically, clamped so it never scrolls past its edges; direction restricts the wheel to 'vertical', 'horizontal', or 'both'.
A container paints a Scrollbar per scrollable axis, themed through the scrollbarTrack and scrollbarThumb roles. The bars overlay the content instead of shrinking it, so the visible box stays the size you declared. scrollbars decides when they show:
'auto'(the default) shows a bar only while its axis actually overflows,'always'keeps it visible as a permanent affordance,'never'builds none at all, leaving the wheel andscrollToas the only way to move.
Dragging a thumb scrolls the content, and scrolling by any other means moves the thumb. The range follows what scroll.content holds — adding or removing an item updates it. A child that resized itself is the one change the container cannot observe; call scroll.refresh() after it.
Items go on scroll.content, not the container
Parent scrollable items to scroll.content, never to the ScrollContainer itself. Children added straight to the container sit outside the clipped, scrolling region — they render at a fixed spot and ignore the wheel.
Scaling the UI
A HUD authored at one size is not automatically readable on every screen. scene.ui.uiScale is one factor over the whole layer, default 1: raise it and every widget, its text and its touch target grow together.
class SettingsScene extends Scene {
override init(): void {
// Snap the factor so nine-slice corners resample predictably.
this.ui.uiScaleStep = 0.25;
// A user-facing "UI scale" setting.
this.ui.uiScale = 1.5;
// Or start from how large a 24-pixel control ends up physically, and grow
// the layer until it reaches a 9mm touch target.
this.ui.uiScale = UIRoot.scaleForTouchTarget(24, 9);
const quit = new Button({ label: 'Quit', width: 160, height: 44 });
// Anchoring works against the scaled box, so this stays in the corner.
quit.anchorIn(this.ui, 'bottom-right', -24, -24);
this.ui.addChild(quit);
}
}The factor is applied as the layer’s transform, so hit-testing follows it for free — pointer routing already goes through that transform, and a widget’s own uiWidth/uiHeight never change. scene.ui.screenWidth and screenHeight report the box in the scaled units widgets lay out in, and onResize fires when the factor changes, so anchored widgets re-place themselves.
uiScaleStep snaps the factor — set it to 0.25 before wiring a slider to it. An arbitrary factor resamples nine-slice corners and pixel art; a handful of discrete sizes usually looks better. Text is SDF and stays crisp either way.
Scale and adaptation are two different mechanisms
uiScale is a readability and touch-size control. It is not a resolution policy: which logical coordinate system the game is authored in — and so how much of the world is visible — belongs to the application’s CanvasSizing, and a HUD authored against the base resolution therefore does not shrink on a 4K display to begin with. Adapting to a different shape of screen is layout’s job: anchors, DockContainer and a reactive Stack. A phone usually wants both — reflow for the aspect ratio, a factor for the finger.
UIRoot.scaleForTouchTarget(sizePixels, millimeters) turns “a 24-pixel button should be at least 9mm across” into a factor. It converts through the CSS reference pixel (96 per inch), which is all a browser exposes — there is no API for the display’s real physical size, and devicePixelRatio describes the backing store, not how large a pixel is. Treat it as a starting point for a density heuristic, not a measurement.
Clicks and keyboard focus
UI nodes are hit-tested in screen space before the world, so a Button on scene.ui is clickable even under a panned or zoomed camera. Listen to button.onClick:
class GameScene extends Scene {
override init(): void {
const pause = new Button({ label: 'Pause' });
pause.anchorIn(this.ui, 'top-right', -16, 16);
pause.onClick.add(() => {
this.app.scenes.pause();
});
this.ui.addChild(pause);
}
}Widgets are also keyboard-focusable. Mark any node focusable and give it a tabIndex; it then receives focus and routed key events:
class FormScene extends Scene {
override init(): void {
const field = new Button({ label: 'Name' });
field.focusable = true;
field.tabIndex = 1; // lower values are visited first
field.onFocus.add(() => {
/* highlight */
});
field.onKeyDown.add(event => {
if (event.channel === Keyboard.Enter) {
/* submit */
}
});
this.ui.addChild(field);
}
}RenderNode focus lives on app.interaction alongside pointer routing: it tracks the focused node (app.interaction.focused), moves focus with Tab / Shift+Tab or with focusNext() / focusPrevious(), activates a focused button on Enter / Space, and exposes app.interaction.focus(node) / app.interaction.blur() to focus programmatically — focus(node) is a no-op if node sits outside an active interaction scope (see below), not only Tab traversal.
Key events bubble from the focused node up through its entire ancestor chain, same as pointer events: a container can listen on onKeyDown for a key pressed anywhere inside it, without every descendant needing its own handler, and call event.stopPropagation() to halt the bubble early. event.currentTarget tracks whichever ancestor is dispatching; event.target stays pinned to the actually-focused node throughout.
Do not confuse this with canvas focus — whether the browser is sending the page’s keyboard input to the ExoJS canvas at all — which lives on app.input.canvasFocused and app.input.onCanvasFocusChange.
Arrow keys and the D-pad
Beyond Tab, focus also moves spatially: the arrow keys and a gamepad D-pad move it to the nearest focusable node in that direction, comparing the centres of their global bounds and weighing sideways offset heavier than distance along the direction. Navigation does not wrap — at the edge of the layout nothing moves — and with nothing focused yet, the first candidate takes focus, which is how a controller enters a menu.
app.interaction.focusNavigation decides what that reaches: 'ui' (the default) stays inside the UI layer so the game keeps the arrow keys, 'always' navigates the world layer too — the same set Tab traverses — and 'never' turns it off. An active interaction scope replaces the candidate set with its own subtree either way, so a modal stays navigable whatever the policy says. app.interaction.focusInDirection('down') performs one step directly, for binding navigation to anything else:
class GamepadMenuScene extends Scene {
override init(): void {
const interaction = this.app.interaction;
// The default: the arrow keys and a D-pad walk the UI layer, and the game
// keeps the arrow keys for everything else.
interaction.focusNavigation = 'ui';
// Enter the menu with something already focused, so the first D-pad press
// moves rather than picks a starting point.
const first = new Button({ label: 'Continue', width: 200, height: 44 });
this.ui.addChild(first);
interaction.focus(first);
// Any other trigger can navigate too - a shoulder button, a stick tilt.
this.app.input.onAnyGamepadButtonDown.add((_pad, button) => {
if (button.channel === GamepadButton.RightShoulder) {
interaction.focusInDirection('down');
}
});
}
}A focused widget that wants an arrow key for itself calls event.preventDefault() on its onKeyDown event, exactly as it would to keep Tab. Slider and Dropdown do this, so the arrows adjust the focused control instead of leaving it.
Every interactive widget also paints a focused state while it holds focus, resolved from its theme like every other state — so keyboard and controller users can see where they are without any extra wiring.
Tooltips
A Tooltip attaches to any interactive node and shows a small text label near the pointer after a short delay, hiding again as soon as the pointer leaves. It parents itself to the nearest UIRoot ancestor of its target, so it always renders above other content — the target just needs to already be in the scene tree:
class ShopScene extends Scene {
private tooltip!: Tooltip;
override init(): void {
const upgrade = new Button({ label: 'Upgrade', width: 160, height: 44 });
upgrade.interactive = true;
upgrade.anchorIn(this.ui, 'top-left', 24, 20);
this.ui.addChild(upgrade);
this.tooltip = new Tooltip(upgrade, { text: 'Costs 50 gold', delay: 0.3 });
}
}Options cover the delay before showing (delay, in seconds), the offset from the pointer (offsetX / offsetY), and basic styling (background, textColor, padding, fontSize). Call tooltip.destroy() to remove the listeners it attached to its target — e.g. when the target itself is destroyed.
Pause overlays
A pause menu is the canonical combination: freeze the world with app.scenes.pause() — which stops update and the scene’s systems while it keeps drawing — and show an overlay built from widgets on scene.ui. Call app.scenes.resume() to unfreeze:
class GameScene extends Scene {
private pausePanel!: Panel;
private pauseLabel!: Label;
override init(): void {
this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18 });
this.pausePanel.anchorIn(this.ui, 'center');
this.pausePanel.visible = false;
this.ui.addChild(this.pausePanel);
this.pauseLabel = new Label('PAUSED', { fontSize: 56 });
this.pauseLabel.anchorIn(this.ui, 'center');
this.pauseLabel.visible = false;
this.ui.addChild(this.pauseLabel);
this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause());
}
togglePause(): void {
if (this.app.scenes.paused) {
this.app.scenes.resume();
} else {
this.app.scenes.pause();
}
const paused = this.app.scenes.paused;
this.pausePanel.visible = paused;
this.pauseLabel.visible = paused;
}
}Try it


