@effect-tui/react
v2.0.1
Published
React bindings for @effect-tui/core
Maintainers
Readme
@effect-tui/react
React renderer for terminal UIs with spring animations.
Installation
bun add @effect-tui/react @effect-tui/core effect reactQuick Start
// tsconfig.json: { "compilerOptions": { "jsxImportSource": "@effect-tui/react" } }
import { render, useQuit, useShortcut } from "@effect-tui/react"
function App() {
const [count, setCount] = useState(0)
const quit = useQuit()
useShortcut({
q: () => quit(),
space: () => setCount((c) => c + 1),
})
return (
<vstack>
<text>Count: {count}</text>
<text fg="gray">Press space to increment, q to quit</text>
</vstack>
)
}
render(<App />)Dev / HMR
Enable hot reload by passing dev: true and importMeta:
export default function App() { ... }
render(<App />, { dev: true, importMeta: import.meta })Note: When dev: true, the entry module's default export is re-imported
on each change. Keep your app as the default export for reliable HMR.
JSX Elements
<vstack>- Vertical flex layout<hstack>- Horizontal flex layout<text>- Text with optional fg/bg colors<box>- Container with optional border<canvas>- Imperative drawing API<spacer>- Flexible space
Canvas draw context:
text(x, y, str, style?)fillRect(x, y, w, h, char?, style?)box(x, y, w, h, opts?)clear()style(style?)→ style id for reusecell(x, y, cp, style?, width?)cells(cells[])
Hooks
useKeyboard(callback)- Handle keyboard inputuseShortcut(shortcuts, options?)- Map key strings to handlersusePaste(callback)- Handle bracketed paste events (callback receives{ text, preventDefault? })useQuit()- Request a clean exit (restores terminal state)useTerminalSize()- Get terminal dimensions (re-renders on resize)useSpring(initial, options)- Spring animationuseSprings(count, fn)- Multiple springsuseColorSpring(color, options)- Animate colors
Components
TextInput- Single-line input (pasting inserts normalized text)MultilineTextInput- Multi-line input (pasting preserves newlines)
Spring Animations
import { useSpring, useSpringRenderer, useRenderer } from "@effect-tui/react"
function AnimatedBox() {
const renderer = useRenderer()
useSpringRenderer(renderer)
const [xMv, setX] = useSpring(0, { visualDuration: 0.35 })
useKeyboard((key) => {
if (key.name === "right") setX(20)
if (key.name === "left") setX(0)
})
return (
<canvas draw={(ctx) => {
ctx.box(xMv.get(), 0, 10, 5, { border: "rounded" })
}} />
)
}Architecture
Each frame (60fps default):
- Measure - Host tree calculates sizes bottom-up
- Layout - Host tree assigns positions top-down
- Render - Each host writes to CellBuffer
- Diff - Compare with previous buffer, find changed lines
- Write - Single
stdout.write()with ANSI sequences
React JSX → Reconciler → Host Tree → CellBuffer → TerminalTesting
Use renderTUI() for integration tests:
import { renderTUI } from "@effect-tui/react/test"
it("renders and handles input", () => {
const { lastFrame, sendKey, renderNow, unmount } = renderTUI(<Counter />)
expect(lastFrame()).toContain("Count: 0")
sendKey({ name: "up" })
renderNow()
expect(lastFrame()).toContain("Count: 1")
unmount()
})API
Renderer
createRenderer(options?)- Create renderer instancecreateRoot(renderer)- Create React rootrender(element, options?)- One-liner convenience API
Options
importMeta is required whenever you pass the options object.
type RenderOptions = RendererOptions & {
dev?: boolean
importMeta: ImportMetaLike
}
interface RendererOptions {
fps?: number // Default: 60
mode?: "fullscreen" | "inline"
exitOnCtrlC?: boolean // Default: true
handleSignals?: boolean // Default: true (signals + process exit cleanup)
manualMode?: boolean // For testing
}Environment Variables
PROFILE_TUI=1- Enable profiling (writes totui-profile.txt)EFFECT_TUI_EDITOR/EDITOR- Editor for trace visualization
License
MIT
