tweakpane
integrationInstallation
npx jsrepo add github/reatom/reusables tweakpane Registry dependencies
Copy the source code below and save it to the specified file path in your project.
export * from './bindings'
export * from './blades'
export * from './core'
export * from './essentials' import { type AtomLike, named } from '@reatom/core'
import type {
BaseParams,
Controller,
FolderApi,
RackApi,
TabPageApi,
} from '@tweakpane/core'
import * as EssentialsPlugin from '@tweakpane/plugin-essentials'
import { type FolderParams, Pane, type TabParams } from 'tweakpane'
import { reatomInstance } from '../instance/reatom-instance'
/** Types that may work as containers for other blades. */
export type BladeRackApi = FolderApi | RackApi | TabPageApi
/** A disposable resource with a controller. */
export type Disposable = { dispose: () => void; controller: Controller }
/**
* Create a lazy reactive disposable resource.
*
* This helper wraps creation of external resources (Tweakpane instances,
* folders, tabs, blades, etc.) in a computed atom so the resource is created
* only when the atom is subscribed to and disposed automatically when the atom
* is disconnected.
*
* @template T Resource type which must provide a `dispose()` method and a
* `controller`.
* @param create Factory that creates and returns the disposable resource.
* @param name Optional debugging name passed to the underlying computed atom.
* @returns A computed atom that yields the created resource and manages its
* lifecycle.
*/
export const reatomDisposable = <T extends Disposable>(
create: () => T,
name: string = named('disposable'),
) =>
reatomInstance(
() => create(),
(disposable) => disposable.dispose(),
name,
)
/** Pane configuration options. */
export type PaneConfig = ConstructorParameters<typeof Pane>[0]
/**
* Creates a reactive Tweakpane instance.
*
* This atom manages the lifecycle of the Pane: creating it on connection and
* disposing it when the atom is no longer subscribed to.
*
* @example
* const pane = reatomPane({ name: 'settings', title: 'Settings' })
* // Subscribe to activate
* effect(() => pane())
*
* @param params - Configuration options for the Pane
* @param params.name - Unique debugging name for the atom (required)
*/
export const reatomPane = (params: PaneConfig & { name: string }) =>
reatomDisposable(() => {
const pane = new Pane(params)
pane.registerPlugin(EssentialsPlugin)
return pane
}, `tweakpane.pane.${params.name}`)
/**
* Creates a reactive folder associated with a parent blade rack.
*
* @example
* const pane = reatomPane({ name: 'main' })
* const folder = reatomPaneFolder({ title: 'Transform' }, pane)
*
* @param params - Folder configuration (title, expanded, etc.)
* @param parent - The parent pane or folder atom
*/
export const reatomPaneFolder = (
params: FolderParams,
parent: AtomLike<BladeRackApi>,
) =>
reatomDisposable(
() => parent().addFolder(params),
`${parent.name}.${params.title}`,
)
/**
* Creates a tab interface with multiple pages.
*
* @example
* const tabs = reatomPaneTab(['General', 'Advanced'], pane)
* // Access pages via the .pages extension
* const generalPage = tabs.pages[0]
*
* @param params - Tab configuration or simple array of page titles
* @param parent - The parent pane or folder atom
*/
export const reatomPaneTab = (
params:
| string[]
| (Omit<TabParams, 'pages'> & { pages: string[] | TabParams['pages'] }),
parent: AtomLike<BladeRackApi>,
) => {
const normalizedParams: TabParams = Array.isArray(params)
? { pages: params.map((title) => ({ title })) }
: {
...params,
pages: params.pages.map((p) =>
typeof p === 'string' ? { title: p } : p,
),
}
return reatomDisposable(
() => parent().addTab(normalizedParams),
`${parent.name}.tabs`,
).extend((target) => ({
pages: normalizedParams.pages.map((_, i) =>
reatomDisposable(() => target().pages[i], `${target.name}.page.${i}`),
),
}))
}
/**
* Adds a visual separator to organize controls.
*
* @param params - Separator configuration
* @param parent - The parent pane or folder atom
*/
export const reatomPaneSeparator = (
params: BaseParams,
parent: AtomLike<BladeRackApi>,
) =>
reatomDisposable(
() => parent().addBlade({ view: 'separator', ...params }),
`${parent.name}.separator`,
) import {
type Atom,
type AtomLike,
type EnumAtom,
type Frame,
top,
withChangeHook,
withConnectHook,
wrap,
} from '@reatom/core'
import type { Formatter } from '@tweakpane/core'
import type { BindingParams, ListParamsOptions } from 'tweakpane'
import { type BladeRackApi, reatomDisposable } from './core'
const isEnumAtom = (target: Atom<unknown>): target is EnumAtom<string> =>
'enum' in target && typeof target.enum === 'object' && target.enum !== null
const toBindingObject = <T>(target: Atom<T>, frame: Frame) => ({
get value() {
return frame.run(target)
},
set value(v: T) {
frame.run(target.set, v)
},
})
/**
* Extends an atom with a two-way binding to a Tweakpane control.
*
* Automatically detects `atom.enum` property to create dropdown lists.
*
* This relies on Tweakpane's internal matching algorithms to determine the view
* type (color, point, number, string, boolean, etc) based on the atom's initial
* value.
*
* @example
* const pane = reatomPane({ name: 'settings' })
* const speed = atom(0.5).extend(
* withBinding({ label: 'Speed', min: 0, max: 1 }, pane),
* )
*
* @param bindingParams - Tweakpane binding configuration
* @param parent - The parent blade rack (pane or folder)
*/
export const withBinding =
<T>(
bindingParams: Omit<BindingParams, 'options' | 'format'> & {
options?: ListParamsOptions<T>
format?: Formatter<T>
},
parent: AtomLike<BladeRackApi>,
) =>
(target: Atom<T>) => {
// Auto-detect enum atoms and generate options
const params: BindingParams = isEnumAtom(target)
? { options: target.enum, ...bindingParams }
: bindingParams
const bindingAtom = reatomDisposable(() => {
const parentApi = parent()
const bindingObject = toBindingObject(target, top().root.frame)
const bindingApi = parentApi.addBinding(bindingObject, 'value', params)
bindingApi.on(
'change',
wrap(({ value }) => {
// tweakpane mutates properties of binding object
// so we need to create a new object in order to trigger reatom update
if (typeof value === 'object') {
target.set({ ...value })
}
}),
)
return bindingApi
}, `${parent.name}.${target.name}.binding`)
target.extend(
withConnectHook(() => bindingAtom.subscribe()),
withChangeHook(() => void bindingAtom().refresh()),
)
return { binding: bindingAtom }
} import {
type Action,
type AtomLike,
isAction,
isWritableAtom,
withConnectHook,
wrap,
} from '@reatom/core'
import type { BladeApi } from '@tweakpane/core'
import type { BaseBladeParams, ButtonParams } from 'tweakpane'
import { type BladeRackApi, type Disposable, reatomDisposable } from './core'
/**
* Extends an action to be triggered by a Tweakpane button.
*
* NOTE: You must subscribe to the action (e.g., using `getCalls` in an effect)
* to ensure the binding is created and active.
*
* @example
* const pane = reatomPane({ name: 'controls' })
* const doThing = action(() => ...).extend(withButton({ title: 'Do Thing' }, pane))
* // Required to activate the binding:
* effect(() => getCalls(doThing))
*
* @param params - Button configuration
* @param parent - Parent blade rack
*/
export const withButton =
(params: ButtonParams, parent: AtomLike<BladeRackApi>) =>
<T extends Action>(target: T) => {
const buttonAtom = reatomDisposable(() => {
const btnApi = parent().addButton(params)
btnApi.on('click', wrap(target))
return btnApi
}, `${parent.name}.${target.name}.button`)
target.extend(withConnectHook(() => buttonAtom.subscribe()))
return { button: buttonAtom }
}
/**
* Generic extension to add any Tweakpane blade.
*
* @example
* const pane = reatomPane({ name: 'main' })
* const slider = atom(50).extend(
* withBlade({ view: 'slider', min: 0, max: 100 }, pane),
* )
*
* @param params - Blade definition
* @param parent - Parent blade rack
*/
export const withBlade =
<Api extends Disposable = BladeApi>(
params: BaseBladeParams,
parent: AtomLike<BladeRackApi>,
) =>
<T extends AtomLike<unknown> | Action<unknown[], unknown>>(target: T) => {
const bladeAtom = reatomDisposable(() => {
const parentApi = parent()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bladeApi = parentApi.addBlade(params) as any
if (isAction(target)) {
bladeApi.on('click', wrap(target))
} else if (isWritableAtom(target)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bladeApi.on(
'change',
wrap((ev: any) => target.set(ev.value)),
)
}
return bladeApi as Api
}, `${parent.name}.${target.name}.blade`)
target.extend(withConnectHook(() => bladeAtom.subscribe()))
return { blade: bladeAtom }
} import { type AtomLike } from '@reatom/core'
import type { BaseInputParams } from '@tweakpane/core'
import type { FpsGraphBladeApi } from '@tweakpane/plugin-essentials/dist/types/fps-graph/api/fps-graph'
import type { BaseBladeParams } from 'tweakpane'
import { withBinding } from './bindings'
import { withBlade } from './blades'
import { type BladeRackApi, reatomDisposable } from './core'
/** Configuration for radio grid binding. */
export type RadioGridParams<T> = {
groupName: string
size: [number, number]
cells: (x: number, y: number) => { title: string; value: T }
label: string
} & BaseInputParams
/**
* Creates a 2D grid of radio buttons.
*
* @param params - Grid configuration (group name, size, cells, etc.)
* @param parent - Parent blade rack
*/
export const withRadioGrid = <T>(
params: RadioGridParams<T>,
parent: AtomLike<BladeRackApi>,
) => withBinding<T>({ view: 'radiogrid', ...params }, parent)
/** Configuration for button grid blade. */
export type ButtonGridParams = {
size: [number, number]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cells: (x: number, y: number) => { title: string; [key: string]: any }
label: string
} & BaseBladeParams
/**
* Creates a 2D grid of buttons.
*
* @param params - Grid configuration
* @param parent - Parent blade rack
*/
export const withButtonGrid = (
params: ButtonGridParams,
parent: AtomLike<BladeRackApi>,
) => withBlade({ view: 'buttongrid', ...params }, parent)
/** Configuration for cubic bezier blade. */
export type CubicBezierParams = {
expanded?: boolean
picker?: 'inline' | 'popup'
label?: string
} & BaseBladeParams
/**
* Creates a cubic bezier curve editor.
*
* @param params - Editor configuration
* @param parent - Parent blade rack
*/
export const withCubicBezier = (
params: CubicBezierParams,
parent: AtomLike<BladeRackApi>,
) => withBlade({ view: 'cubicbezier', ...params, value: [0, 0, 1, 1] }, parent)
/** Configuration for FPS graph blade. */
export type FpsGraphParams = {
rows?: number
max?: number
min?: number
interval?: number
label?: string
} & BaseBladeParams
/**
* Creates a performance monitoring graph.
*
* @example
* const pane = reatomPane({ name: 'debug' })
* const fpsGraph = reatomFpsGraph({ label: 'FPS', interval: 500 }, pane)
* // In render loop:
* fpsGraph().begin()
* // ... render ...
* fpsGraph().end()
*
* @param params - Graph configuration
* @param parent - Parent blade rack
*/
export const reatomFpsGraph = (
params: FpsGraphParams,
parent: AtomLike<BladeRackApi>,
) =>
reatomDisposable(
() =>
parent().addBlade({
view: 'fpsgraph',
...params,
}) as unknown as FpsGraphBladeApi,
`${parent.name}.fpsGraph`,
) Documentation
Tweakpane Integration
Reactive bindings for Tweakpane — a compact GUI for fine-tuning values and monitoring data. This integration manages Tweakpane's lifecycle automatically through Reatom's subscription model.
Installation
This integration requires tweakpane and @tweakpane/plugin-essentials:
npm install tweakpane @tweakpane/plugin-essentials
Core Concepts
All Tweakpane resources (panes, folders, bindings) are wrapped in computed atoms that:
- Create the resource only when subscribed to
- Automatically dispose when unsubscribed
- Support reactive updates
API Reference
reatomPane(params)
Creates a reactive Tweakpane instance.
import { reatomPane } from '#reatom/tweakpane'
const pane = reatomPane({ name: 'settings', title: 'Settings' })
// Activate by subscribing
pane.subscribe()
reatomPaneFolder(params, parent)
Creates a collapsible folder inside a pane.
const folder = reatomPaneFolder({ title: 'Transform' }, pane)
reatomPaneTab(pages, parent)
Creates a tabbed interface.
const tabs = reatomPaneTab(['General', 'Advanced'], pane)
// Access pages
const generalPage = tabs.pages[0]
withBinding(params, parent)
Extension that creates a two-way binding between an atom and a Tweakpane control.
import { atom } from '@reatom/core'
import { withBinding } from '#reatom/tweakpane'
const speed = atom(1.0, 'speed').extend(
withBinding({ label: 'Speed', min: 0, max: 10 }, pane),
)
// Subscribe to activate the binding
speed.subscribe((v) => console.log('Speed:', v))
Automatic view detection: Tweakpane automatically chooses the appropriate control based on the initial value:
| Value Type | Control |
|---|---|
number |
Slider/Number |
boolean |
Checkbox |
string |
Text input |
#rrggbb |
Color picker |
{ x, y } / {x,y,z} |
Point editor |
reatomEnum |
Dropdown |
withButton(params, parent)
Extension that triggers an action from a button.
import { action, effect, getCalls } from '@reatom/core'
import { withButton } from '#reatom/tweakpane'
const reset = action(() => {
// reset logic
}, 'reset').extend(withButton({ title: 'Reset' }, pane))
// Must subscribe to activate
effect(() => getCalls(reset))
reatomFpsGraph(params, parent)
Creates an FPS monitoring graph.
import { reatomFpsGraph } from '#reatom/tweakpane'
const fps = reatomFpsGraph({ label: 'FPS' }, pane)
fps.subscribe()
function render() {
fps().begin()
// ... render ...
fps().end()
requestAnimationFrame(render)
}
Complete Example
import { action, atom, effect, reatomEnum, getCalls } from '@reatom/core'
import {
reatomPane,
reatomPaneFolder,
withBinding,
withButton,
} from '#reatom/tweakpane'
// Create pane
const pane = reatomPane({ name: 'game', title: 'Game Settings' })
// Basic values
const speed = atom(1.0, 'speed').extend(
withBinding({ label: 'Speed', min: 0, max: 10 }, pane),
)
const color = atom('#ff0000', 'color').extend(
withBinding({ label: 'Color' }, pane),
)
const difficulty = reatomEnum(['easy', 'medium', 'hard'], 'difficulty').extend(
withBinding({ label: 'Difficulty' }, pane),
)
// Folder for transforms
const transform = reatomPaneFolder({ title: 'Transform' }, pane)
const position = atom({ x: 0, y: 0 }, 'position').extend(
withBinding({ label: 'Position' }, transform),
)
const scale = atom(1.0, 'scale').extend(
withBinding({ label: 'Scale', min: 0.1, max: 5 }, transform),
)
// Reset button
const reset = action(() => {
speed.set(1.0)
position.set({ x: 0, y: 0 })
scale.set(1.0)
}, 'reset').extend(withButton({ title: 'Reset All' }, pane))
// Activate everything
speed.subscribe()
color.subscribe()
difficulty.subscribe()
position.subscribe()
scale.subscribe()
effect(() => getCalls(reset))
Lifecycle Management
Resources are automatically disposed when their atoms are unsubscribed:
const pane = reatomPane({ name: 'temp' })
const unsub = pane.subscribe()
// Later: disposes the pane and all its children
unsub()
This integrates seamlessly with Reatom's effect for component-scoped GUIs.
Example
import { action, atom, reatomEnum } from '@reatom/core'
import { reatomComponent } from '@reatom/react'
import { withBinding } from './bindings'
import { withButton } from './blades'
import { reatomPane, reatomPaneFolder, reatomPaneTab } from './core'
import { hotWrap } from '../hot-wrap/hot-wrap'
// --- Tweakpane with reatomComponent ---
// Atoms are auto-subscribed when read in render
// Actions use hotWrap for subscription + event handler wrapping
const settingsPane = reatomPane({ name: 'settings', title: 'Settings' })
const speed = atom(1.0, 'speed').extend(
withBinding({ label: 'Speed', min: 0, max: 10, step: 0.1 }, settingsPane),
)
const enabled = atom(true, 'enabled').extend(
withBinding({ label: 'Enabled' }, settingsPane),
)
const position = atom({ x: 0, y: 0 }, 'position').extend(
withBinding({ label: 'Position' }, settingsPane),
)
const mode = reatomEnum(['idle', 'running', 'paused'], 'mode').extend(
withBinding({ label: 'Mode' }, settingsPane),
)
const resetAction = action(() => {
speed.set(1.0)
enabled.set(true)
position.set({ x: 0, y: 0 })
mode.reset()
}, 'reset').extend(withButton({ title: 'Reset All' }, settingsPane))
export const SettingsPanel = reatomComponent(() => {
// Reading atoms auto-subscribes them → Tweakpane bindings activate
const currentSpeed = speed()
const isEnabled = enabled()
const pos = position()
const currentMode = mode()
return (
<div>
<p>Speed: {currentSpeed} </p>
<p> Enabled: {isEnabled ? 'Yes' : 'No'} </p>
<p>
Position: {pos.x}, {pos.y}
</p>
<p>Mode: {currentMode}</p>
{/* hotWrap for actions: subscribes (creates button) + wraps for onClick */}
<button onClick={hotWrap(resetAction)}> Reset </button>
</div>
)
}, 'SettingsPanel')
// --- Folders example ---
const transformFolder = reatomPaneFolder({ title: 'Transform' }, settingsPane)
const scale = atom(1.0, 'scale').extend(
withBinding({ label: 'Scale', min: 0.1, max: 5 }, transformFolder),
)
const rotation = atom(0, 'rotation').extend(
withBinding({ label: 'Rotation', min: 0, max: 360 }, transformFolder),
)
export const TransformPanel = reatomComponent(() => {
return (
<div>
<p>Scale: {scale()} </p>
<p> Rotation: {rotation()}°</p>
</div>
)
}, 'TransformPanel')
// --- Tabs example ---
const tabsPane = reatomPane({ name: 'tabs-demo', title: 'Demo' })
const tabs = reatomPaneTab(['General', 'Advanced', 'Debug'], tabsPane)
const volume = atom(0.8, 'volume').extend(
withBinding({ label: 'Volume', min: 0, max: 1 }, tabs.pages[0]),
)
const debugMode = atom(false, 'debugMode').extend(
withBinding({ label: 'Debug' }, tabs.pages[2]),
)
export const TabsPanel = reatomComponent(() => {
return (
<div>
<p>Volume: {Math.round(volume() * 100)}% </p>
<p> Debug: {debugMode() ? 'On' : 'Off'} </p>
</div>
)
}, 'TabsPanel')