tweaks
v0.5.0
Published
A GUI library with an immediate mode API to inspect, tweak and save values back to your code
Maintainers
Readme
Tweaks
A JavaScript GUI library to easily tweak values and save changes back to your code.
Think of it as a mix of lil-gui and Tweakpane with the immediate mode API of 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, with no bindings or callbacks to keep in sync.
- UI state persists across reloads: folder open and close states, panel positions, and scroll positions are all remembered automatically.
- Save changes back to your code: tweak values in the browser, hit save, and the tweaks server rewrites the original source in place. No more copy pasting values from the GUI into your code.
- Docked mode: wraps the page in an iframe so the GUI never covers what you are working on.
Getting started
Tweaks saves by rewriting your source files, so it needs a local server running alongside your app. Install the package, then pick the setup that matches your project:
npm install tweaksWithin an existing Vite server setup
Vite projects need no separate server. The plugin mounts the tweaks routes on the vite dev server origin and injects the docked mode boot tag into served html, so saving and docked mode work with no further setup:
// vite.config.js
import { defineConfig } from 'vite';
import tweaks from 'tweaks/vite';
export default defineConfig({
plugins: [tweaks()],
});Saves run the same codemod as npx tweaks and read the same config file. The plugin is dev only, builds ship untouched. SSR frameworks need one manual tag, see Docked mode.
Without a Vite server
Start the tweaks server in your project folder:
npx tweaks # default port 7348
npx tweaks --port 3000 # custom portIt holds the tweaks store and writes each SAVE back into your source, see Saving to source. It does not serve your app, so run your own dev server alongside it.
The panel finds the server on its own, whatever port it runs on. It checks the page's own origin first, then scans localhost around the default port, and any server it reaches points it at every other running one. When several servers run at once, the page connects to the one whose scan.roots hold its own calls, so each project reaches its own server. For a different origin or path set GUI.settings.serverEndpoint = 'https://host/__tweaks__/'. CORS is open by default.
Manage running servers:
npx tweaks list # list running tweaks servers
npx tweaks kill # stop every tracked server
npx tweaks kill --port 3000 # stop one
npx tweaks --version # print the installed versionWith no tweaks server reachable the panel goes offline. Unsaved edits and panel state still survive reloads through the sessionStorage overlay, but there is no commit target, see Persistence.
Both setups read an optional config file at your project root, mainly to narrow which folders a save searches. See Saving changes for that file, how values reach your source, and the version and undo safety nets.
Creating Tweaks
Tweaks are values that sync with the GUI and can be saved back to your code after a change.
import { createTweaks, Int } from 'tweaks';
import { GUI } from 'tweaks/gui';
const particlesParams = createTweaks('Particles', {
count: Int(100, 10, 500), // Force an Int with a 10 to 500 range
speed: 1.5,
visible: true,
color: '#4D9CFF',
});
function refreshParticles() {
// Particles code
}
GUI.render(() => {
// Render particlesParams tweaks and call refreshParticles when a value changes
if (GUI.Tweaks(particlesParams)) refreshParticles();
});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'),
});Inferred types
A schema value can also be a bare literal, and Tweaks infers the type from it:
const myTweaks = createTweaks('My Tweaks', {
speed: 1.5, // number -> Float, bounds auto-inferred
active: true, // boolean -> Bool
size: '320px', // a number with a unit -> Num
tint: '#ff6600', // a color string -> Color
name: 'default', // any other string -> Str
});A number infers Float, a boolean Bool, and a string infers Num when it is a number with a unit (320px, 1.5em, 50%), Color when it is a color (3, 4, 6, or 8 digit hex, or a complete rgb(...), rgba(...), hsl(...), hsla(...)), otherwise Str, so a plain numeric string like '42' stays a Str. A nested plain object becomes an auto-folder (see below), and a value with no matching type, like a function, is left out. Pass an explicit factory such as Float(1.5, 0, 10, 0.1) when you want specific bounds, a step, or a forced type. The same inference applies to group.add('size', '320px'), and the inferTweak(value) export runs it programmatically, returning the inferred descriptor or null for a value with no matching type.
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 | 0 | 100 | 0.1 | 1 | | up to 1 | 0 | value x 3 | 0.001 | 1 | | up to 10 | 0 | value x 3 | 0.01 | 1 | | up to 100 | 0 | value x 3 | 0.1 | 1 | | above 100 | 0 | value x 3 | 1 | 1 | | negative | -abs(value) x 3 | 0 | (same as above) | 1 |
A fractional default narrows the step to its own precision, up to six decimals, so Float(150.25) steps by 0.01 rather than 1.
Negative defaults flip the range to end at 0:
Float(50) // slider 0 to 150, step 0.1
Float(-150) // slider -450 to 0, step 1
Float(0.5) // slider 0 to 1.5, step 0.001
Float(5) // slider 0 to 15, step 0.01
Float(500) // slider 0 to 1500, step 1
Float(50, 0, 200) // explicit bounds, step inferred when omittedNum (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: the stored value is the selected element. The default can be the element or an index.
Select(['low', 'medium', 'high']) // default 'low', the first element
Select(['low', 'medium', 'high'], 'medium') // default 'medium'
Select(['low', 'medium', 'high'], 1) // an index also works, it resolves to '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'Auto-folders
Tweaks are organized into collapsible folders via two mechanisms that can be combined freely.
Nested plain objects in the schema create one level of folder:
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.5Slash-separated keys create deep folder hierarchies of any depth:
const params = createTweaks('Config');
params.Float('physics/advanced/threshold', 0.1);
params.Float('physics/advanced/iterations', 10);
params.Float('physics/basic/gravity', 9.8);
params.Float('render/quality', 1);Renders as:
Config
├─ physics
│ ├─ advanced
│ │ ├─ threshold
│ │ └─ iterations
│ └─ basic
│ └─ gravity
└─ render
└─ qualityBoth forms can be combined. A slash in a top-level schema key opens the deep path, the nested object groups tweaks inside it:
const params = createTweaks('Config', {
'physics/advanced': {
threshold: Float(0.1),
iterations: Int(10),
},
'physics/basic': {
gravity: Float(9.8),
},
});The leaf segment becomes the row label; intermediate segments become folder headers. Folder open/close state persists across reloads. Editing a tweak inside a nested folder propagates the unsaved indicator (the dot) up every ancestor folder, so a collapsed branch still flags that something inside has unsaved changes.
Folder values save back to your source like any other, whether you wrote them as nested objects, slash keys, or a mix of both, and a params.Float('a/b/c', value) registration is updated in place too.
Or 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));Reading and writing values
Read or write values directly on the group like a regular JS Object:
console.log(myTweaks.speed); // 1
myTweaks.speed = 5;Reads go through a live getter, so read the group each time in a render or animation loop.
Destructuring like const { speed } = myTweaks, or caching myTweaks.speed in a setup-time const, copies the value once and goes stale, the copy never sees later edits from the panel.
Rendering tweaks
GUI.Tweaks(group) renders all registered properties at the call position and registers the group with the parent panel's SAVE / REVERT / version status bar, which sits at the top of the panel content, toggled by the window bar's status button:
GUI.render(() => {
if (!GUI.BeginPanel('Settings')) return;
GUI.Tweaks(myTweaks);
GUI.BeginGroup('actions', 'div.tweaks_row');
if (GUI.ButtonInput('Reset')) reset();
GUI.EndGroup();
GUI.EndPanel();
});In the example above, the action buttons render below the tweaks, and the status bar keeps its place above both.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| tweakGroup | TweakGroup | | Group returned by createTweaks() |
| filter | string | '' | Segment path narrowing what renders, a trailing * keeps direct children only. See Filtering |
| exclude | string[] | undefined | Skip these exact keys |
Returns true the frame a value changes.
Filtering
Pass a filter 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();
});The filter is a segment path with one glob rule: a trailing * matches exactly one more segment, so it renders direct children of the path only. The bare '*' renders the top-level keys, 'physics/*' the direct children of physics. An optional third argument drops exact keys:
const HIDDEN = ['id'];
GUI.render(() => {
if (!GUI.BeginPanel('Overview')) return;
GUI.Tweaks(params, '*', HIDDEN); // top-level rows only, id hidden
GUI.EndPanel();
});Rows rendered through any filter form keep their full chrome: revert buttons, unsaved dots, and undo history all work as usual.
The exclude array is read during the call and never stored, so one shared module-level array like HIDDEN above is all you need. Don't write the literal inline in the render callback, that allocates a new array every frame (see Gotchas).
TweaksFolder / EndTweaksFolder
Wrap GUI.Tweaks() calls in TweaksFolder / EndTweaksFolder to group them inside a collapsible folder while still sharing the parent panel's SAVE and REVERT bar:
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 |
| | unsaved | boolean | undefined | Lights the folder's unsaved dot (and its ancestors'), for hosts rendering raw Tweak rows whose unsaved state the automatic Tweaks flagging cannot see |
| EndTweaksFolder | | | | Closes the folder |
TweaksStatusBar
Registers a tweak group with the parent panel's status bar (SAVE, REVERT, and the version selector) without rendering any tweaks UI. Returns true when a value in the group has changed:
GUI.render(() => {
if (!GUI.BeginPanel('Controls')) 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');
});Tweak (single property)
GUI.Tweak(prop, labelKey?) renders one property through its registered type constructor, with no folder chrome. Unlike GUI.Tweaks it takes a bare StateProp instead of a group, so it works for a transient property held outside any registered group, for example a merged or proxy row whose value you write through to the real properties yourself. Returns true the frame the value changes.
// prop is a StateProp-shaped object: { type, value, labelKey, n1, n2, ... }
GUI.render(() => {
if (GUI.Tweak(prop)) {
// value changed, apply it wherever it belongs
}
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| prop | StateProp | | The property to render, dispatched on prop.type |
| labelKey | string | prop.labelKey | Row label and key, see LabelKey syntax |
A change still records one undo history entry, exactly like a group-rendered row, so Cmd Z works the same whichever way the row was rendered. Several edits in the same frame coalesce into a single entry.
SetTweakState (manual row state)
GUI.SetTweakState(unsaved, pending, onRevert?) applies native tweak state to the last emitted row. Use it after Tweak or EndGroup. The call stores state on the row widget, creates no frame object, and adds the revert control once when onRevert is present. It does not register a panel SAVE bar because no group owns the row.
GUI.BeginGroup('name', 'div.tweaks_row.tweaks_text_row', target);
GUI.Label('name');
GUI.TextInput(target, 'name');
GUI.EndGroup('name');
GUI.SetTweakState(nameChanged, savePending, revertName);| Param | Type | Default | Description | |-------|------|---------|-------------| | unsaved | boolean | | Adds the native unsaved state | | pending | boolean | | Adds the native pending state | | onRevert | function | | Adds the native revert control |
GetTweaksGroup (render context)
A custom type constructor registered with registerTypeGUI receives two arguments, the property it renders and its labelKey, and nothing else. Some widgets need more than their own property, for example a number whose row must show a unit the user edits on a sibling row. GUI.GetTweaksGroup() returns the group GUI.Tweaks is rendering at that moment. Read a sibling value directly as group[key], or reach the sibling property object itself through group.nameSpace[key].
// A group with a plain unit property and a custom-typed speed property.
const car = createTweaks('Car', {
unit: 'km/h',
topSpeed: { type: 'Speed', defaultValue: 250 },
});
const emptyUnit = { value: '' };
// The Speed row renders its number input, then the current value of the
// sibling unit property as a trailing label.
registerTypeGUI('Speed', (prop, labelKey) => {
const group = GUI.GetTweaksGroup();
const unitProp = group ? group.nameSpace.unit : emptyUnit;
GUI.BeginGroup(labelKey, 'div.tweaks_row');
GUI.Label(labelKey);
const changed = GUI.NumberInput(prop, 'value');
GUI.DisplayTextInput(unitProp, 'value::.tweaks_width_2');
GUI.EndGroup(labelKey);
return changed;
});
GUI.render(() => {
GUI.Tweaks(car);
});The function returns null when no GUI.Tweaks render is running. This includes GUI.Tweak, which renders one bare property that belongs to no group, so a constructor that renders through both entry points needs a fallback like the empty unit above.
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?, exclude?)
Returns true if the group has any values that differ from their saved state. The arguments select keys exactly like GUI.Tweaks does: filter is a segment path where a trailing * keeps direct children only, exclude skips exact keys:
if (params.hasUnsaved()) {
// at least one value was changed since the last save
}
if (params.hasUnsaved('physics')) {
// a physics value was changed
}
if (params.hasUnsaved('*', ['id'])) {
// a top-level value other than id was changed
}remove(key) / removeMatching(prefix)
Drop registered props from the group at runtime. remove(key) drops a single leaf; removeMatching(prefix) drops every key whose path starts with prefix + '/'. Both clear the persisted entry so the next save no longer writes it.
params.remove('speed'); // drop one key
params.removeMatching('physics'); // drop physics/gravity, physics/friction, ...removeMatching batches a single schema-change notification, so multi-field deletes coalesce into one save.
Hooks
hooks (exported from tweaks) lets external code observe edits. Register a listener with add; listeners cannot be removed, so register once at module load and keep them cheap.
import { hooks } from 'tweaks';
hooks.onAfterMutation.add(() => { needsRefresh = true; });| Hook | Fires |
|------|-------|
| onAfterMutation | After any value edit: a widget interaction, a direct group setter write, or a GUI.Tweak change. The library itself uses it to schedule undo history capture |
| onSaveState | After save bookkeeping changes, for example when a SAVE round-trip completes |
Several edits in one frame fire onAfterMutation several times but the library coalesces them into a single history capture at the end of the frame. Keep your own listeners equally cheap, a flag write is the right shape, heavy work belongs in the render loop behind that flag.
Custom types
A custom type has up to three concerns, each registered independently:
registerType(name, defaultValue)registers the data type and returns a factory. No GUI dependency, this is all you need for sync and persistence.registerTypeGUI(name, constructorFn, codec?)registers the GUI renderer (the widget) and, optionally, the source codec that writes the value back to your code on save.
Minimal example
A Gradient type, end to end, data plus widget plus save:
import { registerType } from 'tweaks';
import { GUI, registerTypeGUI } from 'tweaks/gui';
// 1. Data, returns a factory and adds a g.Gradient(...) method to every group.
const Gradient = registerType('Gradient', { from: '#000', to: '#fff' });
// 2. Widget, prop.value is the record, pass it straight to sub-widgets.
registerTypeGUI('Gradient', (prop, labelKey) => {
let changed = false;
GUI.Label(labelKey);
GUI.BeginGroup(labelKey, 'div.tweaks_row');
if (GUI.ColorInput(prop.value, 'from')) changed = true;
if (GUI.ColorInput(prop.value, 'to')) changed = true;
GUI.EndGroup(labelKey);
return changed;
}, {
// 3. Save, return the source that goes inside Gradient(...), the wrapper name is kept for you.
encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}' }` }),
});
// Use it like a built-in. On save, Gradient({ ... }) is rewritten in place.
const theme = createTweaks('Theme', { sky: Gradient({ from: '#0a1628', to: '#4D9CFF' }) });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.
For values that can't be JSON-serialized, hand-built TweakRegister descriptors can carry a function in the f1 slot. It lands on prop.f1, skips persistence, and survives schema-match checks since defaultValue itself stays serializable. Useful for custom types that accept function values from the user, for example MyType(el => computeValue(el)).
registerTypeGUI
registerTypeGUI(name, constructorFn, codec?) registers the GUI renderer for a type. The constructor runs every frame during rendering and must return true when the value changes. The optional codec is a { encode } object used by server mode to write saved values back to source.
Import from tweaks/gui (the renderer is only useful when the GUI is loaded):
import { registerTypeGUI } from 'tweaks/gui';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.tweaks_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.
A third argument can carry the source codec for the type, used when saving to source:
registerTypeGUI('Gradient', constructorFn, {
encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}', midpoint: ${value.midpoint} }` }),
});See Per-type codecs for the full save flow.
The prop object (StateProp reference)
Type constructors, codecs, and GUI.Tweak all work with the same prop object. These are the fields meant for you:
| Field | Type | Description |
|-------|------|-------------|
| type | string | Type name the constructor is dispatched on |
| value | any | Current value. Read it to render, write it to change |
| saved | any | Committed baseline. A row shows as modified when value differs from saved |
| labelKey | string | Default row label and widget identity, see LabelKey syntax |
| name | string | Leaf segment of the key, the part after the last slash |
| folder | string | Folder prefix for nested keys, empty string when top-level |
| n1 … n7 | number | Numeric slots. Built-ins use n1/n2/n3 as min/max/step, custom types use all seven freely |
| s1 | string | null | String slot for custom types, null means uninitialized |
| f1 | function | null | Function slot for custom types. Skips persistence, see registerType |
| list | array | object | Select options |
| symmetric | boolean | Render numeric bounds as [-max, max] instead of [0, max] |
A constructor writes value and, when it owns them, its n*/s1 slots. The remaining fields (used, order, pending, folderSegs) are library bookkeeping, treat them as read-only.
inferNumericBounds / inferNumericBoundsFromMagnitude
Helpers exposed on GUI for custom types that wrap a numeric prop and want the same auto-bounds behavior as the built-in Float / Int / Num. Useful inside a registerTypeGUI constructor when the user passed only a default value.
registerTypeGUI('MyNumber', (prop, labelKey) => {
if (prop.n1 === undefined || isNaN(prop.n1)) GUI.inferNumericBounds(prop);
return GUI.Number(prop, labelKey, prop.n1, prop.n2, prop.n3);
});| Function | Signature | Purpose |
|----------|-----------|---------|
| inferNumericBoundsFromMagnitude | (magnitude, symmetric?) → { n1, n2, n3 } | Pure: derives [min, max, step] from a reference magnitude. Negative magnitudes flip the range to end at 0; pass symmetric: true for [-max, max] |
| inferNumericBounds | (prop, symmetric?, minOverride?, maxOverride?, stepOverride?) | Writes the inferred bounds onto prop.n1 / n2 / n3, using prop.saved as the magnitude. Each override skips the corresponding inferred value |
Bounds table is the same as the Auto-inferred bounds section above.
Saving changes
Config file
Optional. Run npx tweaks init to scaffold a tweaks.config.json at your project root, or write one:
{
"$schema": "./node_modules/tweaks/config.schema.json",
"port": 3000,
"scan": { "roots": ["src"] }
}The dev server reads it at startup, so restart npx tweaks after changing it. The $schema line gives tooling hints and validation, and works with any project. The server reads this plain JSON file; your bundler does not.
| Key | Default | Description |
|-----|---------|-------------|
| port | 7348 | Port the dev server binds to. |
| scan.roots | whole project | Files, folders, or glob patterns to search for saves, relative to the project root. |
| scan.ext | js, ts, jsx, tsx, html | File extensions to search. |
| scan.exclude | node_modules | Extra directory names to skip. |
scan is the main reason to add a config, it narrows the source search so a save is faster and more precise on a large project. It is server-side only.
scan.roots takes any mix of folders, files, and glob patterns, relative to the project root:
- One folder, searched recursively:
"roots": ["src"] - One file:
"roots": ["src/anim.js"] - Several folders:
"roots": ["src", "lib"] - Several files:
"roots": ["src/a.js", "src/b.js"] - Folders and files together:
"roots": ["src", "demos/hero.js"] - Every subfolder of one directory:
"roots": ["examples/*"] - One file per subfolder:
"roots": ["examples/*/index.js"] - Anywhere in the tree:
"roots": ["src/**/*.anim.js"]
A folder is searched recursively and filtered by scan.ext and scan.exclude. A named file is searched directly, whatever its extension. Anything outside roots is never searched, so a save there fails rather than touching the wrong file.
A root containing *, ?, [, or { is a pattern, expanded once at startup:
| | |
|---|---|
| * | any run of characters inside one path segment, demo*.js |
| ** | any depth, src/**/*.js |
| ? | exactly one character, demo?.js |
| […] | one character from a set, demo[12].js |
| {…} | one of several alternatives, {anim,motion}/*.js |
A root that exists on disk is always taken literally, whatever it is spelled with, so a folder really named [legacy] resolves as itself rather than as a character class.
Each match is then treated as a plain folder or file, so scan.ext and scan.exclude still apply, and excluded directories never enter the roots even when a pattern would reach them. Restart the server after adding a file a pattern should pick up, patterns are not re-expanded per save.
A pattern that matches nothing contributes nothing and the server says so on startup. That narrows the save rather than widening it, a typo makes saves fail loudly instead of silently searching your whole project.
Multi-file projects are the reason to reach for a pattern. If your animations live one per demo folder, "roots": ["examples/*/index.js"] keeps every save pinned to the right file, and a call the panel cannot place fails with a reason instead of landing in a neighbor.
A custom port needs no browser setup: a server on a non-default port also parks a tiny relay on the free default port that announces where the real servers run, so the panel finds it wherever it binds.
Persistence
SAVE (or Cmd S) writes the current values back into your source file, see Saving to source below. This needs the dev server running.
Without the dev server the panel runs offline: the status dot is grey, SAVE is disabled, and everything else keeps working. Your unsaved changes, the version selector, and the panel's own state such as which folders are open all survive a page reload, so you never lose work in progress. Start the dev server and reload to go online and enable SAVE.
To skip the localhost probe entirely in a production build, set GUI.settings.probeOnLoad = false before the first render; the panel then stays offline with no network request.
Versions and undo
Two safety nets sit under every edit.
Undo and redo. Cmd Z undoes and Cmd Shift Z redoes (Ctrl on Windows) while a panel is open. Each discrete edit is one entry and a whole drag counts as one, so undoing a slider drag restores the value it started from. The last 500 edits are kept and survive a page reload along with your unsaved changes.
Versions. The status bar at the top of the panel holds SAVE, REVERT, and the version selector, which switches between SAVED, the values in your source, and any number of working versions. Clicking the status button in the window bar, the dot with the unsaved count, shows or hides the bar. The + button forks the current values into a new version, and the first edit you make while viewing SAVED forks one automatically, so SAVED always reflects what is actually saved. Switching versions keeps each version's edits, the delete button removes the active version, and REVERT returns every value to SAVED. Undo tracks all of it: undoing past a version switch or delete restores the values, the active selector, and the version list together.
Saving to source
With the dev server running, SAVE writes your changes straight back into your source file. If you wrote Float(60), it becomes Float(73) right where it is. Only the value you changed is touched, the rest of the file, your code, formatting, and comments, stays exactly as it was. There is no separate save file to keep in sync.
This works in .js, .ts, .jsx, and .tsx files, so you save the same way whether your project is plain JavaScript, TypeScript, or React. A value written with a type cast like createTweaks('config', { ... } as Config) saves fine too. Calls written straight into an .html page save as well, inside a <script type="module"> block, and the markup around it is left untouched.
A few files cannot be written to, and there the save is skipped and the file is left untouched: files using the TypeScript satisfies keyword or an old-style <Type>value cast, and Vue .vue or Svelte .svelte component files. Keep your tweak calls in a regular .js or .ts file and saving always works.
While the server is running the panel shows "connected". If it is not running, your changes stay in the browser marked unsaved, nothing is lost, and they save the next time the server is up.
A save the server cannot place, an id outside its scan.roots for example, turns the status dot red and lists each failed row in the panel with the reason, along with the server command and a CONNECT button, so a save that reached the wrong project's server is diagnosed and fixed in place. The rows clear on the next save or once a server is reached again.
Per-type codecs
The optional third argument to registerTypeGUI is a codec, { encode }, that tells the save engine how to write a custom type's value back to source. Non-server modes never call it.
encode(value, prop) receives the current value and the prop descriptor (so it can read bounds like prop.n1), and returns an object with:
| Field | Required | Description |
|-------|----------|-------------|
| code | yes | The source expression for the value. The type's own wrapper is kept for you, so return what goes inside it. |
| imports | no | Any helper or wrapper names your code references. On save a bare name is added to your import from GUI.settings.importsFrom, a { name, from } entry imports from its own module. |
Return null instead of an object for a value that has no source form; the engine then leaves that property untouched and its row reverts to modified.
When a call has more than one object argument, tell Tweaks which argument contains the fields it can
save. configArg is zero-based, so widget(target, options) uses configArg: 1.
Put configArg on find, not on the codec. A codec converts a value back to source code, while
find locates the object to edit. configArg is required whenever Tweaks reads or writes config
fields. Leave it out only when a name-based entry changes a different argument, such as a marker
position.
const Gradient = registerType('Gradient', { from: '#000', to: '#fff' });
// Source is `sky: Gradient({ ... })`. encode returns the object inside Gradient(...), the Gradient
// wrapper and its import are kept for you, so you never list your own type in imports.
registerTypeGUI('Gradient', constructorFn, {
encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}' }` }),
});Use imports only when your code calls another helper. Point GUI.settings.importsFrom at the module it comes from, and a save adds the import when it is missing. A helper from a different module passes { name, from } instead of a bare name:
GUI.settings.importsFrom = './tweak-helpers.js';
const Tint = registerType('Tint', { color: '#ffffff', amount: 0.5 });
// Source is `bg: Tint(mix('#ffffff', 0.5))`. encode returns the mix(...) call that goes inside Tint,
// and lists mix, so a save adds `import { mix } from './tweak-helpers.js'` when it is absent.
registerTypeGUI('Tint', constructorFn, {
encode: (value) => ({ code: `mix('${value.color}', ${value.amount})`, imports: ['mix'] }),
});See registerTypeGUI for the full constructor signature.
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:
import { GUI } from 'tweaks/gui';
const audio = { volume: 1 };
GUI.render(() => {
if (GUI.Slider(audio, 'volume', 0, 1, 0.01)) {
applyVolume(audio.volume); // runs only when the slider moves
}
if (GUI.Button('Mute')) {
audio.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.
The callback rate is not fixed: idle frames tick at GUI.settings.fps and interaction renders at the display rate. Animation driven from the callback should scale by elapsed time, not by callback count, or it speeds up while a value is being edited.
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.
Settings
GUI.settings holds runtime configuration shared across the library. Defaults:
GUI.settings.warnings // true, gate console.warn output from tweaks
GUI.settings.fps // 10, render loop target frames per second
GUI.settings.debounceTimer // 150, ms debounce on persistence writes
GUI.settings.serverPort // 7348, port of the discovered tweaks server, synced by auto-discovery
GUI.settings.serverEndpoint // '/__tweaks__/', override for a different origin or path
GUI.settings.importsFrom // '', module a custom type's wrapper is imported from, for save to add it
GUI.settings.probeOnLoad // true, probe for the dev server once on load, false stays offlineAssignment is live: setting GUI.settings.fps = 30 clears and re-arms the internal render interval immediately.
import { GUI } from 'tweaks/gui';
GUI.settings.warnings = false;
GUI.settings.fps = 60;Docked mode
Docked mode docks the gui into a resizable shell around your page instead of floating over it. The page moves into an iframe sized by the real leftover viewport, so media queries, viewport units, and window reads stay honest while panels take real space beside it.
The Vite plugin sets it up automatically, injecting the tag above vite's HMR client so every HMR patch and full reload, a save included, lands in the embedded page while the shell and its panels stay put.
SSR frameworks built on vite, SvelteKit, Nuxt, and Astro, render their html outside vite's transformIndexHtml hook, so they get the routes but not the automatic tag. Add it once in the framework's root template after the <title>, src/app.html in SvelteKit, app.head in the Nuxt config, the root layout head in Astro. The plugin serves the boot on the vite origin, so the path stays relative:
<script src="/__tweaks__/iframe.js"></script>Everywhere else, one classic script tag makes it available, placed as early in the head as you can, before every other script on the page. Placement is the contract: in docked mode everything below the tag goes inert in the top window, which is what you want for page scripts, and the boot names the shell tab from the <title> it holds. Keep the charset meta above it:
<script src="/node_modules/tweaks/iframe.js"></script>When the tweaks dev server is running, the server that applies saves also serves the boot, one absolute URL that works whatever the docroot, PHP sites and build pipelines included. The panel shows the tag with the auto-discovered port filled in, and a missing server leaves the dock actions in their setup state:
<script src="http://localhost:7348/__tweaks__/iframe.js"></script>Pages loaded over https can use that URL in Chrome and Firefox, which treat localhost as trustworthy, Safari blocks it, use the form below there. Without the server, the file is a dependency-free static asset: copy it in your build step and point the tag at the copy, re-copying each build keeps it in sync with the installed version:
cp node_modules/tweaks/iframe.js public/build/tweaks-iframe.jsThe tag alone changes nothing: the page boots as usual with the floating gui over the document. The panel bar mode button shows the current floating or docked position and opens a Position section. Its Floating, Docked left, and Docked right buttons switch mode directly. Crossing between floating and docked reloads the page; changing sides inside the shell moves the panel without a reload. Without the tag, the same section includes the tag ready to copy. The mode is remembered per tab in sessionStorage (tweaks_docked), so every new tab starts undocked.
Custom controls can call GUI.IsDockedModeAvailable() to enable their dock actions and GUI.SetDockedMode(enabled) to enter or leave the shell. SetDockedMode() returns false only when docked mode was requested without the boot script. It returns true when the requested mode is already active or starts the required reload.
In docked mode the top window never executes page code. The boot parses everything below the tag into an inert template, nothing executes, fetches, or plays, and the shell replaces it at DOMContentLoaded, the page then runs once, inside the iframe. Content above the tag still parses and runs in the top window, the tag's position is the boundary, so a dev server's live reload script below it reloads the embedded page alone. Navigating the embedded page follows in the shell, the address bar and tab title mirror each page a link reaches so a reload stays there, a page without the gui exits docked mode automatically, and the embedded page's scroll position survives its reloads, dev server full reloads and the save flow included.
The tag is dev markup: ship it next to your tweaks import and strip both for production. GUI.BeginDockedPane(pane, min, max) mounts the widgets emitted until EndDockedPane() in the fixed bottom, left, right, viewportLeft, or viewportRight shell cell. It returns the pane size ref in docked mode and null outside the shell. min and max are visual px. The left and right cells span the full window height. The bottom cell spans the width between them. The viewport cells hug the embedded iframe and stop above the bottom cell. Empty cells and their gutters collapse through CSS; the GUI creates no pane manager or slot state.
Position windows in a pane
BeginDockedPane selects the shell pane. Inside it, BeginLayout selects the direction. Calls in
a row appear from left to right. Calls in a column appear from top to bottom. There is no
separate order value or retained window list.
A Splitter fixes the sibling selected by controls: prev fixes the child before it and next
fixes the child after it. The other child grows. This right pane keeps the panel on the outside and
lets the timeline beside the viewport grow:
if (GUI.BeginDockedPane('right', 520, 2000)) {
GUI.BeginLayout('side', 'row');
GUI.BeginLayout('timeline', 'column', 260);
renderTimeline();
GUI.EndLayout();
GUI.Splitter('timelinePanel', GUI.Ref('@panelWidth', 320), 260, 2000, 'next');
GUI.BeginLayout('panel', 'column', 260);
renderPanel();
GUI.EndLayout();
GUI.EndLayout();
GUI.EndDockedPane();
}Use these call orders for the common placements:
- Column: emit the main panel first to put it at the top.
- Left row: emit the panel first, then a
prevsplitter, then the viewport window. - Right row: emit the viewport window first, then a
nextsplitter, then the panel.
To move a window, emit the same stable window call in a different pane or position. Tweaks reuses the window.
The panel itself picks its mode through the Position section opened by the panel bar mode button. Choose Floating, Docked left, or Docked right; the mode button icon always shows the active choice.
GUI.Splitter(labelKey, size, min, max, controls) is a drag gutter usable in a layout or another flex container. It resizes the current adjacent sibling selected by controls and releases that sibling when the splitter leaves the frame. size, min, and max are visual px. Pass a #id labelKey to adopt an existing element as the gutter.
GUI.SetHostWindow(win) is the primitive docked mode is built on: it mounts the gui into a same origin host window when called before the first render, so panels, floating windows, styles, and input listeners target the host document. Stale tweaks elements a previous session left in the host are removed on mount. GUI.GetHostWindow() returns the window hosting the chrome, the window itself outside docked mode, so host facing reads like GetHostWindow().innerWidth stay correct in both modes.
logWarning
GUI.logWarning(...args) (also exported from tweaks) wraps console.warn and only fires when GUI.settings.warnings is true. Use it for library-style warnings that users can silence:
import { logWarning } from 'tweaks';
logWarning('[mylib] schema mismatch, falling back to default');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 PushParentById('#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::.tweaks_color_red'); // colored
GUI.Text(obj, 'prop::"Name".tweaks_color_red'); // label + color
GUI.Text(obj, 'prop::"Name"#myId.tweaks_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: .tweaks_color_white, .tweaks_color_black, .tweaks_color_red, .tweaks_color_corail, .tweaks_color_orange, .tweaks_color_yellow, .tweaks_color_citrus, .tweaks_color_lime, .tweaks_color_green, .tweaks_color_turquoise, .tweaks_color_cyan, .tweaks_color_sky, .tweaks_color_sega, .tweaks_color_king, .tweaks_color_indigo, .tweaks_color_lavender, .tweaks_color_purple, .tweaks_color_magenta, .tweaks_color_pink. Numbered aliases .tweaks_color_1 through .tweaks_color_19 map to the same colors in order.
Foreground colors: .tweaks_color_fg_0 (transparent) through .tweaks_color_fg_12, plus .tweaks_color_fg_folder which takes the folder foreground a header row paints its caret with.
Background colors: .tweaks_color_bg_0 (transparent) through .tweaks_color_bg_12.
Layout classes: .tweaks_width_1 through .tweaks_width_20, .tweaks_width_full, .tweaks_width_half, .tweaks_width_fit, .tweaks_height_1 through .tweaks_height_20, .tweaks_height_fit, .tweaks_flex_1 through .tweaks_flex_20, .tweaks_padded, .tweaks_padded_left, .tweaks_padded_right, .tweaks_padded_all, .tweaks_padded_small, .tweaks_padded_none.
Border radius classes: .tweaks_border_radius applies the default radius to all corners. .tweaks_border_radius_left and .tweaks_border_radius_right round only the left or right corners, useful for grouping segmented controls.
Grid classes (for Point2DArea): .tweaks_grid_dot or .tweaks_grid_square for the grid style, .tweaks_grid_cols_1 through .tweaks_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: .tweaks_margin_top_-10 through .tweaks_margin_top_10, same for _right, _bottom, _left. Values are in scaled pixels. Defaults to gap + contour width when not specified.
.tweaks_anchor_row_align on an anchored window aligns the window's first row with the row that anchors it. Without it, the window's border sits at the anchor's edge and its own padding pushes the first row lower. The class shifts the whole window up by that padding, so both rows sit on one line. The window then overlaps its anchor along the block axis, so the class also drops the top margin.
Theming external elements: add .tweaks_scope to any element outside the GUI to resolve the tweaks CSS variables and color ramps on it and its subtree, for example a custom overlay or gizmo that should follow the panel palette. The class only exposes the --tweaks-gui-* variables, it applies none of the panel styling or reset.
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.
The UI scale row in the panel settings scales the whole GUI from 25% to 200% through a select and Smaller, Bigger, and Reset buttons. Cmd -, Cmd +, and Cmd 0 (Ctrl on Windows/Linux) drive the same steps and reset alongside the browser zoom; both scales change together. The scale percents mirror the browser zoom levels and a scale change rescales panel boxes with it, so zooming with the shortcuts keeps panels visually constant while the page zooms around them.
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 --tweaks-gui-panel-min-width and --tweaks-gui-panel-min-height CSS variables for their minimum dimensions. Adding a .tweaks_width_N, .tweaks_height_N, .tweaks_width_fit, or .tweaks_height_fit class to the window title overrides the default size on that axis and disables resize on it. Use .tweaks_min_height_N to set a minimum height while still allowing the user to resize:
// Fixed width, resizable height
GUI.BeginWindow('Inspector::.tweaks_width_7', true, visible);
// Fixed width and height
GUI.BeginWindow('Picker::.tweaks_width_7.tweaks_height_5', true, visible);
// Width shrinks to fit content, no horizontal resize
GUI.BeginWindow('Tooltip::.tweaks_width_fit', true, visible);
// Fixed width, custom min-height, still resizable vertically
GUI.BeginWindow('Inspector::.tweaks_width_7.tweaks_min_height_5', true, visible);GUI.GetWindowWidth() and GUI.GetWindowHeight() return the size of the enclosing window or panel in visual px (the unit before --tweaks-gui-scale is applied), so a threshold keeps its meaning across gui scales. Use them to branch the content on the available space:
GUI.BeginWindow('Toolbar', true, visible);
if (GUI.GetWindowWidth() >= 300) {
GUI.Button('REVERT::.tweaks_width_3'); // full label
} else {
GUI.Button('<{arrow_andti_clockwork}>::.tweaks_width_1'); // icon only
}
GUI.EndWindow();The first call for a panel measures it once and registers a shared ResizeObserver; every later read returns the cached number without forcing layout, and a resize re-renders with the new size on the next frame. A panel that never asks pays no observer. They report the real layout size (a min-width clamp included), return NaN outside a window or panel, and inside a .tweaks_width_fit or .tweaks_height_fit window the size follows the content, so a branch on it can oscillate there.
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);
let inspectorAnchor = '';
GUI.render(() => {
if (!GUI.BeginPanel('My Panel')) return;
GUI.Slider(obj, 'speed', 0, 100);
inspectorAnchor = GUI.AnchorName();
anchorVisible(true);
GUI.EndPanel();
});
GUI.render(() => {
if (!anchorVisible()) return;
GUI.AnchorPosition(inspectorAnchor, '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 (windowOpen()) {
GUI.AnchorPosition(anchor, 'bottom center');
GUI.BeginWindow('Inspector', true, windowOpen);
GUI.WindowBar('Inspector');
// ... window 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 uses CSS position area keywords, which Tweaks converts to anchor insets so browser overflow fallbacks remain reliable:
- 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::.tweaks_margin_left_5'); // 5px scaled gap on the leftAdd .tweaks_anchor_row_align when the window's first row should line up with the row that opened it, rather than its border sitting below that row:
GUI.AnchorPosition(anchor, 'span-bottom left');
GUI.BeginWindow('Ease::.tweaks_anchor_row_align'); // title on the anchor row, window beside the panelThe shift rides the window's block start inset, so a position-try fallback that replaces top drops it too.
When the anchor target may not be rendered (e.g. inside a collapsible folder), skip the window with an early return to avoid orphaned positioning.
When an anchored window has a visibility ref, Tweaks clears that ref if the window stops being emitted. A hidden row therefore cannot reopen a stale window when it returns.
| 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') |
Context Menu
Right-click a widget to open a menu at the cursor. Declare the menu right after the widget that triggers it; BeginContextMenu opens automatically when that widget is right-clicked and returns true while the menu is open.
GUI.render(() => {
GUI.Button('Right-click me');
if (GUI.BeginContextMenu('actions')) {
if (GUI.MenuItem('<{copy}>Copy')) copy();
if (GUI.MenuItem('<{delete}>Delete')) remove();
GUI.EndContextMenu();
}
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| key | string | | Menu identity, carries :: modifiers like any window id |
| area | string | 'bottom span-right' | CSS position-area, the direction the menu grows from the cursor |
| autoOpen | boolean | true | Open when the previous widget is right-clicked. Set false to drive opening yourself |
| anchorName | string | undefined | Pin the menu to this anchor instead of the cursor |
BeginContextMenu returns true while the menu is open; render MenuItems and call EndContextMenu() only then. MenuItem(label) is a full-width row that returns true the frame it is clicked and closes the menu; icons use the <{iconName}> syntax. The menu dismisses on Escape, on a click outside it, or on a menu item click, and only one menu shows at a time (opening another closes the current one). Open state is never persisted.
The key's :: modifiers apply to the menu window. For example, actions::.tweaks_margin_right_-6 removes the normal right anchor gap and moves the menu six scaled pixels past its anchor edge.
To drive opening yourself, set autoOpen to false and call OpenContextMenu(key). Pair it with IsItemRightClicked(), which returns true the frame the previous widget was right-clicked, so a shared menu can serve many items:
GUI.render(() => {
for (let i = 0; i < items.length; i++) {
GUI.PushId(i);
GUI.Button(items[i].name);
if (GUI.IsItemRightClicked()) {
selected = items[i];
GUI.OpenContextMenu('itemMenu');
}
GUI.PopId();
}
if (GUI.BeginContextMenu('itemMenu', undefined, false)) {
if (GUI.MenuItem('Duplicate')) duplicate(selected);
if (GUI.MenuItem('Delete')) remove(selected);
GUI.EndContextMenu();
}
});| Function | Signature | Description |
|----------|-----------|-------------|
| IsItemRightClicked | () → boolean | true the frame the previous widget was right-clicked. Registers it as a trigger so the native menu is suppressed |
| OpenContextMenu | (key) | Open the menu for key, closing any other open menu |
| MenuItem | (label) → boolean | Full-width menu row. true the frame it is clicked, then closes the menu |
| EndContextMenu | () | Close the `BeginCon
