tweaks
v0.3.4
Published
A GUI library with an immediate mode API to inspect, tweak and save values back to your code
Downloads
891
Maintainers
Readme
Tweaks
A fully featured, reactive GUI library with an Immediate Mode API to easily tweak object values and save changes back to your code.
Think of it as a mix between lil-gui / Tweakpane and Dear ImGui.
I made this to fix a few pain points with existing JavaScript GUI libraries:
- Reactive by default: the data is the single source of truth. The GUI always reflects the current values, no bindings or callbacks needed to sync
- UI state persists across reloads: folder open/close states, panel positions, scroll positions are all remembered automatically.
- Save changes back to your code: tweak values in the browser, hit save, and the changes are written directly to a state file. No more copy/pasting values from the GUI into your code.
Getting started
npm install tweaksimport { GUI } from 'tweaks/gui';
const obj = { speed: 1, gravity: 9.8 };
GUI.render(() => {
GUI.Number(obj, 'speed', 0, 100, 0.1);
GUI.Number(obj, 'gravity', 0, 20, 0.1);
if (GUI.Button('Reset')) {
obj.speed = 1;
obj.gravity = 9.8;
}
});TWEAKS
TWEAKS lets you define typed variables that sync with the GUI and get saved to a JS file in your project. Change a value in the browser, hit save, and it's written to your code.
import { createTweaks, syncTweaks, Float, Int, Bool, Color, Select } from 'tweaks';
import { GUI } from 'tweaks/gui';
import { tweaks } from './tweaks.js';
syncTweaks(tweaks);
const params = createTweaks('Particles', {
count: Int(200, 10, 1000),
speed: Float(1.5, 0.1, 10, 0.1),
visible: Bool(true),
color: Color('#4D9CFF'),
});
GUI.render(() => {
GUI.Tweaks(params);
});
Running the server
Start the dev server:
npx tweaksThis starts an HTTP server, auto-creates a tweaks.js state file if it doesn't exist, and opens the browser.
Options:
npx tweaks 3000 # Custom port
npx tweaks ./mystate.js # Custom state file path
npx tweaks 3000 ./mystate.js # Both
npx tweaks --no-open # Don't open browserState file
The server automatically creates and manages a tweaks.js file:
/** @type {{ __path__: string, data: Object }} */
export const tweaks = {
__path__: '/tweaks.js',
data: {},
};Tweaks values are written to this file when you click SAVE in the GUI (or press Cmd+S). Import it and pass it to syncTweaks():
import { syncTweaks } from 'tweaks';
import { tweaks } from './tweaks.js';
syncTweaks(tweaks);For browser-only persistence (no server), you can use the localStorage mode:
syncTweaks('localStorage');Registering tweaks
Create a group with a schema:
const myTweaks = createTweaks('My Tweaks', {
speed: Float(1, 0, 10, 0.1),
count: Int(5, 0, 100),
name: Str('default'),
active: Bool(true),
tint: Color('#ff6600'),
});Nested plain objects automatically create collapsible folders:
const params = createTweaks('Config', {
speed: Float(1, 0, 10),
physics: {
gravity: Float(9.8, 0, 20),
friction: Float(0.5, 0, 1),
},
name: Str('hello'),
});
params.physics.gravity // 9.8
params.physics.friction // 0.5Or register properties individually:
const myTweaks = createTweaks('My Tweaks');
myTweaks.Float('speed', 1, 0, 10, 0.1);
myTweaks.Int('count', 5, 0, 100);
myTweaks.Str('name', 'default');
myTweaks.Bool('active', true);
myTweaks.Color('tint', '#ff6600');Or with group.add():
const myTweaks = createTweaks('My Tweaks');
myTweaks.add('speed', Float(1, 0, 10, 0.1));
myTweaks.add('count', Int(5, 0, 100));You can then read or write values directly on the group like a regular JS Object:
console.log(myTweaks.speed); // 1
myTweaks.speed = 5;TweakGroup methods
hasKeys(filter?)
Returns true if the group has any registered keys. With a filter string, only returns true if a key's root prefix matches:
const params = createTweaks('Config', {
speed: Float(1, 0, 10),
physics: {
gravity: Float(9.8, 0, 20),
friction: Float(0.5, 0, 1),
},
});
params.hasKeys(); // true — has any keys
params.hasKeys('physics'); // true — has keys under 'physics'
params.hasKeys('audio'); // false — no keys under 'audio'hasUnsaved(filter?)
Returns true if the group has any values that differ from their saved state. With a filter string, only checks keys whose root prefix matches:
if (params.hasUnsaved()) {
// at least one value was changed since the last save
}
if (params.hasUnsaved('physics')) {
// a physics value was changed
}saveTweaks()
Programmatically trigger a save (same as clicking SAVE in the GUI or pressing Cmd+S):
saveTweaks();Built-in types
| Type | Factory | Parameters | Widget |
|------|---------|------------|--------|
| Float | Float(default, min, max, step) | All optional | Number input + slider |
| Int | Int(default, min, max) | All optional, step forced to 1 | Number input + slider |
| Num | Num(default, min, max, step) | All optional, default can be a string with unit | Measure input + slider |
| Str | Str(default) | Optional | Text input |
| Bool | Bool(default) | Optional | Checkbox |
| Color | Color(default) | Optional | Color picker |
| Select | Select(options, default) | options: array or object; default optional | Dropdown |
Auto-inferred bounds
When Float, Int, or Num are called with only a default value (no min/max/step), reasonable bounds are auto-inferred:
| Default value | Min | Max | Step (Float/Num) | Step (Int) | |---------------|-----|-----|-----------------|------------| | 0 to 1 | 0 | 1 | 0.01 | 1 | | 1 to 10 | 0 | value x 3 | 0.1 | 1 | | 10 to 100 | 0 | value x 3 | 1 | 1 | | 100+ | 0 | value x 3 | 10 | 1 | | negative | -abs(value) x 3 | 0 | (same as above) | 1 |
Negative defaults flip the range to end at 0:
Float(50) // slider 0 to 150, step 1
Float(-150) // slider -450 to 0, step 10
Float(0.5) // slider 0 to 1, step 0.01
Float(5) // slider 0 to 15, step 0.1
Float(500) // slider 0 to 1500, step 10
Float(50, 0, 200) // explicit bounds, no inferenceNum (number with unit)
Num works like Float but accepts a unit suffix (px, %, em, deg, etc.). The value includes the unit string, the slider operates on the numeric part.
Num('50px') // value = '50px', slider 0 to 150
Num('100%') // value = '100%', slider 0 to 300
Num(50) // value = 50, no unit until user types one
Num('50px', 0, 200) // explicit boundsThe unit can be changed by typing in the input field (e.g., type 50em to switch from px to em). Removing the unit (type just 50) clears it. The slider preserves the current unit.
Select
Select renders a dropdown. Pass an array or object as options; the second argument is the default value.
// Array: stored value is the index (number)
Select(['low', 'medium', 'high']) // default index 0
Select(['low', 'medium', 'high'], 1) // default index 1 ('medium')
// Object: keys are stored values, object values are display names
Select({ idle: 'Idle', walk: 'Walk', run: 'Run' }) // default 'idle'
Select({ idle: 'Idle', walk: 'Walk', run: 'Run' }, 'walk') // default 'walk'Rendering tweaks
GUI.Tweaks(group) renders all registered properties and adds SAVE, REVERT, RESET buttons to the parent panel:
GUI.render(() => {
if (GUI.Tweaks(myTweaks)) {
// A value changed
}
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| tweakGroup | TweakGroup | | Group returned by createTweaks() |
| folderFilter | string | undefined | Only render properties whose key or folder path starts with this prefix |
Returns true the frame a value changes.
Filtering
Pass a folderFilter string to render only a subset of properties:
const params = createTweaks('Config', {
speed: Float(1, 0, 10),
physics: {
gravity: Float(9.8, 0, 20),
friction: Float(0.5, 0, 1),
},
});
GUI.render(() => {
if (!GUI.BeginPanel('Physics only')) return;
GUI.Tweaks(params, 'physics'); // only renders gravity and friction
GUI.EndPanel();
});TweaksFolder / EndTweaksFolder
Wrap GUI.Tweaks() calls in TweaksFolder / EndTweaksFolder to group them inside a collapsible folder while still sharing the parent panel's SAVE/REVERT/RESET buttons:
GUI.render(() => {
if (!GUI.BeginPanel('Settings')) return;
GUI.TweaksFolder('Particles', true);
GUI.Tweaks(particleTweaks);
GUI.EndTweaksFolder();
GUI.TweaksFolder('Physics only', false);
GUI.Tweaks(nestedTweaks, 'physics');
GUI.EndTweaksFolder();
GUI.EndPanel();
});| Function | Param | Type | Default | Description |
|----------|-------|------|---------|-------------|
| TweaksFolder | label | string | | Folder label |
| | expanded | boolean | Ref | undefined | Initial expanded state. Accepts a Ref to persist |
| EndTweaksFolder | | | | Closes the folder |
TweaksStatusBar
Registers a tweak group with the parent panel's status bar (SAVE/REVERT/RESET) without rendering any tweaks UI. Returns true when a value in the group has changed:
GUI.render(() => {
if (!GUI.BeginPanel('Editor')) return;
if (GUI.TweaksStatusBar(myTweaks)) {
// A value changed, refresh
}
GUI.EndPanel();
});| Function | Param | Type | Description |
|----------|-------|------|-------------|
| TweaksStatusBar | tweakGroup | TweakGroup | The group to register with the panel status bar |
Rendering as regular widgets
You can also render tweak group properties as regular widgets:
GUI.render(() => {
GUI.Number(myTweaks, 'speed');
GUI.Number(myTweaks, 'count');
});Custom types
Custom types are registered in two steps:
registerType(name, defaultValue)registers the type and returns a factory function. This handles data and persistence, with no GUI dependency.registerTypeGUI(name, constructorFn)registers the GUI renderer for the type, defining the widget displayed when the GUI is shown.
registerType
registerType(name, defaultValue) returns a factory that produces TweakRegister descriptors. A method with the same name is also added to all groups:
const defaultGradient = { from: '#ff0000', to: '#0000ff', midpoint: 50 };
const Gradient = registerType('Gradient', defaultGradient);
const g = createTweaks('Theme', {
sky: Gradient({ from: '#0a1628', to: '#4D9CFF', midpoint: 60 }),
});
// Or register individually:
g.Gradient('sky', { from: '#0a1628', to: '#4D9CFF', midpoint: 60 });
// g.sky.from = '#0a1628', g.sky.to = '#4D9CFF', g.sky.midpoint = 60
const css = `linear-gradient(${g.sky.from}, ${g.sky.to} ${g.sky.midpoint}%)`;The factory accepts (defaultValue, n1, n2, ...n7). Extra parameters are available on the prop object as prop.n1 through prop.n7.
registerTypeGUI
registerTypeGUI(name, constructorFn) registers the GUI renderer for a type. The constructor runs every frame during rendering and must return true when the value changes.
The constructor receives a prop object where prop.value holds the current value and a labelKey string for widget identity. Since prop.value here is an object, it can be passed directly as the target for sub-widgets:
registerTypeGUI('Gradient', (prop, labelKey) => {
let changed = false;
GUI.Label(labelKey);
GUI.BeginGroup(labelKey, 'div.row');
if (GUI.ColorInput(prop.value, 'from')) changed = true;
if (GUI.SliderInput(prop.value, 'midpoint', 0, 100, 1)) changed = true;
if (GUI.ColorInput(prop.value, 'to')) changed = true;
GUI.EndGroup(labelKey);
return changed;
});This separation enables tree-shaking: code that only needs the data (sync, persistence) can import registerType without pulling in any GUI code. The GUI renderer is registered separately, typically in the entry point that also imports GUI.
GUI
Immediate mode
The GUI follows the immediate mode pattern: widgets are declared every frame inside a render callback, not created once and bound to events. There is no setup, no binding, no callback registration. Your data is the single source of truth. Widgets read from it and write to it directly.
Input widgets return true the frame their value changes. Buttons return true the frame they are clicked. This means your logic and your UI live in the same place:
GUI.render(() => {
if (GUI.Slider(obj, 'volume', 0, 1, 0.01)) {
audio.setVolume(obj.volume); // runs only when the slider moves
}
if (GUI.Button('Mute')) {
obj.volume = 0; // runs only on click
}
});The library handles DOM creation, updates, and cleanup automatically. Widgets that are not declared in a frame are removed from the DOM. Widgets that reappear are recreated.
Widget identity
Widgets are identified by their target + labelKey combination within their parent container. If you need to display the same variable twice with the same widget type in the same folder, use a different label or id to make each widget unique:
// Won't work: same target + labelKey in the same parent, only one widget appears
GUI.Number(obj, 'x', 0, 100);
GUI.Number(obj, 'x', 0, 100);
// Works: different labels make them unique
GUI.Number(obj, 'x::"Position X"', 0, 100);
GUI.Number(obj, 'x::"Duplicate X"', 0, 100);Minimal setup
<script type="module" src="app.js"></script>import { GUI } from 'tweaks/gui';
const obj = { x: 0 };
GUI.render(() => {
if (GUI.BeginPanel('My Panel')) {
GUI.Number(obj, 'x', 0, 100, 1);
GUI.EndPanel();
}
});GUI.render(callback) registers a function that runs every frame. All widget calls go inside this callback.
For manual control of the render loop:
function loop() {
GUI.beginRender();
// widget calls here
GUI.endRender();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);GUI.requestRender() requests an extra render frame. Useful when external code changes data that the GUI needs to reflect immediately rather than waiting for the next scheduled frame.
Binding variables to widgets
Widgets take a target object and a labelKey string. The widget reads and writes target[key]:
const obj = { x: 10, name: 'hello' };
GUI.Number(obj, 'x', 0, 100); // reads/writes obj.x
GUI.Text(obj, 'name'); // reads/writes obj.nameArrays work with numeric keys:
const arr = [10, 20, 30];
GUI.Number(arr, 0, 0, 100); // reads/writes arr[0]LabelKey syntax
The labelKey parameter controls the property key, display label, CSS classes, HTML id, and attributes. Without ::, the string is both the key and the label:
GUI.Text(obj, 'name'); // key='name', label='name'Add :: to separate the key from modifiers:
key::"Custom Label"#id.class[attribute]| Part | Description |
|------|-------------|
| key | Property name on the target object |
| :: | Separator between key and modifiers |
| "Label" | Custom display label. "" for empty label |
| #id | Custom HTML id, also used with SetParent('#id') |
| .class | CSS class(es) |
| [attr] | HTML attribute (e.g. [disabled]) |
| [attr=value] | HTML attribute with value (e.g. [title=My tooltip]) |
| [style=css] | Inline styles — appended to existing styles, won't clobber SetPosition |
Examples:
GUI.Text(obj, 'prop::"Display Name"');
GUI.Text(obj, 'prop::""'); // empty label
GUI.Text(obj, 'prop::.color_red'); // colored
GUI.Text(obj, 'prop::"Name".color_red'); // label + color
GUI.Text(obj, 'prop::"Name"#myId.color_red'); // label + id + color
GUI.Slider(obj, 'x::"Position"[disabled]', 0, 100); // disabled
GUI.Slider(obj, 'x::[title=Custom tooltip]', 0, 100); // attribute with value
GUI.Button('btn::[style=box-shadow: 0 0 0 1px red;]'); // inline style (no quotes)
GUI.Number(arr, '0::"First item"', 0, 100); // numeric keyAvailable color classes: .color_white, .color_black, .color_red, .color_corail, .color_orange, .color_yellow, .color_citrus, .color_lime, .color_green, .color_turquoise, .color_cyan, .color_sky, .color_sega, .color_king, .color_indigo, .color_lavender, .color_purple, .color_magenta, .color_pink. Numbered aliases .color_1 through .color_19 map to the same colors in order.
Foreground colors: .color_fg_0 (transparent) through .color_fg_12.
Background colors: .color_bg_0 (transparent) through .color_bg_12.
Layout classes: .width_1 through .width_20, .width_full, .width_half, .height_1 through .height_20, .flex_1 through .flex_20, .padded, .padded_left, .padded_right.
Grid classes (for Point2DArea): .grid_dot or .grid_square for the grid style, .grid_cols_1 through .grid_cols_20 for column count. Rows are automatic from the widget height.
Margin utility classes, mainly used on windows to control the offset and gap between an anchored window and its anchor element: .margin_top_-10 through .margin_top_10, same for _right, _bottom, _left. Values are in scaled pixels. Defaults to gap + contour width when not specified.
Widgets
All input widgets return true when their value changes. Container widgets (BeginPanel, BeginFolder, Header) return true when expanded.
Ref
GUI.Ref(key, initialValue) creates a persistent local value stored in sessionStorage. It survives page reloads within the same tab.
const myRef = GUI.Ref('key', 0);
myRef(); // read
myRef(42); // writeRefs can be used as the target for any input widget:
GUI.Number(GUI.Ref('myNumber', 50), 'My Number', 0, 100);
GUI.Checkbox(GUI.Ref('debug', false), 'Debug mode');They can also be passed as parameters to control the state of container widgets like BeginFolder, Header, BeginTabs, and ToggleButton:
if (GUI.BeginFolder('Settings', GUI.Ref('settingsOpen', true))) {
// folder state persists across reloads
GUI.EndFolder();
}
if (GUI.Header('Advanced', GUI.Ref('advancedOpen', false))) {
// header state persists across reloads
}
GUI.ToggleButton('Dark mode', GUI.Ref('darkMode', false));
GUI.BeginTabs('view', GUI.Ref('selectedTab', 'Scene'));Refs prefixed with @ are global (shared across all panels):
GUI.Number(GUI.Ref('@globalSpeed', 1), 'Speed', 0, 10);Panel
if (GUI.BeginPanel('Title')) {
// panel content
GUI.EndPanel();
}| Param | Type | Default | Description |
|-------|------|---------|-------------|
| title | string | 'GUI' | Panel title |
| fitContent | boolean | true | true: height adjusts to content. false: fixed height, scrollable |
| visible | boolean | Ref | undefined | Visibility state. Accepts a Ref to persist |
| x | number | Ref | undefined | Horizontal offset in pixels (maps to CSS right by default). Accepts a Ref to persist |
| y | number | Ref | undefined | Vertical offset in pixels (maps to CSS top by default). Accepts a Ref to persist |
| width | number | Ref | undefined | Width in pixels. Accepts a Ref to persist |
| height | number | Ref | undefined | Height in pixels. Accepts a Ref to persist |
Returns false when the panel is hidden. Inside a GUI.render() callback, you can use an early return to avoid nesting:
GUI.render(() => {
if (!GUI.BeginPanel('Title')) return;
// panel content
GUI.EndPanel();
});Panels are draggable, resizable, and can be collapsed. Escape toggles all panels.
Window
Lower-level primitive used internally by BeginPanel. Use this when you need full control over the window chrome (custom title bars, no default collapse/close buttons).
const visible = GUI.Ref('@myWindowVisible', true);
const obj = { speed: 50 };
GUI.render(() => {
const active = GUI.BeginWindow('My Window', true, visible);
if (active) {
GUI.WindowBar('My Window');
GUI.Slider(obj, 'speed', 0, 100);
}
GUI.EndWindow();
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| title | string | | Window title (used as widget identity) |
| fitContent | boolean | true | true: height adjusts to content. false: fixed height, scrollable |
| visible | boolean | Ref | undefined | Visibility state. Accepts a Ref to persist |
| x | number | Ref | undefined | Horizontal offset in pixels (maps to CSS right by default). Accepts a Ref to persist |
| y | number | Ref | undefined | Vertical offset in pixels (maps to CSS top by default). Accepts a Ref to persist |
| width | number | Ref | undefined | Width in pixels. Accepts a Ref to persist |
| height | number | Ref | undefined | Height in pixels. Accepts a Ref to persist |
BeginWindow returns true when the window is active (visible and ready for content). Always call EndWindow() after BeginWindow, regardless of the return value.
WindowBar(title) renders a draggable title bar. Unlike BeginPanel, no collapse/close buttons are added automatically — you build the bar contents yourself.
Sizing: By default, floating windows use --gui-panel-min-width and --gui-panel-min-height CSS variables for their minimum dimensions. Adding a .width_N or .height_N class to the window title overrides the default size on that axis and disables resize on it:
// Fixed width, resizable height
GUI.BeginWindow('Inspector::.width_7', true, visible);
// Fixed width and height
GUI.BeginWindow('Picker::.width_7.height_5', true, visible);AnchorName / AnchorPosition
Position a floating window relative to another widget using CSS Anchor Positioning. When anchored, the title bar drag is disabled; the browser handles positioning.
const anchorVisible = GUI.Ref('@anchorVisible', false);
GUI.render(() => {
if (!GUI.BeginPanel('My Panel')) return;
GUI.Slider(obj, 'speed', 0, 100);
const anchor = GUI.AnchorName(); // auto-generates a name from the widget's id
anchorVisible(true);
GUI.EndPanel();
});
GUI.render(() => {
if (!anchorVisible()) return;
GUI.AnchorPosition(anchor, 'right');
const active = GUI.BeginWindow('Inspector', true);
if (active) {
GUI.WindowBar('Inspector');
GUI.Number(obj, 'detail', 0, 10);
}
GUI.EndWindow();
});Anchored windows can also be created from inside a panel (e.g. in a registerTypeGUI constructor). When AnchorPosition is called before BeginWindow inside a panel, the window is automatically created at the root level:
GUI.Slider(obj, 'speed', 0, 100);
const anchor = GUI.AnchorName();
if (editorOpen()) {
GUI.AnchorPosition(anchor, 'bottom center');
GUI.BeginWindow('Editor', true, editorOpen);
GUI.WindowBar('Editor');
// ... editor content
GUI.EndWindow();
}AnchorName(name?) sets anchor-name on the previous widget's root element. Returns the anchor name string. When called with no argument, auto-generates a unique name from the widget's DOM id (cached, no allocation on subsequent frames). When called with a string, uses it directly as a CSS dashed ident (e.g. '--my-anchor').
AnchorPosition(name, area, align?) stores state consumed by the next BeginWindow(). The area parameter is passed directly as the CSS position-area value:
- Single keyword:
'top','bottom','left','right','center' - Two keywords (row column):
'bottom center','top left','center right' - Spanning:
'top span-all','bottom span-left'
Use margin utility classes on the BeginWindow label to control the gap between the anchored window and its anchor element:
GUI.AnchorPosition(anchor, 'right');
GUI.BeginWindow('Inspector::.margin_left_5'); // 5px scaled gap on the leftWhen the anchor target may not be rendered (e.g. inside a collapsible folder), skip the window with an early return to avoid orphaned positioning.
| Function | Param | Type | Description |
|----------|-------|------|-------------|
| AnchorName | name | string? | Optional anchor identifier. Auto-generated from widget id if omitted |
| | returns | string | The anchor name (for passing to AnchorPosition) |
| AnchorPosition | name | string | Anchor identifier (must match a previous AnchorName call) |
| | area | string | CSS position-area value |
| | align | string | Optional CSS align-self value (e.g. 'start', 'center') |
Popup Window
Detach a window into a separate browser popup. Content renders into a gui_popup_window container inside the popup body. The popup persists across main page reloads (reconnects via a named window).
const popupXRef = GUI.Ref('@popupX', 0);
const popupYRef = GUI.Ref('@popupY', 0);
const popupWRef = GUI.Ref('@popupW', 800);
const popupHRef = GUI.Ref('@popupH', 400);
const popupActiveRef = GUI.Ref('@popupActive', false);
const isPopup = popupActiveRef();
const open = isPopup
? GUI.BeginPopupWindow('Timeline::#timeline', popupActiveRef, popupXRef, popupYRef, popupWRef, popupHRef)
: GUI.BeginWindow('Timeline::#timeline.bottom_left', false, visible, x, y, w, h);
if (open) {
GUI.WindowBar('Timeline');
GUI.Slider(obj, 'speed', 0, 100);
// Toggle between popup and inline
if (GUI.ButtonInput(isPopup ? 'Dock' : 'Pop out')) {
popupActiveRef(!isPopup);
}
if (isPopup) GUI.EndPopupWindow(); else GUI.EndWindow();
}BeginPopupWindow(title, visible?, x?, y?, width?, height?) opens a new browser popup window and pushes its body as parent. On subsequent frames, reconnects to the same window and returns true. Position and size are synced from the live window each frame: if the user moves or resizes the popup, the stored refs are updated and the next open restores the last known geometry. When the popup is closed externally (OS close button), state is reset and visible(false) is called automatically the following frame.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| title | string | | Window title with modifiers; must include a #id (e.g. 'Title::#myId') |
| visible | Ref | undefined | Ref controlling popup visibility; set to false when the popup closes externally |
| x | number | Ref | undefined | Screen x position. Accepts a Ref to persist across sessions |
| y | number | Ref | undefined | Screen y position. Accepts a Ref to persist across sessions |
| width | number | Ref | 800 | Window width. Accepts a Ref to persist |
| height | number | Ref | 300 | Window height. Accepts a Ref to persist |
EndPopupWindow() pops the popup parent from the stack.
To close programmatically, set the visible ref to false:
popupActiveRef(false);CSS: The popup body receives gui_root and gui_popup_window classes. Anchored windows inside the popup (e.g. pickers opened from a button inside the popup) are automatically routed to the popup document when their anchor element is in the popup.
SetParent in popup mode: When BeginPopupWindow is used with a #id, SetParent('#id') from other render callbacks routes their content into the popup when active, or into the inline window otherwise:
GUI.render(() => {
GUI.SetParent('#timeline');
GUI.Slider(obj, 'speed', 0, 100); // routes to popup or inline automatically
});Folder
if (GUI.BeginFolder('Section', true)) {
// folder content
GUI.EndFolder();
}| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | | Folder label |
| expanded | boolean | Ref | undefined | true: expanded. false: collapsed. Omit: always open, non-collapsible header. Accepts a Ref to persist state |
FolderHeader
Custom folder headers let you place extra widgets (buttons, labels, etc.) inside the header row, next to the toggle:
const expanded = GUI.BeginFolderHeader('Physics', true);
GUI.Button('Reset'); // appears in the header row
GUI.EndFolderHeader();
if (expanded) {
GUI.Slider(obj, 'gravity');
GUI.Slider(obj, 'drag');
GUI.EndFolder();
}| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | | Folder label |
| expanded | boolean | Ref | undefined | true: expanded. false: collapsed. Accepts a Ref to persist state |
BeginFolderHeader returns true when expanded. Between BeginFolderHeader and EndFolderHeader, widgets are added to the header row. After EndFolderHeader, widgets go into the folder content (if expanded). Close with EndFolder like a regular folder.
Tabs
GUI.BeginTabs('myTabs');
if (GUI.Tab('First')) {
GUI.Text(obj, 'name');
}
if (GUI.Tab('Second')) {
GUI.Number(obj, 'x', 0, 100);
}
GUI.EndTabs();| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | | Tab container id |
| selected | string | Ref | '' | Default selected tab name. Accepts a Ref to persist state |
GUI.Tab(label) returns true when its tab is active.
Header
if (GUI.Header('Section', true)) {
// collapsible content
}| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | | Header label |
| expanded | boolean | Ref | undefined | true: expanded. false: collapsed. Omit: static (non-collapsible). Accepts a Ref to persist state |
Group
GUI.BeginGroup('myRow', 'div.row');
GUI.ButtonInput('A');
GUI.ButtonInput('B');
GUI.EndGroup();| Param | Type | Default | Description |
|-------|------|---------|-------------|
| id | string | | Group identifier |
| elType | string | 'div.col' | 'div.row' for horizontal, 'div.col' for vertical |
LazyItem
Container that skips rendering its children when off-screen, using IntersectionObserver. Use for long lists where most items are scrolled out of view:
for (let i = 0; i < 1000; i++) {
if (GUI.BeginLazyItem('item-' + i)) {
GUI.Number(items, i + '::"Item ' + i + '"', 0, 100);
GUI.EndLazyItem();
}
}| Param | Type | Default | Description |
|-------|------|---------|-------------|
| id | string | | Item identifier |
| elType | string | 'div.col' | Element type |
Returns false when the item is off-screen. Only call EndLazyItem() when BeginLazyItem() returns true.
SetParent
Redirect widgets to a named container:
GUI.SetParent('#col1');
if (GUI.BeginPanel('Panel in col1')) {
GUI.EndPanel();
}| Param | Type | Description |
|-------|------|-------------|
| id | string | A #widgetId, an element id, or 'body' |
PushParent / AppendParent / PopParent
Push and pop the parent widget stack. Widgets created after PushParent become children of the pushed widget. AppendParent does the same but appends new children after existing ones instead of prepending before them.
const expanded = GUI.BeginFolderHeader('Custom', true);
GUI.AppendParent(); // new widgets append after existing header content
GUI.Button('Extra');
GUI.PopParent();
GUI.EndFolderHeader();
if (expanded) {
GUI.EndFolder();
}| Function | Description |
|----------|-------------|
| PushParent(widget?) | Push widget (or the last created widget) as the current parent. New children are prepended |
| AppendParent(widget?) | Same as PushParent, but new children are appended after existing ones |
| PopParent() | Restore the previous parent |
PushId / PopId
Scope widget identity without modifying labels. All widgets between PushId and PopId are uniquely identified by the pushed id, allowing the same labels to be reused across iterations:
for (let i = 0; i < items.length; i++) {
GUI.PushId(i);
GUI.Number(items[i], 'value', 0, 100); // each is unique despite same label
GUI.Button('Delete'); // each button is unique too
GUI.PopId();
}| Function | Param | Type | Description |
|----------|-------|------|-------------|
| PushId | id | number | string | Identity scope. Accepts loop indices or string keys |
| PopId | | | Restore the previous identity scope |
SetPosition / SetPositionPercent
Set position on the previous widget. Values are in pixels or percent. Only writes to the DOM when values change (optimized for per-frame calls):
GUI.BeginGroup('marker', 'div');
GUI.SetPosition(100, 50); // left: 100px, top: 50px
GUI.SetPositionPercent(50, undefined, 50); // left: 50%, right: 50%| Param | Type | Description | |-------|------|-------------| | left | number? | Left position | | top | number? | Top position | | right | number? | Right position | | bottom | number? | Bottom position |
SetDimension / SetDimensionPercent
Set dimensions on the previous widget. Values are in pixels or percent. Only writes to the DOM when values change:
GUI.BeginGroup('box', 'div');
GUI.SetDimension(200, 100); // width: 200px, height: 100px
GUI.SetDimensionPercent(50, 100); // width: 50%, height: 100%| Param | Type | Description | |-------|------|-------------| | width | number? | Width | | height | number? | Height |
VGap / HGap
Tiny spacers that fill the gap between widgets. Unlike VSpace/HSpace (which add a full row/column of space), gaps add just the inter-widget gap amount:
GUI.VGap(); // vertical gap
GUI.HGap(); // horizontal gap
GUI.VGap(2); // taller gap (2 row units)
GUI.HGap(3); // wider gap (3 column units)Text
GUI.Text(obj, 'name');
GUI.Text(obj, 'name::"Display Name"');DisplayText / DisplayTextInput
Read-only, div-based alternatives to Text/TextInput. Use textContent instead of input .value for better performance on frequently-updated display values. Same API — drop-in replacement when editing isn't needed:
GUI.DisplayText(obj, 'status::"Status"');
GUI.DisplayTextInput(obj, 'value');Label + text input. TextInput is the standalone variant (no label).
Number
GUI.Number(obj, 'x');
GUI.Number(obj, 'x::"Position X"', -100, 100, 0.5);Label + number input. Shows a mini slider when both min and max are finite. NumberInput is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min | number | -Infinity | Minimum value |
| max | number | Infinity | Maximum value |
| step | number | 0 | Step increment. 0 = free |
Slider
GUI.Slider(obj, 'volume', 0, 1, 0.01);Label + range slider + number input. SliderInput is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min | number | -Infinity | Minimum value |
| max | number | Infinity | Maximum value |
| step | number | 0 | Step increment |
Measure
GUI.Measure(obj, 'width', 0, 500, 1);Label + range slider + measure input. Like Slider but the text input accepts unit suffixes (px, %, em, etc.). The value can be a number or a string with unit. MeasureInput is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min | number | -Infinity | Minimum value |
| max | number | Infinity | Maximum value |
| step | number | 0 | Step increment |
Vector
GUI.Vector(obj, 'position::"Pos"', -500, 500, 1);Label + number inputs for each component. Works with arrays ([x, y, z]) and objects ({x, y, z}). VectorInput is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min | number | -Infinity | Minimum value per component |
| max | number | Infinity | Maximum value per component |
| step | number | 0 | Step increment |
Checkbox
GUI.Checkbox(obj, 'enabled');Label + checkbox. CheckboxInput is the standalone variant.
Select
GUI.Select(obj, 'choice::"Pick one"', ['Apple', 'Banana', 'Cherry']);
GUI.Select(obj, 'mode', { a: 'Option A', b: 'Option B' });Label + dropdown. SelectInput is the standalone variant.
| Param | Type | Description | |-------|------|-------------| | list | array | object | Array: options are values, selected value is the index. Object: keys are values, values are display labels |
Color
GUI.Color(obj, 'tint');Label + color picker. ColorInput is the standalone variant (swatch only).
Supported value formats: hex strings ('#e74c3c', '#3498dbaa'), CSS strings ('rgb(46, 204, 113)', 'tomato'), integers (0x1abc9c), arrays ([0.9, 0.6, 0.1]), and objects ({r: 1, g: 0.2, b: 0.4}).
Button
if (GUI.Button('Click me')) {
// clicked this frame
}
if (GUI.Button('<{play}>Play')) {
// button with icon
}Full-row button. Returns true on click. ButtonInput is the inline variant (use in rows).
Icon syntax: <{iconName}> before the label text.
ToggleButton
GUI.ToggleButton('Toggle', GUI.Ref('toggled', false));Full-row toggle. ToggleButtonInput is the inline variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | | Button label |
| toggled | boolean | Ref | undefined | Initial state. Accepts a Ref to persist state |
Inside a BeginToggleGroup, toggle buttons act as radio buttons:
GUI.BeginToggleGroup('align', 'Left');
GUI.ToggleButtonInput('Left');
GUI.ToggleButtonInput('Center');
GUI.ToggleButtonInput('Right');
GUI.EndToggleGroup();Point2D
const obj = { pos: [0.5, 0.5] };
GUI.Point2D(obj, 'pos', 0, 1, 0, 1);
const obj2 = { pts: [0.2, 0.8, 0.5, 0.3, 0.8, 0.7] };
GUI.Point2D(obj2, 'pts::"Multiple"', 0, 1, 0, 1);Label + 2D draggable area. Value is a flat array of [x, y] pairs. Point2DArea is the standalone variant. Point2DInput is an individual point handle inside a Point2DArea.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| minX | number | 0 | Minimum X value |
| maxX | number | 1 | Maximum X value |
| minY | number | 0 | Minimum Y value |
| maxY | number | 1 | Maximum Y value |
| step | number | 0 | Snap increment |
| paddingX | number | 0 | Horizontal padding (fraction of range) |
| paddingY | number | 0 | Vertical padding (fraction of range) |
| height | number | undefined | Height in row units. Omit for auto (aspect ratio from ranges) |
Scrubber
GUI.Scrubber(obj, 'time', 0, 100, 0, 100, 1);
GUI.Scrubber(obj, 'time::"Clamped"', 10, 80, 0, 100, 1, true);Label + scrubber slider + number input. ScrubberInput is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| rangeStart | number | Ref | | Start of the visible range |
| rangeEnd | number | Ref | | End of the visible range |
| min | number | -Infinity | Minimum value |
| max | number | Infinity | Maximum value |
| step | number | 0 | Step increment |
| clamped | boolean | Ref | undefined | When set, shows a clamp toggle and range handles |
PlotLine
// Data: flat array of [x0, y0, x1, y1, ...]
const data = { sine: new Array(120) };
for (let i = 0; i < 60; i++) {
data.sine[i * 2] = i / 59;
data.sine[i * 2 + 1] = Math.sin(i / 59 * Math.PI * 4);
}
GUI.PlotLine(data, 'sine', 0, 1, -1, 1, 4);Label + SVG line chart. PlotLineCanvas is the standalone variant. Use NaN for x to create gaps between segments.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| minX | number | undefined | Minimum X bound. Omit for auto |
| maxX | number | undefined | Maximum X bound. Omit for auto |
| minY | number | undefined | Minimum Y bound. Omit for auto |
| maxY | number | undefined | Maximum Y bound. Omit for auto |
| height | number | undefined | Height in row units |
| lineWidth | number | 2 | SVG stroke width |
Monitor
GUI.Monitor(stats, 'fps::"FPS".color_green', 0, 100);Real-time rolling value plot. Appends the current value each frame. MonitorCanvas is the standalone variant.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min | number | 0 | Minimum value |
| max | number | 100 | Maximum value |
| step | number | 0 | Step increment |
| storeArray | number[] | undefined | Optional shared data array |
| height | number | 2 | Height in row units |
| interval | number | 0 | Update interval in ms. 0 = every frame |
VSpace / HSpace
GUI.VSpace(); // vertical space (1 row height)
GUI.VSpace('.height_3'); // taller vertical space
GUI.HSpace(); // horizontal space (1 row width)
GUI.HSpace('.width_3'); // wider horizontal spaceBlank spacers for layout. VSpace adds vertical spacing, HSpace adds horizontal spacing (useful inside .row groups). Both accept an optional labelKey string for size classes.
Label
GUI.Label('My label');
GUI.Label('<{play}>Play label');Standalone label element.
Icon
GUI.Icon('<{play}>');
GUI.Icon('<{chevronRight}>');Standalone icon element. Uses the <{iconName}> syntax.
Html
GUI.Html('p.padded', 'Simple paragraph');
GUI.Html('p.padded.color_sky', '<strong>Bold</strong> and <em>italic</em>');
GUI.Html('span.color_6[title=My tooltip]', 42);Arbitrary HTML element. The elType string supports .class and [attr=value] syntax. Content updates efficiently — only the innerHTML changes, the element is reused.
| Param | Type | Description |
|-------|------|-------------|
| elType | string | Element tag with optional classes and attributes (e.g. 'div.padded.color_red[title=Hello]') |
| html | string | number | Inner HTML content (updates dynamically) |
IsKeyDown
Checks if a key is currently held. Returns true every frame while the key is held. Useful for modifier checks (e.g. Cmd+click).
GUI.render(() => {
if (GUI.IsKeyDown('Meta')) {
// Cmd/Ctrl is being held
}
});| Param | Type | Description |
|-------|------|-------------|
| code | string | Key code (e.g. 'KeyS', 'Escape'). Use 'Meta' for Cmd (Mac) or Ctrl (Windows/Linux) |
IsKeyPressed
Checks if a key was pressed this frame. Returns true for one frame only (on the frame the key is first pressed).
GUI.render(() => {
// Cmd/Ctrl+E to reset (Meta held + E pressed once)
if (GUI.IsKeyDown('Meta') && GUI.IsKeyPressed('KeyE', true)) {
obj.speed = 1;
}
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| code | string | | Key code (e.g. 'KeyS', 'Escape'). Use 'Meta' for Cmd (Mac) or Ctrl (Windows/Linux) |
| preventDefault | boolean | false | When true, blocks the browser default for this key when Cmd/Ctrl is held |
The preventDefault flag registers the key so that future keydown events with Cmd/Ctrl held will have their browser default suppressed. This only affects key presses combined with Cmd/Ctrl — plain key presses (e.g. typing 'S' in a text input) are never blocked.
WantCaptureKeyboard
Returns true when a GUI input element (text input, number input, select, etc.) currently has keyboard focus. Useful to prevent your app's keyboard shortcuts from firing while the user is typing in the GUI:
document.addEventListener('keydown', (e) => {
if (GUI.WantCaptureKeyboard()) return; // GUI has focus, skip app shortcuts
if (e.code === 'Space') togglePlay();
});widgetsMap
A Map<string, Widget> of all widgets keyed by their #id. Useful for looking up widget instances from external code:
const widget = GUI.widgetsMap.get('#myWidget');colors
The color palette object. Keys are color names (e.g. 'red', 'sky', 'king'), values are hex strings:
GUI.colors.sky // '#05DBE9'
GUI.colors.king // '#4D9CFF'Gotchas
A few things to keep in mind when working in immediate mode.
Avoid allocations in render callbacks. Your render function runs every frame. Don't create objects, arrays, or concatenate strings inside it. Declare data outside and mutate it.
// Bad: allocates a new object and a new array every frame
GUI.render(() => {
GUI.Vector({ pos: [x, y] }, 'pos');
});
// Good: reuse the same object
const obj = { pos: [0, 0] };
GUI.render(() => {
GUI.Vector(obj, 'pos');
});Avoid DOM queries in render callbacks. Methods like querySelector, closest, getBoundingClientRect, offsetHeight, etc. are expensive when called every frame. Cache references outside the render loop, or gate them behind a condition so they only run once.
Don't change object shapes. Don't add new properties to objects after creation. This causes V8 hidden class transitions and deoptimizations. Declare all properties upfront. If you need to add a value dynamically, use GUI.Ref instead.
// Bad: adding properties later
const obj = {};
obj.speed = 1;
// Good: declare everything at creation
const obj = { speed: 1 };
// Also good: use a Ref for dynamic/ad-hoc values
GUI.Number(GUI.Ref('speed', 1), 'Speed', 0, 10);Conditional widgets are fine. Unlike retained mode GUIs, you can freely show or hide widgets with regular if statements. Widgets that stop being declared are automatically removed from the DOM.
GUI.render(() => {
GUI.Checkbox(obj, 'advanced');
if (obj.advanced) {
GUI.Number(obj, 'detail', 0, 100);
}
});Always check the return value of Begin* and Header widgets. BeginPanel returns false when the panel is hidden. BeginFolder, BeginFolderHeader, and Header return false when collapsed. When the return value is false, skip the content and the matching End* call.
GUI.render(() => {
if (GUI.BeginPanel('Settings')) {
if (GUI.BeginFolder('Advanced', false)) {
GUI.Number(obj, 'detail', 0, 100);
GUI.EndFolder();
}
const expanded = GUI.BeginFolderHeader('Custom', true);
GUI.Button('Reset');
GUI.EndFolderHeader();
if (expanded) {
GUI.Number(obj, 'detail', 0, 100);
GUI.EndFolder();
}
if (GUI.Header('Debug', false)) {
GUI.Checkbox(obj, 'wireframe');
}
GUI.EndPanel();
}
});You can call GUI.render() as many times as you want. Each call registers a separate callback that runs every frame. Widgets are appended to a default panel if none is declared, or you can target a specific panel with SetParent.
// Each render callback can declare its own panel
GUI.render(() => {
if (GUI.BeginPanel('Panel A')) { /* ... */ GUI.EndPanel(); }
});
// Or add widgets to an existing panel from another callback
GUI.render(() => {
GUI.SetParent('#panelA');
GUI.Number(obj, 'x', 0, 100);
});