These docs describe the main branch, including unreleased changes. Read the 0.8.0 docs.

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 creates one Core renderable for each element and updates the renderable tree on each commit.

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

A later root.render(node) updates the mounted tree in place and keeps component state, as in react-dom. It also retries a tree that failed with an uncaught error.

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 while the renderer stays active. Called outside React work, it runs React effect cleanup before it returns; called from an effect, it runs the cleanup right after the current commit. 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 calls the constructor with { id, ...props }. After construction, React applies each prop to the instance again. For an ordinary prop, React assigns the value to the instance property of the same name. React assigns each entry of the style prop as a separate property. On each update, React applies the changed props and sets removed props to null. Read Custom renderables to draw custom content and to compare React and Solid construction.

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

Changes0.8.0
0.8.0
react: useAnimationTick(fps, callback, options?) subscribes a component to the shared animation clock and commits state updates from the callback in the tick’s frame. An undefined rate pauses it. (#1803)