Skip to main content
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

1. Configure TypeScript

Add JSX configuration to your tsconfig.json:
tsconfig.json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@opentui/solid"
  }
}

2. Add Preload Script

Configure bunfig.toml to preload the Solid plugin:
bunfig.toml
preload = ["@opentui/solid/preload"]

3. Create Your App

index.tsx
import { render } from "@opentui/solid"

function App() {
  return <text>Hello, OpenTUI!</text>
}

render(() => <App />)

4. Run Your App

bun index.tsx

Building for Production

Use Bun.build with the Solid plugin:
build.ts
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 Components

  • <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", ...]

Performance Tips

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

Build docs developers (and LLMs) love