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])
}