Solid bindings
Use Solid components and reactive primitives to build an OpenTUI application.
Requirements#
@opentui/solid requires solid-js 1.9.12 exactly. See
Runtime and platform support for the current runtime, operating system,
architecture, and native renderer matrix.
Installation#
bun install solid-js @opentui/solidSetup#
1. Configure TypeScript#
Add JSX config to tsconfig.json:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}2. Configure Bun#
Add preload script to bunfig.toml:
preload = ["@opentui/solid/preload"]3. Configure Node compilation#
The Node path requires compiled Solid TSX. Use Solid’s universal transform semantics. Point the generated runtime
imports at the published solid-js JavaScript files:
import ts from "@babel/preset-typescript"
import moduleResolver from "babel-plugin-module-resolver"
import solid from "babel-preset-solid"
export default {
plugins: [
[
moduleResolver,
{
resolvePath(specifier) {
if (specifier === "solid-js") return "solid-js/dist/solid.js"
if (specifier === "solid-js/store") return "solid-js/store/dist/store.js"
return specifier
},
},
],
],
presets: [[solid, { moduleName: "@opentui/solid", generate: "universal" }], [ts]],
}Use Node.js 26.4.0 with --experimental-ffi. If you enable the Node permission model, also pass --permission,
--allow-ffi, and the filesystem permissions that the application needs. The Solid Node source lane in CI compiles
TSX and runs on Linux x64.
4. Create the app#
import { render } from "@opentui/solid"
const App = () => <text>Hello, World!</text>
await render(App)Run the app with bun index.tsx.
Runtime-loaded modules#
Solid hosts that load TSX modules at runtime use this framework-specific import:
import "@opentui/solid/runtime-plugin-support"See Load plugins and modules at runtime for Bun setup, import order, module maps, and trust boundaries.
Components#
Solid JSX intrinsic elements map to Core renderables. Multi-word intrinsic names use snake_case, such as
<ascii_font> and <tab_select>. See the Components overview for availability and
registration requirements.
API reference#
render(node, rendererOrConfig?)#
Render a Solid component tree into a CLI renderer. Pass an existing CliRenderer or a CliRendererConfig.
import { render } from "@opentui/solid"
const App = () => <text>Hello, Solid!</text>
// Simple usage
await render(() => <App />)
// With renderer config
await render(() => <App />, {
targetFps: 30,
exitOnCtrlC: false,
})Parameters:
node: Function that returns a JSX element.rendererOrConfig: OptionalCliRendererinstance orCliRendererConfig.
testRender(node, options?)#
Create a test renderer for frame and interaction tests.
import { testRender } from "@opentui/solid"
const App = () => <text>Ready</text>
const testSetup = await testRender(() => <App />, { width: 40, height: 10 })extend(components)#
Register custom renderables as JSX intrinsic elements.
import { BoxRenderable } from "@opentui/core"
import { extend } from "@opentui/solid"
extend({ custom_box: BoxRenderable })getComponentCatalogue()#
Returns the current component catalogue that powers JSX tag lookup.
For plugin slots, see Plugin slots and Solid plugin slots.
Lifecycle and cleanup#
render() returns a promise that resolves after the initial mount. It does not return a disposer. When you pass a
renderer, Solid adopts it but does not take application ownership. The code that created that renderer must destroy it.
Renderer destruction disposes the Solid root. Disposal runs onCleanup callbacks and removes subscriptions created by
the Solid hooks. If render() creates the renderer from a config object, a component can get it with useRenderer()
and call renderer.destroy() during application shutdown.
See Lifecycle and cleanup for renderer shutdown and failure handling.
Scrollback writers#
In split-footer mode with externalOutputMode: "capture-stdout", the Solid binding includes helpers that append JSX-rendered output above the footer. They wrap renderer.writeToScrollback so you can write scrollback content with signals and components.
writeSolidToScrollback(renderer, node, options?)#
Render a JSX node once and append it as a scrollback commit.
import { writeSolidToScrollback } from "@opentui/solid"
writeSolidToScrollback(renderer, () => <text fg="#8BD5CA">api responded in 12ms</text>)createScrollbackWriter(node, options?)#
If you need to pass the same JSX rendering to multiple writeToScrollback calls (or hold onto the writer inside your own code), use the lower-level factory:
import { createScrollbackWriter } from "@opentui/solid"
const writer = createScrollbackWriter(() => <text>logged at {new Date().toISOString()}</text>, { startOnNewLine: true })
renderer.writeToScrollback(writer)Options#
| Option | Type | Default | Description |
|---|---|---|---|
width |
number |
renderer width | Override the snapshot width in columns |
height |
number |
measured auto | Override the snapshot height in rows (otherwise measured from layout) |
rowColumns |
number |
snapshot width | Explicit last-row column count for tail tracking |
startOnNewLine |
boolean |
true |
Insert a newline before this commit if the previous commit ended mid-row |
trailingNewline |
boolean |
true |
Append a newline after the final row |
The writer returns a ScrollbackSnapshot. Its teardown disposes the inner Solid subtree after the snapshot renders.
Use the Core ScrollbackSurface APIs for streaming commits that
render the same tree more than once.
Hooks#
The event hooks subscribe after mount and remove their listeners during Solid owner cleanup.
useRenderer()#
Access the OpenTUI renderer instance.
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
const App = () => {
const renderer = useRenderer()
onMount(() => {
renderer.console.show()
console.log("Hello from console!")
})
return <box />
}useKeyboard(handler, options?)#
Subscribe to keyboard events.
import { useKeyboard, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to close</text>
}With release events:
import { createSignal } from "solid-js"
const App = () => {
const [pressedKeys, setPressedKeys] = createSignal(new Set<string>())
useKeyboard(
(event) => {
setPressedKeys((keys) => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true },
)
return <text>Pressed: {Array.from(pressedKeys()).join(", ") || "none"}</text>
}onResize(callback)#
Handle terminal resize events.
import { onResize } from "@opentui/solid"
const App = () => {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize-aware component</text>
}onFocus(callback)#
Run side effects when the terminal window gains focus.
import { onFocus } from "@opentui/solid"
const App = () => {
onFocus(() => {
console.log("Terminal focused")
})
return <text>Switch away and back to trigger focus events</text>
}onBlur(callback)#
Run side effects when the terminal window loses focus.
import { onBlur } from "@opentui/solid"
const App = () => {
onBlur(() => {
console.log("Terminal blurred")
})
return <text>Switch away and back to trigger blur events</text>
}These hooks listen for terminal focus-in/focus-out events when the terminal emulator supports them.
useTerminalDimensions()#
Get reactive terminal dimensions (returns a Solid signal).
import { useTerminalDimensions } from "@opentui/solid"
const App = () => {
const dimensions = useTerminalDimensions()
return (
<text>
Terminal: {dimensions().width}x{dimensions().height}
</text>
)
}usePaste(handler)#
Subscribe to paste events.
import { usePaste } from "@opentui/solid"
const textDecoder = new TextDecoder()
const App = () => {
usePaste((event) => {
console.log("Pasted:", textDecoder.decode(event.bytes))
})
return <text>Paste something!</text>
}useSelectionHandler(callback)#
Handle text selection events.
import { useSelectionHandler } from "@opentui/solid"
const App = () => {
useSelectionHandler((selection) => {
console.log("Selected:", selection)
})
return <text selectable>Select me!</text>
}useTimeline(options?)#
useTimeline() creates and returns one Timeline during component setup. It registers the timeline on mount and starts
it unless autoplay is false. Solid pauses and unregisters it during owner cleanup.
import { useTimeline } from "@opentui/solid"
const App = () => {
const timeline = useTimeline({ autoplay: false })
return <text>{timeline.isPlaying ? "Playing" : "Paused"}</text>
}The hook reads its options during component setup. Read Animation and Timeline for scheduling, callbacks, engine ownership, and cleanup.
Special components#
Portal#
Render children into a different mount point. Use this for content such as modals and overlays.
import { Portal, useRenderer } from "@opentui/solid"
const App = () => {
const renderer = useRenderer()
return (
<box>
<text>Main content</text>
<Portal mount={renderer.root}>
<box border>Overlay</box>
</Portal>
</box>
)
}Dynamic#
Render arbitrary intrinsic elements or components dynamically.
import { Dynamic } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [isMultiline, setIsMultiline] = createSignal(false)
return <Dynamic component={isMultiline() ? "textarea" : "input"} />
}Testing#
testRender(node, options?) mounts a Solid root on a Core test renderer. It returns the Core TestRendererSetup.
Destroying the test renderer disposes the Solid root and runs onCleanup callbacks.
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
const App = () => <text>Ready</text>
test("renders the application", async () => {
const setup = await testRender(() => <App />, { width: 20, height: 4 })
try {
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Ready")
} finally {
setup.renderer.destroy()
}
})Use the returned setup for frame capture, waits, resize, keyboard and mouse input, native stats, and external output. Always destroy the renderer during test cleanup.
Example: counter#
import { render, useKeyboard, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
const App = () => {
const [count, setCount] = createSignal(0)
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "up") setCount((c) => c + 1)
if (key.name === "down") setCount((c) => c - 1)
if (key.name === "escape") renderer.destroy()
})
return (
<box border padding={2}>
<text>Count: {count()}</text>
<text fg="#888">Up/Down to change, ESC to close</text>
</box>
)
}
await render(App)React and Solid differences#
| Aspect | React | Solid |
|---|---|---|
| Render function | createRoot(renderer).render(<App />) |
render(() => <App />) |
| Component naming | kebab-case (ascii-font) |
snake_case (ascii_font) |
| State | useState |
createSignal |
| Effects | useEffect |
onMount, onCleanup |
| Resize hook | useOnResize(callback) |
onResize(callback) |
| Dimensions | Returns object: dimensions.width |
Returns signal: dimensions().width |
| Focus, blur, paste, and selection | useFocus, useBlur, usePaste, useSelectionHandler |
onFocus, onBlur, usePaste, useSelectionHandler |
| Special components | None | Portal, Dynamic |
Next#
- React bindings describes the React state and ownership model.
- Components overview lists Solid component availability and registration requirements.
- Interaction, focus, and selection owns shared pointer and focus behavior.
- Animation and Timeline documents scheduling and engine ownership.
- Solid keymap integration connects application commands to Solid state.
- Package entry points lists the public Solid imports.
- Runtime and platform support gives the tested runtime matrix.
- Deploy an OpenTUI application helps you choose a distribution format.
- Standalone executables gives the Bun and Node.js executable procedures.
- Troubleshooting routes runtime, native loading, and terminal failures.