React bindings

Use React components and hooks to build an OpenTUI application.

Requirements#

@opentui/react requires React >=19.2.0. See Runtime and platform support for the current runtime, operating system, architecture, and native renderer matrix.

Installation#

Create a project with Bun and create-tui:

bun create tui --template react

Or add the packages to an existing project:

bun install @opentui/react @opentui/core react

Quick start#

import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"

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

const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)

TypeScript configuration#

Configure your tsconfig.json:

{
  "compilerOptions": {
    "lib": ["ESNext", "DOM"],
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "jsxImportSource": "@opentui/react",
    "strict": true,
    "skipLibCheck": true
  }
}

Runtime-loaded modules#

React hosts that load TSX modules at runtime use this framework-specific import:

import "@opentui/react/runtime-plugin-support"

See Load plugins and modules at runtime for Bun setup, import order, module maps, and trust boundaries.

Components#

React JSX intrinsic elements map to Core renderables and use kebab-case names such as <ascii-font> and <tab-select>. See the Components overview for availability and registration requirements. React reconciles these elements into the renderer tree.

API reference#

createRoot(renderer)#

createRoot(renderer) adopts an existing CliRenderer. It returns a React root with render(node) and unmount().

import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"

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

const renderer = await createCliRenderer()
const root = createRoot(renderer)
root.render(<App />)

For plugin slots, see Plugin slots and React plugin slots.

Lifecycle and cleanup#

The React root does not own the renderer. root.unmount() removes the React tree and runs React effect cleanup while the renderer stays active. renderer.destroy() releases the terminal and renderer resources. It also unmounts the React root.

The code that creates the renderer owns renderer.destroy(). Call it on every application shutdown path. See Lifecycle and cleanup for signal and failure handling.

Hooks#

The event hooks subscribe after mount and remove their listeners during React effect cleanup.

useRenderer()#

Access the OpenTUI renderer instance.

import { useRenderer } from "@opentui/react"
import { useEffect } from "react"

function App() {
  const renderer = useRenderer()

  useEffect(() => {
    renderer.console.show()
    console.log("Hello from console!")
  }, [])

  return <box />
}

useKeyboard(handler, options?)#

Handle keyboard events.

import { useKeyboard, useRenderer } from "@opentui/react"

function App() {
  const renderer = useRenderer()

  useKeyboard((key) => {
    if (key.name === "escape") {
      renderer.destroy()
    }
  })

  return <text>Press ESC to close</text>
}

To handle release events:

useKeyboard(
  (event) => {
    if (event.eventType === "release") {
      console.log("Key released:", event.name)
    } else {
      console.log("Key pressed:", event.name)
    }
  },
  { release: true },
)

useOnResize(callback)#

Handle terminal resize events.

import { useOnResize } from "@opentui/react"

function App() {
  useOnResize((width, height) => {
    console.log(`Resized to ${width}x${height}`)
  })

  return <text>Resize-aware component</text>
}

useTerminalDimensions()#

Get reactive terminal dimensions.

import { useTerminalDimensions } from "@opentui/react"

function App() {
  const { width, height } = useTerminalDimensions()

  return (
    <text>
      Terminal: {width}x{height}
    </text>
  )
}

usePaste(handler)#

Handle terminal paste events (bracketed paste).

import { decodePasteBytes } from "@opentui/core"
import { usePaste } from "@opentui/react"

function App() {
  usePaste((event) => {
    const text = decodePasteBytes(event.bytes)
    console.log("Pasted text:", text)
  })

  return <text>Paste something into the terminal</text>
}

useFocus(handler)#

Subscribe to terminal window focus events.

import { useFocus } from "@opentui/react"

function App() {
  useFocus(() => {
    console.log("Terminal gained focus")
  })

  return <text>Focus-aware component</text>
}

useBlur(handler)#

Subscribe to terminal window blur events.

import { useBlur } from "@opentui/react"

function App() {
  useBlur(() => {
    console.log("Terminal lost focus")
  })

  return <text>Blur-aware component</text>
}

useSelectionHandler(handler)#

Handle text selection events such as mouse drag selection.

import { useSelectionHandler } from "@opentui/react"

function App() {
  useSelectionHandler((selection) => {
    const text = selection.getSelectedText()
    console.log("Selected:", text)
  })

  return <text selectable>Select this text with your mouse</text>
}

useTimeline(options?)#

useTimeline() creates one Timeline for the mounted hook. It registers the timeline after mount and starts it unless autoplay is false. React pauses and unregisters the same instance during effect cleanup.

import { useTimeline } from "@opentui/react"

function App() {
  const timeline = useTimeline({ autoplay: false })
  return <text>{timeline.isPlaying ? "Playing" : "Paused"}</text>
}

The hook reads its options when it creates the instance. Read Animation and Timeline for scheduling, callbacks, engine ownership, and cleanup.

Styling#

Style components with props or the style prop:

// Direct props
<box backgroundColor="blue" padding={2}>
  <text>Hello</text>
</box>

// Style prop
<box style={{ backgroundColor: "blue", padding: 2 }}>
  <text>Hello</text>
</box>

Testing#

@opentui/react/test-utils exports testRender(node, options). It is a React-aware wrapper around createTestRenderer(). It mounts the initial node with React act() and returns the Core TestRendererSetup. Renderer destruction unmounts the React root with act().

import { expect, test } from "bun:test"
import { testRender } from "@opentui/react/test-utils"

function App() {
  return <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()
  }
})

The options argument is required and uses TestRendererOptions. The helper enables React’s act-environment flag for the mounted test root. Renderer destruction unmounts the root, calls options.onDestroy when supplied, and then sets the flag to false. The helper does not preserve a previous global value. If onDestroy throws, it prevents the final flag reset.

testRender() does not return the React root or a separate rerender function. Use the returned Core setup for frame capture, waits, resize, keyboard and mouse input, native stats, and external output. Always destroy the renderer in test teardown. Destruction unmounts the React tree and resets the global act-environment flag.

Example: login form#

OpenTUI <input> has no password-masking mode. This demonstration displays the password value as normal terminal text. Do not use this pattern for real secrets.

import { createCliRenderer } from "@opentui/core"
import { createRoot, useKeyboard } from "@opentui/react"
import { useCallback, useState } from "react"

function App() {
  const [username, setUsername] = useState("")
  const [password, setPassword] = useState("")
  const [focused, setFocused] = useState<"username" | "password">("username")
  const [status, setStatus] = useState("idle")

  useKeyboard((key) => {
    if (key.name === "tab") {
      setFocused((prev) => (prev === "username" ? "password" : "username"))
    }
  })

  const handleSubmit = useCallback(() => {
    if (username === "admin" && password === "secret") {
      setStatus("success")
    } else {
      setStatus("error")
    }
  }, [username, password])

  return (
    <box style={{ border: true, padding: 2, flexDirection: "column", gap: 1 }}>
      <text fg="#FFFF00">Login Form</text>

      <box title="Username" style={{ border: true, width: 40, height: 3 }}>
        <input
          placeholder="Enter username..."
          onInput={setUsername}
          onSubmit={handleSubmit}
          focused={focused === "username"}
        />
      </box>

      <box title="Password" style={{ border: true, width: 40, height: 3 }}>
        <input
          placeholder="Enter password..."
          onInput={setPassword}
          onSubmit={handleSubmit}
          focused={focused === "password"}
        />
      </box>

      <text fg={status === "success" ? "green" : status === "error" ? "red" : "#999"}>{status.toUpperCase()}</text>
    </box>
  )
}

const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)

Component extension#

Register custom renderables as JSX elements:

import { BoxRenderable, createCliRenderer, type BoxOptions, type RenderContext } from "@opentui/core"
import { createRoot, extend } from "@opentui/react"

class ConsoleButtonRenderable extends BoxRenderable {
  private _label: string = "Button"

  constructor(ctx: RenderContext, options: BoxOptions & { label?: string }) {
    super(ctx, options)
    if (options.label) this._label = options.label
    this.borderStyle = "single"
    this.padding = 2
  }

  get label(): string {
    return this._label
  }

  set label(value: string) {
    this._label = value
    this.requestRender()
  }
}

// Add TypeScript support
declare module "@opentui/react" {
  interface OpenTUIComponents {
    consoleButton: typeof ConsoleButtonRenderable
  }
}

// Register the component
extend({ consoleButton: ConsoleButtonRenderable })

// Use in JSX
function App() {
  return <consoleButton label="Click me!" style={{ border: true, backgroundColor: "green" }} />
}

const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)

React DevTools#

Use React DevTools to inspect the React tree:

  1. Install:
bun add --dev react-devtools-core@7
  1. Start DevTools:
npx react-devtools@7
  1. Run with the DEV flag:
DEV=true bun run your-app.ts

Next#