Build and modify terminal user interfaces using OpenTUI with React or Core API. Use when implementing terminal UIs, TUIs, CLI applications, interactive terminal components, keyboard navigation, terminal styling, or working on OpenTUI-based applications.
This skill provides comprehensive guidance for building terminal user interfaces (TUIs) using OpenTUI, a TypeScript library for creating component-based terminal applications with React or Core API.
Use this skill when:
OpenTUI is a TypeScript framework for building terminal user interfaces with:
@opentui/core - Core library with imperative API and primitives (standalone)@opentui/react - React reconciler for declarative UI development@opentui/solid - SolidJS reconciler (alternative framework)bun create tui --template react
cd my-tui-app
bun install
bun run index.tsx
bun install @opentui/core @opentui/react react
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box style={{ padding: 2 }}>
<text fg="#00FF00">Hello, OpenTUI!</text>
</box>
)
}
const renderer = await createCliRenderer({ useAlternateScreen: true })
createRoot(renderer).render(<App />)
renderer.start()
OpenTUI requires specific TypeScript settings:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true
}
}
Key settings:
jsxImportSource: "@opentui/react" - Use OpenTUI's JSX factorymoduleResolution: "bundler" - Required for Bunstrict: true - Enable strict type checkingThe renderer manages terminal output, input events, and the rendering loop.
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
targetFps: 30, // Target frame rate
maxFps: 60, // Maximum frame rate
useAlternateScreen: true, // Use alternate screen (full-screen)
useMouse: true, // Enable mouse support
backgroundColor: "#000000", // Background color
useConsole: true, // Enable console overlay
})
// Start the render loop
renderer.start()
Rendering Modes:
renderer.start() for continuous rendering at target FPSstart(), renders only when changes occurOpenTUI uses Yoga for CSS Flexbox-like layouts:
<box
flexDirection="row" // row | column | row-reverse | column-reverse
justifyContent="center" // flex-start | flex-end | center | space-between | space-around
alignItems="center" // flex-start | flex-end | center | stretch | baseline
gap={2} // Gap between children
flexGrow={1} // Grow factor
flexShrink={1} // Shrink factor
flexBasis="auto" // Base size
flexWrap="wrap" // no-wrap | wrap | wrap-reverse
>
{/* Children */}
</box>
Size Properties:
width={40} // Fixed width in characters
height={10} // Fixed height in lines
width="50%" // Percentage of parent
width="auto" // Auto-size based on content
minWidth={20} // Minimum width
maxWidth={80} // Maximum width
Spacing:
padding={2} // All sides
paddingLeft={1} // Individual sides
margin={1} // Margin (all sides)
marginTop={2} // Individual margins
Position:
position="relative" // relative | absolute
top={5} // Position from top
left={10} // Position from left
zIndex={999} // Stack order
Colors in OpenTUI use RGBA format or hex strings:
import { RGBA } from "@opentui/core"
// Hex strings (most common)
<text fg="#FFFFFF" bg="#000000">Text</text>
// RGBA objects
const red = RGBA.fromHex("#FF0000")
const blue = RGBA.fromInts(0, 0, 255, 255) // RGB 0-255
const transparent = RGBA.fromValues(1.0, 1.0, 1.0, 0.5) // RGBA 0.0-1.0
Style text with bold, italic, underline, etc.:
import { TextAttributes } from "@opentui/core"
<text attributes={TextAttributes.BOLD}>Bold text</text>
<text attributes={TextAttributes.BOLD | TextAttributes.ITALIC}>Bold italic</text>
// Available attributes:
// BOLD, DIM, ITALIC, UNDERLINE, BLINK, INVERSE, HIDDEN, STRIKETHROUGH
Container with borders, backgrounds, and layout:
<box
title="Panel Title"
border // Show border
borderStyle="single" // single | double | rounded | bold | classic
borderColor="#FFFFFF"
backgroundColor="#1a1a1a"
focused={true} // Highlight when focused
padding={2}
flexDirection="column"
width={40}
height={10}
>
<text>Content</text>
</box>
Border Styles:
single - Single line (─│┌┐└┘)double - Double line (═║╔╗╚╝)rounded - Rounded corners (─│╭╮╰╯)bold - Bold/thick lineclassic - ASCII style (+-|)Display text with styling:
// Simple text
<text fg="#FFFFFF" bg="#000000">Simple text</text>
// Rich text with children
<text>
<span fg="#FF0000">Red text</span>
{" "}
<strong>Bold</strong>
{" "}
<em>Italic</em>
{" "}
<u>Underlined</u>
<br />
<a href="https://example.com">Link</a>
</text>
// Formatted content
<text content="Preformatted content" fg="#00FF00" />
Single-line text input:
const [value, setValue] = useState("")
<input
placeholder="Type here..."
value={value}
focused={true} // Must be focused to receive input
onInput={setValue} // Called on every keystroke
onSubmit={(val) => { // Called on Enter key
console.log("Submitted:", val)
}}
style={{
backgroundColor: "#1a1a1a",
focusedBackgroundColor: "#2a2a2a",
}}
/>
Multi-line text editor:
import { useRef } from "react"
import type { TextareaRenderable } from "@opentui/core"
const textareaRef = useRef<TextareaRenderable>(null)
<textarea
ref={textareaRef}
placeholder="Type here..."
focused={true}
initialValue="Initial text"
style={{ width: 60, height: 10 }}
/>
// Access value: textareaRef.current?.getText()
List selection with scrolling:
import type { SelectOption } from "@opentui/core"
const options: SelectOption[] = [
{ name: "Option 1", description: "First option", value: "opt1" },
{ name: "Option 2", description: "Second option", value: "opt2" },
{ name: "Option 3", description: "Third option", value: "opt3" },
]
<select
options={options}
focused={true}
onChange={(index, option) => {
console.log(`Selected ${index}:`, option)
}}
showScrollIndicator
style={{ height: 10 }}
/>
Navigation:
Horizontal tab selection:
<tab-select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
{ name: "Help", description: "Documentation" },
]}
focused={true}
onChange={(index, option) => {
console.log("Tab changed:", option.name)
}}
tabWidth={20}
/>
Scrollable container for long content:
<scrollbox
focused={true}
style={{
height: 20,
rootOptions: { backgroundColor: "#24283b" },
wrapperOptions: { backgroundColor: "#1f2335" },
viewportOptions: { backgroundColor: "#1a1b26" },
contentOptions: { backgroundColor: "#16161e" },
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{/* Long scrollable content */}
<text>Line 1</text>
<text>Line 2</text>
{/* ... many more lines */}
</scrollbox>
Display code with syntax highlighting:
import { SyntaxStyle, RGBA } from "@opentui/core"
const syntaxStyle = SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#ff6b6b"), bold: true },
string: { fg: RGBA.fromHex("#51cf66") },
comment: { fg: RGBA.fromHex("#868e96"), italic: true },
function: { fg: RGBA.fromHex("#4dabf7") },
number: { fg: RGBA.fromHex("#ffd43b") },
})
<code
content={codeString}
filetype="typescript" // javascript, python, rust, go, etc.
syntaxStyle={syntaxStyle}
/>
Render markdown documents (headings, lists, emphasis, tables, fenced code with Tree-sitter highlighting). This is separate from using filetype="markdown" on <code>, which only syntax-highlights markdown source as code.
Requires a SyntaxStyle with at least default and markup-oriented keys (for example markup.heading.1, markup.list, markup.raw) plus any code tokens you want inside fenced blocks.
import { SyntaxStyle, RGBA } from "@opentui/core"
const mdStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromHex("#E6EDF3") },
"markup.heading.1": { fg: RGBA.fromHex("#58A6FF"), bold: true },
"markup.list": { fg: RGBA.fromHex("#FF7B72") },
"markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
})
<markdown
content={"# Hello\n\n- one\n- two\n\n```ts\nconst x = 1\n```"}
syntaxStyle={mdStyle}
width={60}
conceal
streaming={false}
tableOptions={{
widthMode: "full",
borderStyle: "rounded",
cellPadding: 1,
selectable: true,
}}
/>
Streaming (e.g. LLM output): set streaming={true} while appending chunks, then streaming={false} when the message is complete so the parser finalizes the trailing block (including tables). Set an explicit width so wrapping matches the terminal panel.
Official reference: Markdown component.
Code with line numbers and diff highlights:
import { useRef, useEffect } from "react"
import type { LineNumberRenderable } from "@opentui/core"
const lineNumberRef = useRef<LineNumberRenderable>(null)
useEffect(() => {
// Highlight added line
lineNumberRef.current?.setLineColor(1, "#1a4d1a")
lineNumberRef.current?.setLineSign(1, { after: " +", afterColor: "#22c55e" })
// Highlight deleted line
lineNumberRef.current?.setLineColor(5, "#4d1a1a")
lineNumberRef.current?.setLineSign(5, { after: " -", afterColor: "#ef4444" })
// Add diagnostic warning
lineNumberRef.current?.setLineSign(10, { before: "⚠️", beforeColor: "#f59e0b" })
}, [])
<line-number
ref={lineNumberRef}
fg="#6b7280"
bg="#161b22"
minWidth={3}
showLineNumbers={true}
>
<code content={codeContent} filetype="typescript" />
</line-number>
Unified or split diff viewer:
<diff
oldContent={oldCode}
newContent={newCode}
filetype="typescript"
syntaxStyle={syntaxStyle}
viewMode="unified" // unified | split
/>
Display ASCII art text:
<ascii-font
text="OPENTUI"
font="block" // tiny | block | slick | shade
color={RGBA.fromHex("#00FF00")}
/>
Access the renderer instance:
import { useRenderer } from "@opentui/react"
const renderer = useRenderer()
// Show/hide console overlay
renderer.console.toggle()
renderer.console.show()
renderer.console.hide()
Handle keyboard events:
import { useKeyboard } from "@opentui/react"
import type { KeyEvent } from "@opentui/core"
useKeyboard((key: KeyEvent) => {
console.log("Key:", key.name)
console.log("Modifiers:", { ctrl: key.ctrl, shift: key.shift, alt: key.meta })
if (key.name === "escape") {
process.exit(0)
}
if (key.ctrl && key.name === "s") {
// Save action
}
}, { release: false }) // Set release: true for key release events
Key Event Properties:
name - Key name (e.g., "a", "enter", "escape", "up", "down")ctrl - Ctrl key pressedshift - Shift key pressedmeta - Alt/Meta key pressed (Linux/Windows)option - Option key pressed (macOS)sequence - Raw key sequenceGet current terminal size (auto-updates on resize):
import { useTerminalDimensions } from "@opentui/react"
const { width, height } = useTerminalDimensions()
return (
<box style={{ width, height }}>
<text>{`Terminal: ${width}x${height}`}</text>
</box>
)
Handle terminal resize events:
import { useOnResize } from "@opentui/react"
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
Create animations:
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
autoplay: true,
})
useEffect(() => {
timeline.add(
{ width },
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (animation) => {
setWidth(animation.targets[0].width)
},
}
)
}, [])
return <box style={{ width }}><text>Animating...</text></box>
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
type View = "home" | "settings" | "help"
function App() {
const [view, setView] = useState<View>("home")
useKeyboard((key) => {
if (key.name === "1") setView("home")
if (key.name === "2") setView("settings")
if (key.name === "3") setView("help")
if (key.name === "escape") process.exit(0)
})
return (
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}>
<Header view={view} />
{view === "home" && <HomeView />}
{view === "settings" && <SettingsView />}
{view === "help" && <HelpView />}
<Footer />
</box>
)
}
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function App() {
const [showModal, setShowModal] = useState(false)
useKeyboard((key) => {
if (showModal) return // Modal handles its own keys
if (key.name === "m") {
setShowModal(true)
}
})
return (
<>
<MainContent />
{showModal && (
<Modal onClose={() => setShowModal(false)} />
)}
</>
)
}
function Modal({ onClose }: { onClose: () => void }) {
const [input, setInput] = useState("")
useKeyboard((key) => {
if (key.name === "escape") onClose()
})
return (
<box
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 60,
height: 10,
backgroundColor: "#1a1a1a",
border: true,
borderColor: "#FFFFFF",
}}
>
<input
placeholder="Enter value..."
value={input}
focused={true}
onInput={setInput}
onSubmit={(val) => {
console.log("Submitted:", val)
onClose()
}}
/>
</box>
)
}
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
type FocusPanel = "left" | "right"
function App() {
const [focused, setFocused] = useState<FocusPanel>("left")
useKeyboard((key) => {
if (key.name === "tab") {
setFocused(focused === "left" ? "right" : "left")
}
})
return (
<box style={{ flexDirection: "row", width: "100%", height: "100%" }}>
<box
title="Left Panel"
style={{ flexGrow: 1, border: true }}
focused={focused === "left"}
>
<text>Content</text>
</box>
<box
title="Right Panel"
style={{ flexGrow: 1, border: true }}
focused={focused === "right"}
>
<text>Content</text>
</box>
</box>
)
}
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function FileList({ files }: { files: string[] }) {
const [selected, setSelected] = useState(0)
useKeyboard((key) => {
if (key.name === "up") {
setSelected(Math.max(0, selected - 1))
}
if (key.name === "down") {
setSelected(Math.min(files.length - 1, selected + 1))
}
if (key.name === "enter") {
console.log("Selected:", files[selected])
}
})
return (
<box style={{ flexDirection: "column" }}>
{files.map((file, idx) => (
<text
key={file}
fg={idx === selected ? "#00FF00" : "#FFFFFF"}
bg={idx === selected ? "#333333" : undefined}
>
{idx === selected ? "> " : " "}
{file}
</text>
))}
</box>
)
}
import { useTerminalDimensions } from "@opentui/react"
function App() {
const { width, height } = useTerminalDimensions()
// Show simplified layout on small terminals
const isSmall = width < 80
return (
<box style={{ flexDirection: isSmall ? "column" : "row", width, height }}>
<Sidebar width={isSmall ? width : 30} />
<MainContent width={isSmall ? width : width - 30} />
</box>
)
}
import { useState, useEffect } from "react"
function Spinner() {
const [frame, setFrame] = useState(0)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % frames.length)
}, 80)
return () => clearInterval(interval)
}, [])
return <text fg="#00FFFF">{frames[frame]} Loading...</text>
}
type keyword)import { useState, useEffect } from "react"
import { useKeyboard, useRenderer } from "@opentui/react"
import { RGBA, TextAttributes } from "@opentui/core"
import { formatTime } from "./utils"
import { Header } from "./components/Header"
import type { AppState, View } from "./types"
// Good
const userInput = "text"
function handleSubmit() {}
interface UserData {}
const MAX_RETRIES = 3
// Bad
const UserInput = "text"
function HandleSubmit() {}
interface userData {}
interface ComponentProps {
title: string
items: string[]
}
export function Component({ title, items }: ComponentProps) {
// 1. Hooks
const [selected, setSelected] = useState(0)
const renderer = useRenderer()
// 2. Event handlers
useKeyboard((key) => {
if (key.name === "escape") process.exit(0)
})
// 3. Effects
useEffect(() => {
// Side effects
}, [])
// 4. Helper functions
const handleSelect = (index: number) => {
setSelected(index)
}
// 5. JSX return
return (
<box>
<text>{title}</text>
{/* Content */}
</box>
)
}
useMemo and useCallback for expensive computationsfocused={true} at a timefocused prop on boxes to show focus state with bordersflexDirection, justifyContent, alignItems for layouts"50%" for responsive layoutsrenderer.console.show() instead of console.logOTUI_DEBUG=true for debug modeOTUI_SHOW_STATS=true to see performance statsconsole.log goes to overlay, use renderer.console.show() to viewfocused={true} to receive eventsuseAlternateScreen: false to disablerenderer.start() after createRoot().render()useAlternateScreen: falsefocused={true}useKeyboard hook is registeredwidth, height)flexGrow for flexible sizingflexDirection is set correctly"#RRGGBB"For detailed API references and advanced topics, see:
The OpenTUI repository contains many examples:
# Install dependencies
bun install
# Run application
bun run index.tsx
# Type check
bun --bun tsc --noEmit
# Build project (if applicable)
bun run build
After implementing a basic OpenTUI application: