tweaks
v0.1.8
Published
A GUI library with an immediate mode API to inspect, tweak and save values back to your code
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';
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 { TWEAKS, GUI, Float, Int, Bool, Color } from 'tweaks';
import { tweaks } from './tweaks.js';
TWEAKS.sync(tweaks);
const params = TWEAKS.group('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 TWEAKS.sync():
import { TWEAKS } from 'tweaks';
import { tweaks } from './tweaks.js';
TWEAKS.sync(tweaks);For browser-only persistence (no server), you can use the localStorage mode:
TWEAKS.sync('localStorage');Registering tweaks
Create a group with a schema:
const myTweaks = TWEAKS.group('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 = TWEAKS.group('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 = TWEAKS.group('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 = TWEAKS.group('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;Built-in types
| Type | Factory | Parameters | Widget |
|------|---------|------------|--------|
| Float | Float(default, min, max, step) | All optional | Number input |
| Int | Int(default, min, max) | All optional, step forced to 1 | Number input |
| Str | Str(default) | Optional | Text input |
| Bool | Bool(default) | Optional | Checkbox |
| Color | Color(default) | Optional | Color picker |
Rendering tweaks
GUI.Tweaks(group) renders all registered properties in a collapsible folder and adds SAVE, REVERT, RESET buttons to the parent panel:
GUI.render(() => {
if (GUI.Tweaks(myTweaks)) {
// A value changed
}
});Or as a regular JS Object properties:
GUI.render(() => {
GUI.Number(myTweaks, 'speed');
GUI.Number(myTweaks, 'count');
});Custom types
Register a new type with TWEAKS.registerType(name, defaultValue, constructorFn). The constructor function renders the GUI for this type and returns true when the value changes. Extra parameters are available as prop.n1 through prop.n7.
The constructor receives a prop object where prop.value holds the current value and prop.n1 through prop.n7 hold the extra parameters from the factory. It must return true when the value changes.
Since prop.value here is an object, it can be passed directly as the target for sub-widgets:
// Value is { from, to, midpoint }, rendered as a CSS linear-gradient string
const defaultGradient = { from: '#ff0000', to: '#0000ff', midpoint: 50 };
const Gradient = TWEAKS.registerType('Gradient', defaultGradient, (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;
});
const g = TWEAKS.group('Theme', {
sky: Gradient({ 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 returned by registerType accepts (defaultValue, n1, n2, ...n7). A method with the same name is also added to all groups:
g.Gradient('sky', { from: '#0a1628', to: '#4D9CFF', midpoint: 60 });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';
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);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]) |
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.Number(arr, '0::"First item"', 0, 100); // numeric keyAvailable color classes: .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.
Background colors: .color_bg_1 through .color_bg_12.
Layout classes: .width_1 through .width_6, .width_full, .width_half, .flex_1 through .flex_6, .padded.
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 localStorage. It survives page reloads.
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 |
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 |
| right | number | Ref | undefined | Right offset in pixels. Accepts a Ref to persist |
| top | number | Ref | undefined | Top offset in pixels. 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.
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 (no 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 |
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' |
Text
GUI.Text(obj, 'name');
GUI.Text(obj, 'name::"Display Name"');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 |
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 + canvas 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 |
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 |
Space
GUI.Space();Visual separator.
Label
GUI.Label('My label');
GUI.Label('<{play}>Play label');Standalone label element.
Html
GUI.Html('p.padded', 'Simple paragraph');
GUI.Html('p.padded.color_sky', '<strong>Bold</strong> and <em>italic</em>');Arbitrary HTML element.
| Param | Type | Description |
|-------|------|-------------|
| elType | string | Element tag with optional classes (e.g. 'div.padded.color_red') |
| html | string | number | Inner HTML content |
IsKeyDown
Checks if a key is currently pressed. Use inside a render callback to react to keyboard input every frame.
GUI.render(() => {
// Cmd/Ctrl+E to reset
if (GUI.IsKeyDown('Meta') && GUI.IsKeyDown('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 |
Returns true when the key is currently pressed.
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.
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);
});