Component

Debug Hooks

See on GitHub

A tweakpane-powered debug panel with URL-persisted state, draggable UI, and reactive hooks for binding component values.

'use client'

import * as THREE from 'three/webgpu'
import {
  cos,
  dot,
  float,
  floor,
  fract,
  mix,
  mod,
  select,
  sin,
  smoothstep,
  sqrt,
  sub,
  time,
  uv,
  vec2,
} from 'three/tsl'
import { Canvas, extend, useThree } from '@react-three/fiber'
import { useEffect, useMemo } from 'react'

import { DebugProvider, useDebugBindings } from '@/registry/lib/debug'
import { useUniforms } from '@/hooks/use-uniforms'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
extend(THREE as any)

function GradientMesh() {
  const [ref, folder] = useDebugBindings(
    'Gradient',
    {
      colorA: '#6366f1',
      colorB: '#ec4899',
      colorC: '#facc15',
      angle: 45,
      stripes: 20,
      sharpness: 20.0,
      swirlStrength: 9.0,
      speed: 1.0,
    },
    {
      angle: { min: 0, max: 360, step: 1 },
      stripes: { min: 1, max: 20, step: 1 },
      sharpness: { min: 1, max: 20, step: 0.1 },
      swirlStrength: { min: 0, max: 10, step: 0.1 },
      speed: { min: 0, max: 5, step: 0.1 },
      colorA: { type: 'color' },
      colorB: { type: 'color' },
      colorC: { type: 'color' },
    }
  )

  const [uniforms, setUniforms] = useUniforms({
    uColorA: new THREE.Color(ref.current.colorA),
    uColorB: new THREE.Color(ref.current.colorB),
    uColorC: new THREE.Color(ref.current.colorC),
    uAngle: ref.current.angle,
    uStripes: ref.current.stripes,
    uSharpness: ref.current.sharpness,
    uSwirlStrength: ref.current.swirlStrength,
    uSpeed: ref.current.speed,
  })

  useEffect(() => {
    if (!folder) return
    // eslint-disable-next-line react-hooks/immutability
    folder.expanded = true
    folder.on('change', () => {
      setUniforms({
        uColorA: ref.current.colorA,
        uColorB: ref.current.colorB,
        uColorC: ref.current.colorC,
        uAngle: ref.current.angle,
        uStripes: ref.current.stripes,
        uSharpness: ref.current.sharpness,
        uSwirlStrength: ref.current.swirlStrength,
        uSpeed: ref.current.speed,
      })
    })
  }, [folder, ref, setUniforms])

  const material = useMemo(() => {
    const centered = sub(uv(), vec2(0.5))
    const dist = sqrt(dot(centered, centered))
    const swirlAngle = dist.mul(uniforms.uSwirlStrength).add(time.mul(uniforms.uSpeed).mul(0.3))
    const ca = cos(swirlAngle)
    const sa = sin(swirlAngle)
    const swirled = vec2(
      centered.x.mul(ca).sub(centered.y.mul(sa)),
      centered.x.mul(sa).add(centered.y.mul(ca))
    )

    const rad = uniforms.uAngle.mul(Math.PI / 180)
    const dir = vec2(cos(rad), sin(rad))
    const distorted = dot(swirled, dir).add(0.5)

    const t = smoothstep(
      sub(float(0.5), float(0.5).div(uniforms.uSharpness)),
      float(0.5).add(float(0.5).div(uniforms.uSharpness)),
      fract(distorted.mul(uniforms.uStripes))
    )

    const stripeIdx = mod(floor(distorted.mul(uniforms.uStripes)), float(3))

    const fromColor = select(
      stripeIdx.lessThan(0.5),
      uniforms.uColorA,
      select(stripeIdx.lessThan(1.5), uniforms.uColorB, uniforms.uColorC)
    )
    const toColor = select(
      stripeIdx.lessThan(0.5),
      uniforms.uColorB,
      select(stripeIdx.lessThan(1.5), uniforms.uColorC, uniforms.uColorA)
    )

    const finalColor = mix(fromColor, toColor, t)

    const mat = new THREE.MeshBasicNodeMaterial()
    mat.colorNode = finalColor
    return mat
  }, [uniforms])

  const viewport = useThree((s) => s.viewport)

  return (
    <mesh scale={[viewport.width, viewport.height, 1]}>
      <planeGeometry />
      <primitive object={material} attach="material" />
    </mesh>
  )
}

function DebugDemo() {
  return (
    <DebugProvider title="Gradient Debug" position="top-right" enabled>
      <div className="bg-background h-screen w-full">
        <Canvas
          gl={async (props) => {
            const renderer = new THREE.WebGPURenderer(
              props as ConstructorParameters<typeof THREE.WebGPURenderer>[0]
            )
            await renderer.init()
            renderer.outputColorSpace = THREE.SRGBColorSpace
            renderer.toneMapping = THREE.NoToneMapping
            return renderer
          }}
          flat
          style={{ width: '100%', height: '100%' }}
        >
          <GradientMesh />
        </Canvas>
      </div>
    </DebugProvider>
  )
}

export default DebugDemo

Installation

pnpm dlx shadcn@latest add @joyco/debug

Usage

import { DebugProvider, useDebugBindings, useDebugState } from '@/components/debug'
 
function App() {
  return (
    <DebugProvider>
      <MyScene />
    </DebugProvider>
  )
}

API

DebugProvider

Wraps your app to provide the tweakpane debug panel. Activated via ?debug query param or Alt+D shortcut.

PropTypeDefaultDescription
positionstring'bottom-left'Panel position: top-left, top-right, bottom-left, bottom-right
titlestring'Debug'Title shown on the panel
paddingnumber8Padding from the viewport edge
enabledbooleanfalseWhether the panel starts visible

useDebugBindings

Creates a tweakpane folder with bindings for each key in target. Returns a ref, the folder API, and a zustand store.

const [ref, folder, store] = useDebugBindings('Box', {
  width: 120,
  height: 120,
  color: '#6366f1',
}, {
  width: { min: 50, max: 300, step: 1 },
  height: { min: 50, max: 300, step: 1 },
  color: { type: 'color' },
})
ReturnTypeDescription
refRefObject<T>Mutable ref kept in sync with the UI. Not reactive — reads from ref.current won't trigger re-renders. Use this for imperative reads (e.g. in useFrame, animation loops, or event handlers).
folderFolderApi | nullTweakpane folder handle for manual control (e.g. folder.expanded = true)
storeStoreApi<T>Zustand store. Pass to useDebugState for reactive subscriptions.
ParamTypeDescription
folderTitlestringName of the tweakpane folder
targetRecord<string, unknown>Object with initial values to bind
optionsDebugOptions<T>Optional per-key tweakpane binding params

useDebugState

Reactively subscribes to debug values. Use this when you need tweakpane changes to trigger re-renders.

// Subscribe to all values — re-renders on any change
const state = useDebugState(store)
 
// Subscribe with a selector — re-renders only when the selected value changes
const width = useDebugState(store, (s) => s.width)
 
// Subscribe by folder title (store must already exist via useDebugBindings)
const state = useDebugState<{ speed: number }>('Box')

Accepts either a direct StoreApi reference (returned by useDebugBindings) or a folder title string, with an optional selector.

useDebugFolder

Returns a tweakpane FolderApi for manual control. Handles ref-counted creation and disposal automatically.

const folder = useDebugFolder('Advanced')
 
// Add custom buttons, separators, etc.
useEffect(() => {
  if (!folder) return
  const btn = folder.addButton({ title: 'Reset' })
  btn.on('click', () => { /* ... */ })
  return () => btn.dispose()
}, [folder])

Ref vs State

useDebugBindings returns a ref (ref.current) that is always up-to-date but does not trigger React re-renders. This is ideal for non-React consumers like Three.js useFrame loops, canvas drawing, or Web Audio.

For React-driven UI that needs to re-render when a value changes, pass the store to useDebugState:

function DebuggedBox() {
  const [ref, , store] = useDebugBindings('Box', {
    width: 120,
    color: '#6366f1',
  })
 
  // Reactive — triggers re-renders
  const { width, color } = useDebugState(store)
 
  return (
    <div style={{ width, backgroundColor: color }} />
  )
}

Related Components

Maintainers
Downloads
0Total
0 downloads today