Controls

Config-driven control panels – describe parameters as an object and render them with Substrate controls.

Describe a panel as a plain object and get typed values back. Controls renders every registered panel with Substrate's own components, so it sits inside a Panel like any hand-built pane.

The config format and store come from DialKit by Josh Puckett (MIT). Substrate uses its headless store and supplies the interface.

Install

npx shadcn@latest add https://substrate.georgedrury.co.uk/r/controls

Examples

Default

Substrate
"use client"

import { Controls } from "@/components/substrate/controls"

Usage

import { Panel } from "@/components/substrate/panel"
import { Controls } from "@/components/substrate/controls"
import { useControls } from "@/hooks/substrate/use-controls"

function Editor() {
  const card = useControls("Card", {
    radius: [16, 0, 48],
    colour: "#a78bfa",
    visible: true,
    shadow: { blur: [24, 0, 64] },
  })

  return (
    <>
      <div style={{ borderRadius: card.radius, background: card.colour }} />
      <Panel>
        <Controls />
      </Panel>
    </>
  )
}

Keys become labels, and the returned values keep the same nesting as the config.

Config

ControlDefinitionReturned valueRendered with
Sliderradius: [16, 0, 48]numberNumberSlider
Slider with stepradius: [16, 0, 48, 2]numberNumberSlider
Inferred sliderscale: 1.2numberNumberSlider
Togglevisible: truebooleanToggle
Texttitle: "Hello"stringTextArea
Colourcolour: "#a78bfa"stringColourPicker
Selectalign: { type: "select", options: ["left", "right"] }stringSelect
Easingmotion: { type: "easing", duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }{ type, duration, ease }EasingCurveEditor
Actionreplay: { type: "action" }Calls onAction("replay")Button
Foldershadow: { blur: [24, 0, 64] }Nested valuesCollapsiblePane

Slider tuples are [default, min, max, step?]. Add _collapsed: true inside a folder to start it closed.

DialKit's spring, image and XY pad controls have no Substrate component yet. They keep their values and show a placeholder row.

useControls

const values = useControls(name, config, options)
OptionTypeDescription
idstringKeeps values across unmounts and shares them between mounts using the same id
persistboolean | { key?, storage?, presets? }Saves values to browser storage
onAction(path: string) => voidCalled with the path of a clicked action control

Setting values from code

useControlsController registers the same panel and also returns setters, for loading a saved state or applying a preset:

const parameters = useControlsController("Parameters", {
  steps: [30, 1, 100, 1],
  cfgScale: [7, 1, 20, 0.5],
})

parameters.values.steps
parameters.setValues({ steps: 40 })
parameters.reset()

Values that belong to a selection

A panel owns its values, which suits settings with local state. When the values belong to something selectable, mount a small component per selection with key, seed the config from the selected item, and write edits back:

function MaterialControls({ object, onChange }: Props) {
  const [config] = useState(
    () =>
      ({
        colour: object.colour,
        roughness: [object.roughness, 0, 1, 0.01],
      }) satisfies ControlsConfig,
  )
  const material = useControls("Material", config)

  const onChangeRef = useRef(onChange)
  onChangeRef.current = onChange
  useEffect(() => {
    onChangeRef.current(material)
  }, [material])

  return <Controls panel="Material" />
}

<MaterialControls key={selected.id} object={selected} onChange={update} />

This works when nothing else edits those values while the item is selected. If they also change elsewhere (dragging on a canvas, undo), compose the individual controls by hand so they read straight from your store.

Controls

PropTypeDefaultDescription
panelstring—Render only the panel with this name or id. Renders every panel when omitted.

Each panel has a reset button that restores the config defaults.