OpenTUI Solid is a custom SolidJS reconciler that brings fine-grained reactivity to terminal user interfaces. Build performant CLI apps with Solid’s reactive primitives.
Installation
npm install solid-js @opentui/solid
Setup
Add JSX configuration to your tsconfig.json:
{
"compilerOptions" : {
"jsx" : "preserve" ,
"jsxImportSource" : "@opentui/solid"
}
}
2. Add Preload Script
Configure bunfig.toml to preload the Solid plugin:
preload = [ "@opentui/solid/preload" ]
3. Create Your App
import { render } from "@opentui/solid"
function App () {
return < text > Hello, OpenTUI! </ text >
}
render (() => < App /> )
4. Run Your App
Building for Production
Use Bun.build with the Solid plugin:
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun . build ({
entrypoints: [ "./index.tsx" ],
target: "bun" ,
outdir: "./build" ,
plugins: [ solidPlugin ],
compile: {
target: "bun-darwin-arm64" ,
outfile: "app-macos" ,
},
})
Rendering
render
Render a Solid component tree into a CLI renderer:
import { createCliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
// With default renderer
render (() => < App /> )
// With custom renderer
const renderer = await createCliRenderer ({
exitOnCtrlC: false ,
})
render (() => < App /> , renderer )
// With renderer config
render (() => < App /> , {
exitOnCtrlC: false ,
})
Parameters:
node - Function returning a JSX element
rendererOrConfig? - CliRenderer instance or CliRendererConfig
testRender
Create a test renderer for snapshots and interaction tests:
import { testRender } from "@opentui/solid"
const testSetup = await testRender (() => < App /> , {
width: 40 ,
height: 10 ,
})
// Access the renderer
testSetup . renderer
// Simulate keyboard input
testSetup . sendKey ({ name: "return" })
// Get rendered output
const snapshot = testSetup . renderer . toString ()
Reactive Hooks
useRenderer
Access the OpenTUI renderer instance:
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App () {
const renderer = useRenderer ()
onMount (() => {
renderer . console . show ()
console . log ( "Hello from console!" )
})
return < box />
}
useKeyboard
Handle keyboard input with press and release events:
import { useKeyboard } from "@opentui/solid"
import { createSignal } from "solid-js"
function App () {
const [ pressed , setPressed ] = createSignal < Set < string >>( new Set ())
useKeyboard (( event ) => {
setPressed ( prev => {
const next = new Set ( prev )
if ( event . eventType === "release" ) {
next . delete ( event . name )
} else {
next . add ( event . name )
}
return next
})
}, { release: true })
return (
< text >
Pressed: { Array . from ( pressed ()). join ( ", " ) || "none" }
</ text >
)
}
Parameters:
callback - Function receiving a KeyEvent object
options? - Optional configuration:
release?: boolean - Include key release events (default: false)
By default, only press events are received (including repeats with repeated: true). Set options.release = true to also receive release events.
onResize
Handle terminal resize events:
import { onResize , useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App () {
const renderer = useRenderer ()
onMount (() => {
renderer . console . show ()
})
onResize (( width , height ) => {
console . log ( `Resized to ${ width } x ${ height } ` )
})
return < text > Resize-aware component </ text >
}
useTerminalDimensions
Get current terminal dimensions with automatic updates:
import { useTerminalDimensions } from "@opentui/solid"
function App () {
const dimensions = useTerminalDimensions ()
return (
< box >
< text > Terminal: { dimensions (). width } x { dimensions (). height } </ text >
< box
style = { {
width: Math . floor ( dimensions (). width / 2 ),
height: Math . floor ( dimensions (). height / 3 )
} }
>
< text > Half-width, third-height box </ text >
</ box >
</ box >
)
}
Returns: Signal containing { width: number, height: number }
usePaste
Handle paste events from the terminal:
import { usePaste } from "@opentui/solid"
import { createSignal } from "solid-js"
function App () {
const [ pastedText , setPastedText ] = createSignal ( "" )
usePaste (( event ) => {
setPastedText ( event . text )
})
return < text > Last paste: { pastedText () } </ text >
}
useSelectionHandler
Handle text selection events:
import { useSelectionHandler } from "@opentui/solid"
import { createSignal } from "solid-js"
function App () {
const [ selection , setSelection ] = createSignal < string | null >( null )
useSelectionHandler (( sel ) => {
setSelection ( sel . text )
})
return < text > Selected: { selection () || "none" } </ text >
}
useTimeline
Create and manage animations using OpenTUI’s timeline system:
import { useTimeline } from "@opentui/solid"
import { createSignal , onMount } from "solid-js"
function App () {
const [ width , setWidth ] = createSignal ( 0 )
const timeline = useTimeline ({
duration: 2000 ,
loop: false ,
})
onMount (() => {
timeline . add (
{ width: width () },
{
width: 50 ,
duration: 2000 ,
ease: "linear" ,
onUpdate : ( animation ) => {
setWidth ( animation . targets [ 0 ]. width )
},
}
)
})
return < box style = { { width: width (), backgroundColor: "#6a5acd" } } />
}
Parameters:
options? - Optional TimelineOptions:
duration?: number - Animation duration in ms (default: 1000)
loop?: boolean - Whether to loop (default: false)
autoplay?: boolean - Auto-start timeline (default: true)
onComplete?: () => void - Completion callback
onPause?: () => void - Pause callback
Returns: Timeline instance with methods:
add(target, properties, startTime) - Add animation
play() - Start timeline
pause() - Pause timeline
restart() - Restart from beginning
Components
OpenTUI Solid provides intrinsic JSX elements that map to OpenTUI renderables:
Layout & Display
<text> - Display styled text
<box> - Container with borders and layout
<scrollbox> - Scrollable container
<ascii_font> - ASCII art text renderer
Notice the underscore in ascii_font - Solid uses underscores for multi-word element names.
<input> - Single-line text input
<textarea> - Multi-line text input
<select> - Dropdown selection
<tab_select> - Tab-based selection
Code & Diff
<code> - Syntax-highlighted code blocks
<line_number> - Line-numbered code with diff/diagnostics
<diff> - Unified or split diff viewer
Text Modifiers
These elements must be used inside a <text> component:
<span> - Inline styled text
<strong>, <b> - Bold text
<em>, <i> - Italic text
<u> - Underlined text
<br> - Line break
<a> - Link with href attribute
See the Components section for detailed documentation on each component.
Advanced Components
Portal
Render children into a different mount point, useful for overlays and modals:
import { Portal , useRenderer } from "@opentui/solid"
function App () {
const renderer = useRenderer ()
return (
<>
< box border >
< text > Main content </ text >
</ box >
< Portal mount = { renderer . root } >
< box border title = "Overlay" >
< text > This is rendered at the root level </ text >
</ box >
</ Portal >
</>
)
}
Dynamic
Render arbitrary intrinsic elements or components dynamically:
import { Dynamic } from "@opentui/solid"
import { createSignal } from "solid-js"
function App () {
const [ isMultiline , setIsMultiline ] = createSignal ( false )
return (
< box >
< Dynamic
component = { isMultiline () ? "textarea" : "input" }
placeholder = "Type here..."
focused
/>
< text onClick = { () => setIsMultiline ( ! isMultiline ()) } >
Toggle multiline
</ text >
</ box >
)
}
Examples
Counter with Reactive Updates
import { render } from "@opentui/solid"
import { createSignal , onCleanup } from "solid-js"
function App () {
const [ count , setCount ] = createSignal ( 0 )
const interval = setInterval (() => {
setCount ( c => c + 1 )
}, 1000 )
onCleanup (() => clearInterval ( interval ))
return (
< box title = "Counter" style = { { padding: 2 } } >
< text fg = "#00FF00" > Count: { count () } </ text >
</ box >
)
}
render (() => < App /> )
Interactive Todo List
import { render , useKeyboard } from "@opentui/solid"
import { createSignal , For } from "solid-js"
type Todo = { id : number ; text : string ; done : boolean }
function App () {
const [ todos , setTodos ] = createSignal < Todo []>([
{ id: 1 , text: "Learn OpenTUI" , done: false },
{ id: 2 , text: "Build a CLI app" , done: false },
])
const [ selected , setSelected ] = createSignal ( 0 )
useKeyboard (( key ) => {
if ( key . name === "up" ) {
setSelected ( s => Math . max ( 0 , s - 1 ))
} else if ( key . name === "down" ) {
setSelected ( s => Math . min ( todos (). length - 1 , s + 1 ))
} else if ( key . name === "space" ) {
setTodos ( todos => todos . map (( t , i ) =>
i === selected () ? { ... t , done: ! t . done } : t
))
}
})
return (
< box title = "Todo List" border style = { { padding: 1 , flexDirection: "column" } } >
< For each = { todos () } >
{ ( todo , i ) => (
< text
fg = { i () === selected () ? "#00FF00" : "#FFFFFF" }
style = { { backgroundColor: i () === selected () ? "#333333" : undefined } }
>
{ todo . done ? "[x]" : "[ ]" } { todo . text }
</ text >
) }
</ For >
< text fg = "#666666" > ↑↓ to navigate, Space to toggle </ text >
</ box >
)
}
render (() => < App /> )
Animated Progress Bar
import { render , useTimeline } from "@opentui/solid"
import { createSignal , onMount } from "solid-js"
function App () {
const [ progress , setProgress ] = createSignal ( 0 )
const timeline = useTimeline ({
duration: 3000 ,
loop: true ,
})
onMount (() => {
timeline . add (
{ value: 0 },
{
value: 100 ,
duration: 3000 ,
ease: "linear" ,
onUpdate : ( anim ) => {
setProgress ( Math . floor ( anim . targets [ 0 ]. value ))
},
}
)
})
return (
< box title = "Loading..." border style = { { padding: 1 , flexDirection: "column" } } >
< text > Progress: { progress () } % </ text >
< box style = { { width: 50 , height: 1 , backgroundColor: "#333333" } } >
< box
style = { {
width: Math . floor ( 50 * progress () / 100 ),
height: 1 ,
backgroundColor: "#00FF00"
} }
/>
</ box >
</ box >
)
}
render (() => < App /> )
Extending Components
Register custom renderables as JSX intrinsic elements:
import {
BoxRenderable ,
OptimizedBuffer ,
RGBA ,
type BoxOptions ,
type RenderContext ,
} from "@opentui/core"
import { render , extend } from "@opentui/solid"
class CustomButton extends BoxRenderable {
private _label : string
constructor ( ctx : RenderContext , options : BoxOptions & { label ?: string }) {
super ( ctx , {
border: true ,
borderStyle: "single" ,
minHeight: 3 ,
... options ,
})
this . _label = options . label ?? "Button"
}
protected renderSelf ( buffer : OptimizedBuffer ) : void {
super . renderSelf ( buffer )
const centerX = this . x + Math . floor ( this . width / 2 - this . _label . length / 2 )
const centerY = this . y + Math . floor ( this . height / 2 )
buffer . drawText ( this . _label , centerX , centerY , RGBA . fromInts ( 255 , 255 , 255 , 255 ))
}
}
// Register the component
extend ({ custom_button: CustomButton })
// Add TypeScript support
declare module "@opentui/solid" {
namespace JSX {
interface IntrinsicElements {
custom_button : BoxOptions & { label ?: string }
}
}
}
// Use in JSX
function App () {
return (
< box >
< custom_button label = "Click me!" style = { { backgroundColor: "blue" } } />
</ box >
)
}
render (() => < App /> )
Utilities
getComponentCatalogue
Get the current component catalogue that powers JSX tag lookup:
import { getComponentCatalogue } from "@opentui/solid"
const catalogue = getComponentCatalogue ()
console . log ( Object . keys ( catalogue ))
// ["box", "text", "input", "textarea", ...]
Use fine-grained reactivity
Solid’s reactivity system updates only what changes. Avoid wrapping large component trees in effects - let Solid track dependencies automatically. // Good - fine-grained
< text > Count: { count () } </ text >
// Avoid - coarse-grained
createEffect (() => {
return < text > Count: { count () } </ text >
})
Destructure props in render, not component body
Destructuring props at the component level breaks reactivity. Access props directly or destructure in JSX. // Good
function Component ( props ) {
return < text > { props . value } </ text >
}
// Bad - loses reactivity
function Component ( props ) {
const { value } = props
return < text > { value } </ text >
}
Use Index for static lists
When rendering lists where items don’t change identity, use <Index> instead of <For> for better performance. import { Index } from "solid-js"
< Index each = { items () } >
{ ( item , i ) => < text > { item () } </ text > }
</ Index >