ParamPanelTypeScript-first · framework independent

Turn your variables into knobs

ParamPanel drops a live control panel into any web app — sliders, colors, selects, and custom canvas visualizations wired straight to your values. Tweak a shader, tune a simulation, and watch it change in real time. Built for Three.js, WebGPU, canvas, and creative coding.

import { createPanel } from "param-panel";

const panel = createPanel({ title: "Bloom" });

// Every control is a reactive value — read it, or subscribe to changes.
const count = panel.slider({ label: "Particles", value: 800, min: 0, max: 5000, step: 50 });
const speed = panel.slider({ label: "Speed", value: 1.2, min: 0, max: 4, step: 0.1 });
const tint = panel.color({ label: "Tint", value: "#38bdf8" });
const blend = panel.select({
  label: "Blend",
  value: "screen",
  options: [
    { label: "Screen", value: "screen" },
    { label: "Additive", value: "add" },
  ],
});

// Pull the latest values every frame — no re-renders, no plumbing.
function draw() {
  simulate(count.get(), speed.get());
  render({ tint: tint.get(), blend: blend.get() });
  requestAnimationFrame(draw);
}
draw();

// Or react to one control. `final` fires once — when the drag ends.
count.subscribe((value, { final }) => {
  if (final) saveScene({ particles: value });
});

A live panel over a canvas

A barebones 2D-canvas fireworks simulation, tuned in real time by a ParamPanel — sliders and colors for the physics and look, plus live FPS and total-brightness monitors. The animation loop just reads each control with .get(). The whole source is right below.

fireworks-demo.ts— the whole thing, in one file
import { createPanel } from "param-panel";

// A fireworks simulation on a barebones 2D canvas, driven entirely by a live
// ParamPanel. The panel *is* the state: the animation loop reads each control
// with .get() every frame, so there is no separate config object to keep in
// sync — move a slider and the next frame already sees the new value.
export default function mountFireworks(root: HTMLElement): () => void {
  const panelHost = document.createElement("div");
  panelHost.style.cssText =
    "position:absolute;top:12px;right:12px;max-height:calc(100% - 24px);overflow:auto";
  root.appendChild(panelHost);

  // --- The control panel: every knob and monitor, up front ---
  const pp = createPanel({ container: panelHost, title: "🎆 Fireworks", draggable: false });

  const launch = pp.folder({ label: "Launch" });
  const rate = launch.slider({ label: "Rate", value: 2.5, min: 0, max: 12, step: 0.5 });
  const burst = launch.slider({ label: "Burst", value: 90, min: 20, max: 240, step: 1, decimals: 0 });
  const spread = launch.slider({ label: "Spread", value: 6.5, min: 1, max: 14, step: 0.5 });
  launch.button({ label: "Launch now", variant: "primary" }).onPress(() => launchRocket());

  const physics = pp.folder({ label: "Physics" });
  const gravity = physics.slider({ label: "Gravity", value: 0.05, min: 0, max: 0.3, step: 0.005 });
  const drag = physics.slider({ label: "Air drag", value: 0.965, min: 0.9, max: 0.999, step: 0.001 });
  const wind = physics.slider({ label: "Wind", value: 0, min: -3, max: 3, step: 0.1 });
  const life = physics.slider({ label: "Spark life", value: 1.4, min: 0.4, max: 3, step: 0.1 });

  const style = pp.folder({ label: "Style" });
  const hue = style.slider({ label: "Hue", value: 40, min: 0, max: 360, step: 1, decimals: 0 });
  const hueSpread = style.slider({ label: "Hue spread", value: 60, min: 0, max: 180, step: 1, decimals: 0 });
  const trail = style.slider({ label: "Trails", value: 0.12, min: 0.02, max: 0.4, step: 0.01 });
  const glow = style.checkbox({ label: "Additive glow", value: true });
  const rainbow = style.checkbox({ label: "Rainbow", value: true });
  const sky = style.color({ label: "Sky", value: "#05060a" });

  // Throttle every read-out so the fast-changing numbers don't flicker (the
  // graph lines still update on every frame).
  const monitors = pp.folder({ label: "Monitors" });
  const fps = monitors.graph({ label: "FPS", min: 0, max: 90, height: 52, throttleMilliseconds: 200 });
  const count = monitors.monitor({ label: "Particles", value: 0, throttleMilliseconds: 200 });
  const bright = monitors.monitor({
    label: "Brightness",
    value: 0,
    throttleMilliseconds: 200,
    format: (v) => v.toFixed(0),
  });
  const brightGraph = monitors.graph({
    label: "Total brightness",
    autoRange: true,
    height: 52,
    throttleMilliseconds: 200,
  });

  pp.button({ label: "Clear sky", variant: "danger" }).onPress(() => (particles.length = 0));

  // --- Barebones canvas ---
  const canvas = document.createElement("canvas");
  canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
  canvas.style.background = sky.get();
  root.insertBefore(canvas, panelHost);
  const ctx = canvas.getContext("2d")!;

  let width = 0;
  let height = 0;
  const resize = () => {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    width = root.clientWidth;
    height = root.clientHeight;
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  };
  const observer = new ResizeObserver(resize);
  observer.observe(root);
  resize();

  // --- Simulation (every function just reads the controls it needs) ---
  interface Particle {
    x: number;
    y: number;
    vx: number;
    vy: number;
    life: number; // 1 → 0 over the spark's lifetime
    decay: number; // life lost per frame
    hue: number;
    rocket: boolean;
  }

  const particles: Particle[] = [];
  let launchAccumulator = 0;

  function pickHue(): number {
    return rainbow.get() ? Math.random() * 360 : hue.get();
  }

  function launchRocket(x = 0.12 + Math.random() * 0.76): void {
    const rise = height * (0.55 + Math.random() * 0.3);
    particles.push({
      x: x * width,
      y: height + 4,
      vx: (Math.random() - 0.5) * 1.2,
      vy: -Math.sqrt(2 * 0.12 * rise), // just enough to arc to `rise`
      life: 1,
      decay: 0,
      hue: pickHue(),
      rocket: true,
    });
  }

  function explode(p: Particle): void {
    const n = burst.get();
    for (let i = 0; i < n; i++) {
      const angle = (i / n) * Math.PI * 2 + Math.random() * 0.4;
      const speed = spread.get() * (0.35 + Math.random() * 0.65);
      const seconds = life.get() * (0.6 + Math.random() * 0.6);
      particles.push({
        x: p.x,
        y: p.y,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        life: 1,
        decay: 1 / (seconds * 60),
        hue: rainbow.get() ? p.hue + (Math.random() - 0.5) * hueSpread.get() : p.hue,
        rocket: false,
      });
    }
  }

  function step(): void {
    launchAccumulator += rate.get() / 60;
    while (launchAccumulator >= 1) {
      launchRocket();
      launchAccumulator -= 1;
    }
    const g = gravity.get();
    const d = drag.get();
    const w = wind.get();
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.vy += p.rocket ? 0.12 : g;
      if (!p.rocket) {
        p.vx = (p.vx + w * 0.02) * d;
        p.vy *= d;
      }
      p.x += p.vx;
      p.y += p.vy;
      if (p.rocket) {
        if (p.vy >= -1) {
          explode(p);
          particles.splice(i, 1);
        }
      } else {
        p.life -= p.decay;
        if (p.life <= 0 || p.y > height + 30) particles.splice(i, 1);
      }
    }
    if (particles.length > 4000) particles.splice(0, particles.length - 4000);
  }

  function render(): number {
    // Translucent sky wash leaves fading trails instead of clearing outright.
    ctx.globalCompositeOperation = "source-over";
    ctx.fillStyle = withAlpha(sky.get(), trail.get());
    ctx.fillRect(0, 0, width, height);
    ctx.globalCompositeOperation = glow.get() ? "lighter" : "source-over";

    let brightness = 0;
    for (const p of particles) {
      const alpha = p.rocket ? 1 : Math.max(0, p.life);
      brightness += alpha;
      ctx.fillStyle = `hsla(${p.hue}, 100%, ${p.rocket ? 80 : 62}%, ${alpha})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.rocket ? 2.4 : 1.8, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalCompositeOperation = "source-over";
    return brightness;
  }

  let raf = 0;
  let previous = performance.now();
  function frame(now: number): void {
    const dt = (now - previous) / 1000;
    previous = now;
    step();
    const brightness = render();
    if (dt > 0) fps.push(1 / dt, now);
    count.set(particles.length);
    bright.set(brightness);
    brightGraph.push(brightness, now);
    raf = requestAnimationFrame(frame);
  }
  raf = requestAnimationFrame(frame);

  return () => {
    cancelAnimationFrame(raf);
    observer.disconnect();
    pp.destroy();
    canvas.remove();
    panelHost.remove();
  };
}

/** `#rgb`/`#rrggbb` → `rgba(...)` with the given alpha. */
function withAlpha(hex: string, alpha: number): string {
  const m = hex.replace("#", "");
  const full = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
  const r = parseInt(full.slice(0, 2), 16) || 0;
  const g = parseInt(full.slice(2, 4), 16) || 0;
  const b = parseInt(full.slice(4, 6), 16) || 0;
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

Everything you need to tune parameters

A small core with sliders, numbers, selects, colors, buttons, folders, live monitors, and custom canvas controls.

Framework independent

Plain browser JS and TS. No React, Vue, virtual DOM, or store library required — integrations are optional adapters.

Reactive values

Every control is a Readable/Writable. Subscribe for changes, with a clean distinction between intermediate and final events.

Atomic configuration

configure() validates the whole proposed change before applying any of it — no invalid intermediate state, ever.

First-class visualizations

A canvas control that handles device-pixel-ratio, resizing, and the animation-frame lifecycle. Just write draw().

Tree-shakable

Import only what you use. A slider never pulls in the color picker, graph rendering, or URL persistence.

Safe lifecycle

destroy() releases DOM, listeners, observers, timers, and subscriptions. Using a destroyed control throws.

Ready to build?

Install the package and mount your first panel in a few lines.

npm i param-panelRead the docs