shadimage
joyco
componentdebugsource: ok · hit

Debug Hooks

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

npx shadcn add "https://registry.joyco.studio/r/debug.json"
free

Where it goes

stats

Interaction

animated-counterdrag

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@tweakpane/corelodash.debounce@types/lodash.debouncezustandtweakpane

Files6 · source cached 10/15/2026, 10:49:35 AM

registry/lib/debug/provider.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import debounce from 'lodash.debounce'
import { Pane } from 'tweakpane'

import { parseHashState, writeHashState } from './hash-state'
import { DebugContext, useDebug, type DebugRegistry } from './context'
import { useDraggable } from './use-draggable'

export function DebugParamSync() {
  const searchParams = useSearchParams()
  const { setEnabled } = useDebug()

  useEffect(() => {
    const hasParam = searchParams.has('debug')
    if (hasParam) setEnabled(true)
  }, [searchParams, setEnabled])

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.altKey && e.code === 'KeyD') {
        e.preventDefault()
        setEnabled((prev) => !prev)
      }
    }

    window.addEventListener('keydown', handleKeyDown)

    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [setEnabled])

  return null
}

type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'

const positionStyles: Record<Position, Pick<React.CSSProperties, 'top' | 'right' | 'bottom' | 'left'>> = {
  'top-left': { top: 0, left: 0 },
  'top-right': { top: 0, right: 0 },
  'bottom-left': { bottom: 0, left: 0 },
  'bottom-right': { bottom: 0, right: 0 },
}

interface DebugProviderProps {
  children: ReactNode
  position?: Position
  title?: string
  padding?: number
  enabled?: boolean
}

export function DebugProvider({
  children,
  position = 'bottom-left',
  title = 'Debug',
  padding = 8,
  enabled: enabledProp,
}: DebugProviderProps) {
  const [enabled, setEnabled] = useState(enabledProp ?? false)

  const [pane, setPane] = useState<Pane | null>(null)
  const paneRef = useRef<Pane | null>(null)
  const containerRef = useRef<HTMLDivElement>(null)
  const registryRef = useRef<DebugRegistry>({
    folderEntries: new Map(),
    stores: new Map(),
    onFolderCreated: null,
  })
  const pendingStateRef = useRef<Record<string, unknown> | null>(null)
  const safetyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  const flushPendingImport = useCallback(() => {
    const state = pendingStateRef.current
    if (state && paneRef.current) {
      paneRef.current.importState(state)
      pendingStateRef.current = null
    }
  }, [])

  // eslint-disable-next-line react-hooks/exhaustive-deps
  const schedulePendingImport = useCallback(
    debounce(() => flushPendingImport(), 150),
    [flushPendingImport]
  )

  // Create pane once on mount; keep it alive across visibility toggles
  useEffect(() => {
    if (paneRef.current || !containerRef.current) return

    const instance = new Pane({
      container: containerRef.current,
      title,
      expanded: true,
    })

    paneRef.current = instance
    setPane(instance)
    registryRef.current.onFolderCreated = schedulePendingImport

    const urlState = parseHashState()
    if (urlState) {
      pendingStateRef.current = urlState
      safetyTimerRef.current = setTimeout(() => flushPendingImport(), 2000)
    }

    const exportFolder = instance.addFolder({
      title: 'Export',
      expanded: false,
    })

    const copyLinkBtn = exportFolder.addButton({ title: 'Copy Link' })
    copyLinkBtn.on('click', () => {
      writeHashState(instance.exportState())
      navigator.clipboard.writeText(window.location.href).catch(() => {})
      copyLinkBtn.title = 'Copied!'
      setTimeout(() => {
        copyLinkBtn.title = 'Copy Link'
      }, 1500)
    })

    const copyStateBtn = exportFolder.addButton({ title: 'Copy State' })
    copyStateBtn.on('click', () => {
      const json = JSON.stringify(instance.exportState(), null, 2)
      navigator.clipboard.writeText(json).catch(() => {})
      copyStateBtn.title = 'Copied!'
      setTimeout(() => {
        copyStateBtn.title = 'Copy State'
      }, 1500)
    })
  }, [flushPendingImport, schedulePendingImport, title])

  // Toggle pane visibility without disposing
  useEffect(() => {
    if (paneRef.current) paneRef.current.hidden = !enabled
  }, [enabled])

  useEffect(() => {
    if (enabledProp === undefined) return
    setEnabled(enabledProp)
  }, [enabledProp])

  // Dispose pane on provider unmount only
  useEffect(() => {
    return () => {
      schedulePendingImport.cancel()
      if (safetyTimerRef.current) clearTimeout(safetyTimerRef.current)
      pendingStateRef.current = null
      const reg = registryRef.current
      for (const [, entry] of reg.folderEntries) entry.folder.dispose()
      reg.folderEntries.clear()
      reg.stores.clear()
      reg.onFolderCreated = null
      paneRef.current?.dispose()
      paneRef.current = null
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useDraggable(containerRef, '.tp-rotv_t', !!pane)

  return (
    <DebugContext.Provider value={{ pane, enabled, setEnabled, registry: registryRef.current }}>
      <Suspense fallback={null}>
        <DebugParamSync />
      </Suspense>
      {children}
      <div
        ref={containerRef}
        style={{
          position: 'fixed',
          ...positionStyles[position],
          padding,
          zIndex: 9999,
          display: enabled ? undefined : 'none',
        }}
      />
    </DebugContext.Provider>
  )
}
registry/lib/debug/context.tsx
'use client'

import { createContext, Dispatch, SetStateAction, useContext } from 'react'
import type { StoreApi } from 'zustand'
import type { FolderApi, Pane } from 'tweakpane'

export interface FolderEntry {
  folder: FolderApi
  refCount: number
}

export interface DebugRegistry {
  folderEntries: Map<string, FolderEntry>
  stores: Map<string, StoreApi<Record<string, unknown>>>
  onFolderCreated: (() => void) | null
}

export const DebugContext = createContext<{
  pane: Pane | null
  enabled: boolean
  setEnabled: Dispatch<SetStateAction<boolean>>
  registry: DebugRegistry
} | null>(null)

export const useDebug = () => {
  const context = useContext(DebugContext)
  if (!context) {
    throw new Error('useDebug must be used within a DebugProvider')
  }
  return context
}
registry/lib/debug/hooks.tsx
'use client'

import React, { useLayoutEffect, useRef, useState } from 'react'
import { createStore, useStore } from 'zustand'
import type { StoreApi } from 'zustand'
import type { BindingParams, FolderApi } from 'tweakpane'
import type { Pane } from 'tweakpane'

import { useDebug, type DebugRegistry } from './context'

// ---------------------------------------------------------------------------
// Folder helpers — scoped to a DebugRegistry instance
// ---------------------------------------------------------------------------

function getOrCreateFolder(reg: DebugRegistry, pane: Pane, title: string): FolderApi {
  const existing = reg.folderEntries.get(title)
  if (existing) {
    existing.refCount++
    reg.onFolderCreated?.()
    return existing.folder
  }
  const f = pane.addFolder({ title, expanded: false })
  reg.folderEntries.set(title, { folder: f, refCount: 1 })
  reg.onFolderCreated?.()
  return f
}

function releaseFolder(reg: DebugRegistry, title: string) {
  const entry = reg.folderEntries.get(title)
  if (!entry) return

  entry.refCount--

  if (entry.refCount <= 0) {
    entry.folder.dispose()
    reg.folderEntries.delete(title)
    reg.stores.delete(title)
  }
}

// ---------------------------------------------------------------------------
// useDebugFolder — acquisition inside effect for balanced ref counting
// ---------------------------------------------------------------------------

export function useDebugFolder(title: string): FolderApi | null {
  const { pane, registry } = useDebug()
  const [folder, setFolder] = useState<FolderApi | null>(null)

  useLayoutEffect(() => {
    if (!pane) return

    const f = getOrCreateFolder(registry, pane, title)
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setFolder(f)

    return () => {
      releaseFolder(registry, title)
      setFolder(null)
    }
  }, [pane, registry, title])

  return folder
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type DebugTarget = Record<string, unknown>

export type DebugOptions<T> = Partial<Record<keyof T, BindingParams>>

// ---------------------------------------------------------------------------
// Store helpers — scoped to a DebugRegistry instance
// ---------------------------------------------------------------------------

function getOrCreateStore<T extends DebugTarget>(reg: DebugRegistry, key: string, initial: T): StoreApi<T> {
  let store = reg.stores.get(key) as StoreApi<T> | undefined
  if (!store) {
    store = createStore<T>(() => ({ ...initial }))
    reg.stores.set(key, store)
  } else {
    // Merge new keys from later useDebugBindings calls
    const current = store.getState()
    const patch: Partial<T> = {}
    let hasPatch = false
    for (const k of Object.keys(initial)) {
      if (!(k in current)) {
        ;(patch as Record<string, unknown>)[k] = initial[k]
        hasPatch = true
      }
    }
    if (hasPatch) store.setState(patch)
  }
  return store
}

function getStore<T extends DebugTarget>(reg: DebugRegistry, key: string): StoreApi<T> {
  const store = reg.stores.get(key) as StoreApi<T> | undefined
  if (!store) {
    throw new Error(
      `[useDebugState] No store found for "${key}". Make sure useDebugBindings("${key}", ...) mounts before useDebugState("${key}", ...).`
    )
  }
  return store
}

// ---------------------------------------------------------------------------
// bindKeys — iterates Object.keys(target), returns BindingApi[]
// ---------------------------------------------------------------------------

function bindKeys<T extends DebugTarget>(folder: FolderApi, target: T, options?: DebugOptions<T>) {
  const apis: ReturnType<FolderApi['addBinding']>[] = []
  for (const key of Object.keys(target)) {
    if (key.startsWith('_')) continue // skip internal/private props (e.g. GSAP)
    const opts = options?.[key as keyof T]
    apis.push(folder.addBinding(target, key, opts))
  }
  return apis
}

// ---------------------------------------------------------------------------
// useDebugBindings — creates tweakpane UI, mutates target, notifies store
// ---------------------------------------------------------------------------

export function useDebugBindings<T extends DebugTarget>(
  folderTitle: string,
  target: T,
  options?: DebugOptions<T>
): [React.RefObject<T>, FolderApi | null, StoreApi<T>] {
  const { registry } = useDebug()
  const folder = useDebugFolder(folderTitle)
  const optionsRef = useRef(options)

  // eslint-disable-next-line react-hooks/refs
  optionsRef.current = options
  const targetRef = useRef(target)

  // Create store eagerly during render so useDebugState can access it
  // in the same render cycle (before effects run).
  const store = getOrCreateStore(registry, folderTitle, target)

  useLayoutEffect(() => {
    if (!folder) return

    const raw = targetRef.current
    const store = getOrCreateStore(registry, folderTitle, raw)

    // Shallow-clone helper that spreads nested objects so Zustand sees new refs
    const shallowClone = (obj: Record<string, unknown>): T => {
      const next: Record<string, unknown> = {}
      for (const k of Object.keys(obj)) {
        const v = obj[k]
        next[k] = v !== null && typeof v === 'object' && !Array.isArray(v) ? { ...v } : v
      }
      return next as T
    }

    // Proxy intercepts external mutations so the folder UI stays in sync.
    // The `get` trap wraps nested plain objects in a child Proxy so that
    // mutations like `targetRef.current.position.x = 5` are intercepted.
    const proxy = new Proxy(raw, {
      get(obj, prop) {
        const value = Reflect.get(obj, prop)
        if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
          return new Proxy(value as Record<string, unknown>, {
            set(nested, nestedProp, nestedValue) {
              const result = Reflect.set(nested, nestedProp, nestedValue)
              folder.refresh()
              store.setState(shallowClone(obj as Record<string, unknown>))
              return result
            },
          })
        }
        return value
      },
      set(obj, prop, value) {
        const result = Reflect.set(obj, prop, value)
        folder.refresh()
        store.setState(shallowClone(obj as Record<string, unknown>))
        return result
      },
    }) as T
    targetRef.current = proxy

    const apis = bindKeys(folder, proxy, optionsRef.current)

    // Tweakpane may mutate nested objects directly without going through
    // our proxy's property access chain, so attach change listeners as
    // a safety net to keep the Zustand store in sync with UI changes.
    for (const api of apis) {
      api.on('change', () => {
        store.setState(shallowClone(raw as Record<string, unknown>))
      })
    }

    return () => {
      for (const a of apis) a.dispose()
      targetRef.current = raw
    }
    // target is expected to be a stable ref (e.g. useRef().current).
    // options is read from a ref — safe to define inline.
  }, [folder, folderTitle, registry])

  return [targetRef, folder, store]
}

// ---------------------------------------------------------------------------
// useDebugState — subscribe to a debug store by folder title OR direct StoreApi
// ---------------------------------------------------------------------------

export function useDebugState<T extends DebugTarget>(store: StoreApi<T>): T
export function useDebugState<T extends DebugTarget, S>(store: StoreApi<T>, selector: (state: T) => S): S
export function useDebugState<T>(folderTitle: string): T
export function useDebugState<T, S>(folderTitle: string, selector: (state: T) => S): S
export function useDebugState(
  storeOrTitle: StoreApi<DebugTarget> | string,
  selector?: (state: DebugTarget) => unknown
) {
  const { registry } = useDebug()
  const store = typeof storeOrTitle === 'string' ? getStore(registry, storeOrTitle) : storeOrTitle
  return useStore(store, selector as (state: DebugTarget) => unknown)
}
registry/lib/debug/hash-state.ts
function encodeState(state: object): string {
  return btoa(encodeURIComponent(JSON.stringify(state)))
}

export function parseHashState(): Record<string, unknown> | null {
  if (typeof window === 'undefined') return null
  const hash = window.location.hash
  if (!hash.startsWith('#tp=')) return null
  try {
    const encoded = hash.slice(4)
    return JSON.parse(decodeURIComponent(atob(encoded)))
  } catch {
    console.warn('[debug] Failed to parse tweakpane state from URL')
    return null
  }
}

export function writeHashState(state: object): void {
  const encoded = encodeState(state)
  const url = new URL(window.location.href)
  url.hash = `tp=${encoded}`
  window.history.replaceState(null, '', url.toString())
}
registry/lib/debug/index.ts
export { DebugProvider } from './provider'
export { useDebugFolder, useDebugBindings, useDebugState } from './hooks'
export type { DebugOptions } from './hooks'
registry/lib/debug/use-draggable.ts
import { useEffect, type RefObject } from 'react'

/**
 * Makes a fixed-position container draggable by a handle element.
 *
 * - Clamped to the viewport so the panel can never leave the screen.
 * - After each drag, anchors to the nearest corner (top/bottom × left/right)
 *   so that content growth always pushes toward the center, not off-screen.
 */
export function useDraggable(
  containerRef: RefObject<HTMLElement | null>,
  handleSelector: string,
  active: boolean
) {
  useEffect(() => {
    const container = containerRef.current
    if (!active || !container) return

    const handle = container.querySelector<HTMLElement>(handleSelector)
    if (!handle) return

    handle.style.cursor = 'grab'

    let dragging = false
    let didDrag = false
    let offsetX = 0
    let offsetY = 0

    function anchorToCorner(el: HTMLElement) {
      const rect = el.getBoundingClientRect()
      const cx = rect.left + rect.width / 2
      const cy = rect.top + rect.height / 2
      const vw = window.innerWidth
      const vh = window.innerHeight

      if (cx > vw / 2) {
        el.style.right = `${vw - rect.right}px`
        el.style.left = 'auto'
      } else {
        el.style.left = `${rect.left}px`
        el.style.right = 'auto'
      }

      if (cy > vh / 2) {
        el.style.bottom = `${vh - rect.bottom}px`
        el.style.top = 'auto'
      } else {
        el.style.top = `${rect.top}px`
        el.style.bottom = 'auto'
      }
    }

    const onMouseDown = (e: MouseEvent) => {
      const rect = container.getBoundingClientRect()
      dragging = true
      didDrag = false
      offsetX = e.clientX - rect.left
      offsetY = e.clientY - rect.top
      handle.style.cursor = 'grabbing'
      e.preventDefault()
    }

    const onMouseMove = (e: MouseEvent) => {
      if (!dragging) return
      didDrag = true
      const w = container.offsetWidth
      const h = container.offsetHeight

      let x = e.clientX - offsetX
      let y = e.clientY - offsetY

      x = Math.max(0, Math.min(x, window.innerWidth - w))
      y = Math.max(0, Math.min(y, window.innerHeight - h))

      container.style.left = `${x}px`
      container.style.top = `${y}px`
      container.style.right = 'auto'
      container.style.bottom = 'auto'
    }

    const onMouseUp = () => {
      if (!dragging) return
      dragging = false
      handle.style.cursor = 'grab'
      if (didDrag) anchorToCorner(container)
    }

    // Swallow the click that fires after a drag so Tweakpane
    // doesn't toggle expand/collapse.
    const onClick = (e: MouseEvent) => {
      if (didDrag) {
        e.stopPropagation()
        didDrag = false
      }
    }

    handle.addEventListener('mousedown', onMouseDown)
    handle.addEventListener('click', onClick, true)
    window.addEventListener('mousemove', onMouseMove)
    window.addEventListener('mouseup', onMouseUp)

    return () => {
      handle.removeEventListener('mousedown', onMouseDown)
      handle.removeEventListener('click', onClick, true)
      window.removeEventListener('mousemove', onMouseMove)
      window.removeEventListener('mouseup', onMouseUp)
    }
  }, [containerRef, handleSelector, active])
}

More from joyco

Browse all components in this library →