Skip to content

Debug UI and Deployment

A Three.js guide for coding agents. Also covers lil-gui, dat.GUI, debug panel, tweak panel, GUI controls, how do I add a slider to tweak a value, and 19 more.

Show all 25 aliases

lil-gui, dat.GUI, debug panel, tweak panel, GUI controls, how do I add a slider to tweak a value, gui.add, gui.addColor, color picker shows wrong color, onChange vs onFinishChange, tweak a variable that is not a property, debugObject pattern, trigger a function from the debug UI, rebuild geometry from a tweak, geometry dispose memory leak, lil-gui folders, hide debug UI with keyboard, toggle gui with h key, npm run build, vite build dist folder, deploy three.js to vercel, vercel --prod, how do I put my three.js project online, hosting a webgl experience, netlify github pages

Why a debug UI at all

  • Every creative project needs tweakability: colors, speeds, positions, quantities. Designers and clients cannot edit code, so expose parameters in a panel
  • Add tweaks as you build, not at the end. Deferring them means you never add them and miss unexpected good-looking results
  • lil-gui is the standard choice: maintained, popular, drop-in replacement for the abandoned dat.GUI with better features. Course code that says new dat.GUI() works identically with lil-gui
  • A common production pattern: only show the panel when the URL contains #debug, so the same build serves users and tweakers

Setup

// npm install lil-gui
import GUI from 'lil-gui'

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI',
    closeFolders: false
})
  • import * as dat from 'lil-gui' then new dat.GUI() is the same thing, seen in older course code
  • gui.close() collapses the panel by default, gui.hide() removes it entirely
  • Toggle visibility with a key, using the internal _hidden flag:
window.addEventListener('keydown', (event) => {
    if (event.key == 'h')
        gui.show(gui._hidden)
})

Tweak types

lil-gui infers the control from the property type: number gives a range or input, boolean gives a checkbox, string gives a text field, function gives a button. Colors need the dedicated addColor().

Range (numbers)

gui.add(mesh.position, 'y', -3, 3, 0.01)
// or chained, same result
gui.add(mesh.position, 'y').min(-3).max(3).step(0.01).name('elevation')
  • Signature is gui.add(object, 'propertyName'): the object first, the property name as a string second
  • Call gui.add(...) after the object and property exist, otherwise lil-gui errors on a missing property
  • name() sets the panel label independently of the property name

Checkbox (booleans)

gui.add(mesh, 'visible')
gui.add(material, 'wireframe')

Button (functions)

lil-gui can only bind object properties, so put the function on an object:

debugObject.spin = () => {
    gsap.to(mesh.rotation, { duration: 1, y: mesh.rotation.y + Math.PI * 2 })
}
gui.add(debugObject, 'spin')

The debugObject pattern for non-properties

  • lil-gui cannot tweak a standalone variable, only a property of an object. Wrap loose values in a holder object, commonly named debugObject, parameters, or global
const gui = new GUI()
const debugObject = {}

debugObject.myVariable = 1337
gui.add(debugObject, 'myVariable')
  • This same object is the home for colors, functions, and constructor-only parameters (see geometry rebuild below)

Colors: the wrong-value trap

  • Use addColor(), not add(), because material.color is a THREE.Color instance, not a primitive
  • Gotcha: Three.js applies color management internally, so the hex shown in the picker is NOT the value Three.js uses. Copying the picker value into code gives a different color on screen

Two fixes:

  1. Read the managed value with getHexString() in onChange, then copy that from the console:
gui.addColor(material, 'color').onChange((value) => {
    console.log(value.getHexString())
})
  1. Better: keep the color as a plain string on debugObject, tweak that, and push it into the material with set(). Nobody needs the console open, and the initial color lives in exactly one place:
debugObject.color = '#a778d8'
const material = new THREE.MeshBasicMaterial({ color: debugObject.color })

gui.addColor(debugObject, 'color').onChange(() => {
    material.color.set(debugObject.color)
})

Rebuilding geometry from a tweak

  • Constructor parameters like widthSegments are NOT live properties of the geometry. They are consumed once at construction, so gui.add(geometry, 'widthSegments') throws
  • Store the parameter on debugObject, then rebuild the geometry when the tweak changes:
debugObject.subdivision = 2
gui
    .add(debugObject, 'subdivision')
    .min(1).max(20).step(1)
    .onFinishChange(() => {
        mesh.geometry.dispose()
        mesh.geometry = new THREE.BoxGeometry(
            1, 1, 1,
            debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
        )
    })

Two gotchas encoded there:

  • Use onFinishChange, not onChange. Building geometry is CPU-heavy, and onChange fires continuously while dragging the slider. onFinishChange fires once when the drag stops
  • Call dispose() on the old geometry before replacing it. Assigning a new geometry does not free the old one from GPU memory, so skipping dispose is a memory leak

Folders

const cubeTweaks = gui.addFolder('Awesome cube')
cubeTweaks.add(mesh.position, 'y').min(-3).max(3).step(0.01)
cubeTweaks.add(material, 'wireframe')
cubeTweaks.close() // collapsed by default
  • Create the folder first, then call add/addColor on the folder instead of on gui
  • Folders nest inside folders, so a crowded panel has no excuse
  • closeFolders: true in the GUI constructor collapses all folders at once

Deployment: the build step

  • Never upload the raw project (with node_modules/ and the Vite config) to a host. Browsers need built HTML, CSS, JS, and assets
  • npm run build runs the build script from package.json and outputs to /dist/. Upload that folder's contents
  • Re-run npm run build before every upload; the old /dist/ is not refreshed automatically

Deployment: Vercel

  • Vercel (and alternatives Netlify, GitHub Pages) gives continuous integration: connect a Git repo and every push deploys automatically, optionally per branch
  • Install as a project dependency (npm install vercel) rather than globally, so collaborators need no machine setup
  • A dependency binary is not available directly in the terminal, but npm scripts can call it. Add a deploy script:
{
  "scripts": {
    "deploy": "vercel --prod"
  }
}
  • vercel without --prod publishes to a preview URL for testing; --prod goes to production
  • First npm run deploy walks through login and project setup. Vercel auto-detects the build settings from vite.config.js, so answer no to overriding settings
  • Every later deploy is just npm run deploy again
  • Pricing: the free Hobby plan covers non-commercial projects with unlimited projects, but has bandwidth and build-time limits. Commercial use, teams, or password-protected previews need a paid plan

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "debug-ui-and-deployment" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems