
Opentui
- 7 installs
- 11 repo stars
- Updated March 8, 2026
- ainergiz/xfeed
opentui is a Claude Code skill that teaches building terminal UIs with OpenTUI's React renderer using Yoga layout.
About
opentui is a Claude Code skill that teaches building terminal UIs with OpenTUI's React renderer. It covers the lowercase JSX intrinsics (box, text, scrollbox), keyboard handling with useKeyboard, the scrollbox API, flex layout via Yoga, and screen navigation. Developers use it when creating terminal-based UI screens and components in React.
- React renderer for terminal UIs using OpenTUI and Yoga layout
- Covers JSX intrinsics, useKeyboard, scrollbox, and focus patterns
- Screen navigation and scroll-position preservation patterns
Opentui by the numbers
- 7 all-time installs (skills.sh)
- Ranked #1,756 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
opentui capabilities & compatibility
- Capabilities
- terminal ui building · keyboard handling · layout
- Use cases
- frontend · ui design
What opentui says it does
OpenTUI is a React renderer for terminal UIs using Yoga layout (like React Native). **NOT React DOM or Ink.**
Always Check `focused` in Keyboard Handlers
npx skills add https://github.com/ainergiz/xfeed --skill opentuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 11 |
| Last updated | March 8, 2026 |
| Repository | ainergiz/xfeed ↗ |
What it does
Build terminal UI screens and components with OpenTUI's React renderer.
Who is it for?
Developers building terminal UI screens and components in React with OpenTUI.
Skip if: Web DOM UIs or Ink-based terminal apps, which use different APIs.
When should I use this skill?
When creating terminal UI screens, components, handling keyboard input, or managing scroll.
What you get
Working terminal UI screens with correct layout, keyboard handling, and scroll preservation.
- terminal UI screens
- OpenTUI components
By the numbers
- 6 JSX intrinsics (box, text, scrollbox, a, input, textarea)
- OpenTUI version 0.1.69
Files
OpenTUI/React Quick Reference
OpenTUI is a React renderer for terminal UIs using Yoga layout (like React Native). NOT React DOM or Ink.
Version Info
- Current: 0.1.69 (updated), Latest: 0.1.69
- Context repo:
.context/repos/opentui(runbun run sync-contextif missing)
Core Imports
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
import type { ScrollBoxRenderable, KeyEvent } from "@opentui/core";JSX Elements (Lowercase!)
// CORRECT - OpenTUI intrinsics
<box style={{ flexDirection: "column" }}>
<text fg="#ffffff">Hello</text>
<scrollbox ref={scrollRef} focused />
</box>
// WRONG - Not OpenTUI
<div>, <span>, <Box>, <Text>| Element | Purpose | Key Props |
|---|---|---|
<box> | Container/layout | style, id, onMouse |
<text> | Text content (strings only!) | fg, bg, selectable |
<scrollbox> | Scrollable container | ref, focused |
<a> | Hyperlink (OSC8) | href, fg |
<input> | Text input | focused, onInput, onSubmit |
<textarea> | Multi-line input | ref, focused, placeholder |
Critical Rules
1. Text Only Accepts Strings
// WRONG - Cannot nest elements in <text>
<text>Hello <text fg="red">world</text></text>
// CORRECT - Use row box for inline styling
<box style={{ flexDirection: "row" }}>
<text>Hello </text>
<text fg="red">world</text>
</box>2. Always Check focused in Keyboard Handlers
useKeyboard((key) => {
if (!focused) return; // MUST check first!
if (key.name === "j") moveDown();
});3. Save Scroll Position Synchronously
// WRONG - useEffect runs after render, scroll already reset
useEffect(() => { if (!focused) savedScroll.current = scrollRef.current?.scrollTop; }, [focused]);
// CORRECT - Save before state change
const handleSelect = () => {
savedScroll.current = scrollRef.current?.scrollTop; // Save first!
onSelect(item);
};Hyperlinks (New in 0.1.64+)
<text>
Visit <a href="https://example.com">example.com</a> for more
</text>Renders clickable links in terminals supporting OSC8 (iTerm2, Kitty, etc.).
Key Names
| Key | key.name | Key | key.name | |
|---|---|---|---|---|
| Enter | "return" | Arrows | "up", "down", "left", "right" | |
| Escape | "escape" | Letters | "a", "b", "j", "k" | |
| Tab | "tab" | Shift+Letter | "A", "B", "G" |
Scrollbox API
const scrollbox = scrollRef.current;
scrollbox.scrollTop // Current position
scrollbox.scrollHeight // Total content height
scrollbox.viewport.height // Visible area
scrollbox.scrollTo(pos) // Absolute scroll
scrollbox.scrollBy(delta) // Relative scroll
scrollbox.getChildren() // Find elements by IDCommon Layout Patterns
// Full-height with fixed header/footer
<box style={{ flexDirection: "column", height: "100%" }}>
<box style={{ flexShrink: 0 }}>{/* Header */}</box>
<scrollbox style={{ flexGrow: 1 }}>{/* Content */}</scrollbox>
<box style={{ flexShrink: 0 }}>{/* Footer */}</box>
</box>
// Prevent unwanted spacing (Yoga quirk)
<box style={{ justifyContent: "flex-start", marginBottom: 0, paddingBottom: 0 }}>React DevTools (Optional)
bun add --dev react-devtools-core@7
npx react-devtools@7 # Start standalone devtools
DEV=true bun run start # Run app with devtools enabledDetailed References
- COMPONENTS.md - JSX elements, styling, text nesting
- KEYBOARD.md - Keyboard handling, key names, focus patterns
- SCROLLBOX.md - Scrollbox API, scroll preservation, windowed lists
- LAYOUT.md - Flex layout, Yoga engine, spacing issues
- PATTERNS.md - Screen navigation, state preservation, library compatibility
xfeed Reference Files
src/app.tsx- Screen routing, navigation historysrc/components/PostList.tsx- Scrollbox with preservationsrc/components/PostCard.tsx- Component styling, mouse handlingsrc/modals/FolderPicker.tsx- Windowed list patternsrc/hooks/useListNavigation.ts- Vim-style navigation
OpenTUI Components Reference
JSX Intrinsic Elements
OpenTUI uses lowercase intrinsic elements, NOT React DOM or Ink components:
// CORRECT - OpenTUI intrinsics
<box style={{ flexDirection: "column" }}>
<text fg="#ffffff">Hello</text>
</box>
// WRONG - These are NOT OpenTUI
<div>, <span>, <Box>, <Text>Available Elements
Layout & Display
| Element | Purpose | Key Props |
|---|---|---|
<box> | Container/layout | style, id, onMouse, onMouseOver, onMouseOut |
<text> | Text content | fg, bg, content, selectable, selectionBg |
<scrollbox> | Scrollable container | ref, focused, style |
<ascii-font> | ASCII art text | text, font |
Input Components
| Element | Purpose | Key Props |
|---|---|---|
<input> | Text input field | focused, onInput, onSubmit, placeholder |
<textarea> | Multi-line text | ref, focused, placeholder, showCursor |
<select> | Selection dropdown | options, focused, onChange, showScrollIndicator |
<tab-select> | Tab-based selection | options, onChange |
Code & Diff Components
| Element | Purpose | Key Props |
|---|---|---|
<code> | Syntax highlighted code | content, filetype, syntaxStyle |
<line-number> | Code with line numbers | showLineNumbers, fg, bg |
<diff> | Unified/split diff viewer | content, mode |
Text Modifiers (inside <text> only)
| Element | Effect |
|---|---|
<span> | Inline styling container |
<strong>, <b> | Bold text |
<em>, <i> | Italic text |
<u> | Underlined text |
<a> | Hyperlink (OSC8) |
<br> | Line break |
Text Nesting Rules
CRITICAL: <text> only accepts string children or text modifiers. You CANNOT nest arbitrary elements inside <text>.
// WRONG - Cannot nest <text> inside <text>
<text>
Hello <text fg="red">world</text>
</text>
// Error: TextNodeRenderable only accepts strings, TextNodeRenderable instances, or StyledText instances
// WRONG - Cannot nest <box> inside <text>
<text>
Count: <box>{count}</box>
</text>
// CORRECT - Use text modifiers inside <text>
<text>
<strong>Bold</strong>, <em>Italic</em>, and <u>Underlined</u>
</text>
// CORRECT - Use <span> for inline colors inside <text>
<text>
<span fg="red">Red</span> and <span fg="blue">blue</span>
</text>
// CORRECT - Use row box for separate text elements
<box style={{ flexDirection: "row" }}>
<text>Hello </text>
<text fg="red">world</text>
</box>
// CORRECT - Multiple segments with different colors
<box style={{ flexDirection: "row" }}>
<text fg="#666666">Posted by </text>
<text fg="#1DA1F2">@username</text>
<text fg="#666666"> . 2h</text>
</box>Why this matters: OpenTUI's <text> element maps to TextNodeRenderable which only accepts primitive string content or specific inline modifiers. To achieve inline styled text (like colored usernames), wrap multiple <text> elements in a <box> with flexDirection: "row".
Hyperlinks (OSC8)
New in OpenTUI 0.1.64+. Renders clickable hyperlinks in terminals that support OSC8 (iTerm2, Kitty, WezTerm, etc.).
// Inside <text>
<text>
Visit <a href="https://example.com">example.com</a> for more info
</text>
// Styled link
<text>
<u>
<a href="https://example.com" fg="blue">Click here</a>
</u>
</text>
// Multiple links
<text>
Check out <a href="https://github.com">GitHub</a> and <a href="https://x.com">X</a>
</text>Styling
Style Prop
Components can be styled using props or the style prop:
// Direct props
<box backgroundColor="blue" padding={2}>
<text>Hello, world!</text>
</box>
// Style prop (preferred for complex styles)
<box style={{ backgroundColor: "blue", padding: 2 }}>
<text>Hello, world!</text>
</box>Common Style Properties
<box
style={{
// Layout
flexDirection: "column", // "row" | "column"
flexGrow: 1, // number
flexShrink: 0, // number (use for fixed headers/footers)
alignItems: "center", // "flex-start" | "center" | "flex-end" | "stretch"
justifyContent: "center", // "flex-start" | "center" | "flex-end" | "space-between"
gap: 1, // spacing between children
// Dimensions
height: "100%", // number | "100%" | "auto"
width: "100%",
minHeight: 3,
minWidth: 10,
// Spacing (in character units)
padding: 1,
paddingLeft: 1,
paddingRight: 1,
paddingTop: 1,
paddingBottom: 1,
margin: 1,
marginTop: 1,
marginBottom: 1,
marginLeft: 1,
marginRight: 1,
// Appearance
backgroundColor: "#1a1a2e",
border: true,
borderStyle: "single", // "single" | "double" | "round"
borderColor: "#444444",
opacity: 0.5, // 0-1 (new in 0.1.64)
overflow: "hidden", // for hiding content
}}
>Text Colors
<text fg="#1DA1F2">Blue text</text>
<text fg="#ffffff">White text</text>
<text fg="#666666">Gray text</text>
<text bg="#1a1a2e">With background</text>Selection Indicator Pattern
<box style={{ backgroundColor: isSelected ? "#1a1a2e" : undefined }}>
<text fg="#1DA1F2">{isSelected ? "> " : " "}</text>
<text>Content</text>
</box>Mouse Events
import type { MouseEvent } from "@opentui/core";
<box
onMouse={(event: MouseEvent) => {
if (event.button !== 0) return; // Left click only
if (event.type === "down") { /* Mouse pressed */ }
if (event.type === "up") { /* Mouse released */ }
if (event.type === "drag") { /* Mouse dragged */ }
}}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>Selectable Text
<text selectable selectionBg="#264F78">
This text can be selected with the mouse
</text>Console Mouse Selection (0.1.61+)
The console component supports mouse selection and scrolling:
const renderer = useRenderer();
useEffect(() => {
renderer.console.show();
// Optional: handle copy events
renderer.console.onCopy = (text) => {
// Copy to system clipboard
};
}, []);OpenTUI Keyboard Handling Reference
Basic Pattern
import { useKeyboard } from "@opentui/react";
function MyComponent({ focused = false }) {
useKeyboard((key) => {
// CRITICAL: Always check focused prop first
if (!focused) return;
switch (key.name) {
case "j":
case "down":
handleDown();
break;
case "k":
case "up":
handleUp();
break;
case "return":
handleEnter();
break;
case "escape":
handleBack();
break;
case "g":
handleTop(); // Lowercase g
break;
case "G":
handleBottom(); // Shift+G (uppercase)
break;
}
});
}Key Names Reference
| Physical Key | key.name |
|---|---|
| Enter | "return" |
| Escape | "escape" |
| Backspace | "backspace" |
| Tab | "tab" |
| Space | "space" |
| Arrow Up | "up" |
| Arrow Down | "down" |
| Arrow Left | "left" |
| Arrow Right | "right" |
| Home | "home" |
| End | "end" |
| Page Up | "pageup" |
| Page Down | "pagedown" |
| Delete | "delete" |
| Letters | "a", "b", "j", "k", etc. |
| Shift+Letter | "A", "B", "G", etc. (uppercase) |
| Numbers | "1", "2", "0", etc. |
KeyEvent Object
interface KeyEvent {
name: string; // Key identifier
ctrl: boolean; // Ctrl modifier held
shift: boolean; // Shift modifier held
alt: boolean; // Alt modifier held
meta: boolean; // Meta/Cmd modifier held
eventType: "press" | "release"; // Press or release event
repeated: boolean; // Key is being held (repeat)
}Modifier Keys
useKeyboard((key) => {
if (!focused) return;
// Ctrl+key combinations
if (key.ctrl && key.name === "c") {
handleCopy();
return;
}
// Alt+key combinations
if (key.alt && key.name === "left") {
handleWordLeft();
return;
}
// Meta/Cmd combinations (macOS)
if (key.meta && key.name === "s") {
handleSave();
return;
}
});Release Events
By default, useKeyboard only receives press events. To also receive release events:
import { useKeyboard } from "@opentui/react";
import { useState } from "react";
function App() {
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set());
useKeyboard(
(event) => {
setPressedKeys((keys) => {
const newKeys = new Set(keys);
if (event.eventType === "release") {
newKeys.delete(event.name);
} else {
newKeys.add(event.name);
}
return newKeys;
});
},
{ release: true } // Enable release events
);
return (
<box>
<text>Currently pressed: {Array.from(pressedKeys).join(", ") || "none"}</text>
</box>
);
}Focused Prop Pattern
CRITICAL: Pass a focused prop to control which component handles keyboard input:
// Parent component controls focus
function App() {
const [currentView, setCurrentView] = useState("timeline");
return (
<>
<TimelineScreen focused={currentView === "timeline"} />
<DetailScreen focused={currentView === "detail"} />
</>
);
}
// Child component checks focused before handling keys
function TimelineScreen({ focused }) {
useKeyboard((key) => {
if (!focused) return; // MUST check this first
// Handle keys...
});
}Common Pitfall: Forgetting to Check focused
// WRONG - will handle keys even when another screen is focused
useKeyboard((key) => {
if (key.name === "escape") handleBack();
});
// CORRECT
useKeyboard((key) => {
if (!focused) return; // Check first!
if (key.name === "escape") handleBack();
});Vim-Style Navigation Hook
xfeed includes a useListNavigation hook for vim-style navigation:
const { selectedIndex, setSelectedIndex } = useListNavigation({
itemCount: items.length,
enabled: focused, // Only handle keys when focused
onSelect: (index) => {
// Called when Enter is pressed
handleSelect(items[index]);
},
});Handles: j/k/arrows for movement, g/G for top/bottom, Enter for selection.
See src/hooks/useListNavigation.ts for implementation.
Super+Arrow Keys (Kitty Keyboard Mode)
New in OpenTUI 0.1.64+. In terminals supporting Kitty keyboard protocol:
Super+Left/Super+Right- Jump to line start/end in textarea- Enables more advanced key combinations
Global Keybindings Pattern
For app-wide keybindings that work regardless of focus:
function App() {
useKeyboard((key) => {
// Global keybindings - no focus check
if (key.name === "q") {
renderer.destroy(); // Quit app
return;
}
// Tab switching
if (key.name === "1") {
setCurrentView("timeline");
return;
}
if (key.name === "2") {
setCurrentView("bookmarks");
return;
}
});
// Screen-specific handlers check their own focus
return (
<>
<TimelineScreen focused={currentView === "timeline"} />
<BookmarksScreen focused={currentView === "bookmarks"} />
</>
);
}Key Conflict Resolution
When multiple components register keyboard handlers, they all receive events. Use the focused pattern to ensure only one component acts:
// Timeline has j/k for navigation
function TimelineScreen({ focused }) {
useKeyboard((key) => {
if (!focused) return;
if (key.name === "j") selectNext();
});
}
// Modal also has j/k for its own list
function Modal({ open }) {
useKeyboard((key) => {
if (!open) return;
if (key.name === "j") selectNextInModal();
});
}
// Parent manages focus state
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<TimelineScreen focused={!modalOpen} />
{modalOpen && <Modal open={modalOpen} />}
</>
);
}OpenTUI Layout Reference (Yoga Engine)
OpenTUI uses the Yoga layout engine (same as React Native). Understanding Yoga's behavior is critical for debugging spacing issues.
Default Flex Behavior
// DEFAULT VALUES (if not specified)
flexGrow: 0 // Children don't grow to fill space
flexShrink: 1 // Children CAN shrink (for auto-sized elements)
flexShrink: 0 // Auto-set when explicit width/height is provided
// alignItems default is "stretch" (cross-axis)
// justifyContent default is "flex-start" (main-axis)Key insight: If you set explicit width or height, OpenTUI automatically sets flexShrink: 0 to prevent unwanted shrinking.
Common Layout Patterns
Full-Height Screen with Header/Footer
<box style={{ flexDirection: "column", height: "100%" }}>
{/* Fixed header */}
<box style={{ flexShrink: 0, padding: 1 }}>
<text>Header</text>
</box>
{/* Scrollable content */}
<scrollbox style={{ flexGrow: 1, height: "100%" }}>
{/* Content */}
</scrollbox>
{/* Fixed footer */}
<box style={{ flexShrink: 0, padding: 1 }}>
<text>Footer</text>
</box>
</box>Row Layout (Inline Elements)
<box style={{ flexDirection: "row" }}>
<text>Label: </text>
<text fg="#1DA1F2">Value</text>
</box>Centered Content
<box style={{
alignItems: "center",
justifyContent: "center",
height: "100%",
}}>
<text>Centered</text>
</box>Space Between Items
<box style={{
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
}}>
<text>Left</text>
<text>Right</text>
</box>Preventing Unwanted Spacing
Extra space between flex children often comes from: 1. Implicit margins/padding (even if not explicitly set) 2. Flex distribution allocating space to children 3. Text elements with trailing whitespace
Solution: Explicit Zero Values
// When you need tight layout with NO gaps between children
<box
style={{
flexDirection: "column",
justifyContent: "flex-start", // Pack children at top
marginBottom: 0, // Explicit zero margin
paddingBottom: 0, // Explicit zero padding
}}
>
{/* Child content */}
</box>Real-World Example: Profile Header Spacing Bug
Problem: Extra blank line appearing between profile header content and separator line.
Investigation revealed:
- fullHeader box had no explicit bottom margin/padding
- separator had no explicit top margin/padding
- Yet a blank line appeared between them
Solution:
// Profile header - pack children at top with explicit zero spacing
const fullHeader = (
<box
style={{
flexShrink: 0,
flexDirection: "column",
justifyContent: "flex-start", // Critical!
marginBottom: 0,
paddingBottom: 0,
}}
>
{/* header content */}
</box>
);
// Separator - explicit zero margins
const separator = (
<box
style={{
paddingLeft: 1,
paddingRight: 1,
flexShrink: 0,
marginTop: 0,
marginBottom: 0,
paddingTop: 0,
paddingBottom: 0,
}}
>
<text fg="#444444">{"─".repeat(50)}</text>
</box>
);Debugging Spacing Issues
1. Check for implicit values - Yoga may apply defaults you don't expect 2. Add explicit zeros - marginTop: 0, marginBottom: 0, paddingTop: 0, paddingBottom: 0 3. Use justifyContent - "flex-start" packs children at start of main axis 4. Check text content - Use .trim() on user-provided text to remove trailing whitespace 5. Inspect parent containers - Spacing can come from parent flex distribution
Gap Property (Preferred for Intentional Spacing)
When you DO want spacing between children, use gap instead of margins:
<box
style={{
flexDirection: "column",
gap: 1, // 1 line gap between all children
}}
>
<text>Item 1</text>
<text>Item 2</text>
<text>Item 3</text>
</box>gap is cleaner than adding marginBottom to each child because:
- It doesn't apply to the last child
- It's a single property on the parent
- It's more explicit about intent
Flex Properties Reference
| Property | Values | Description |
|---|---|---|
flexDirection | "row", "column" | Main axis direction |
flexGrow | number | How much to grow relative to siblings |
flexShrink | number | How much to shrink relative to siblings |
flexBasis | number, "auto" | Initial size before grow/shrink |
alignItems | "flex-start", "center", "flex-end", "stretch" | Cross-axis alignment |
alignSelf | same as alignItems | Override parent's alignItems |
justifyContent | "flex-start", "center", "flex-end", "space-between", "space-around" | Main-axis distribution |
flexWrap | "nowrap", "wrap" | Whether to wrap children |
gap | number | Space between children |
Dimension Properties
| Property | Values | Description |
|---|---|---|
width | number, "100%", "auto" | Element width |
height | number, "100%", "auto" | Element height |
minWidth | number | Minimum width |
maxWidth | number | Maximum width |
minHeight | number | Minimum height |
maxHeight | number | Maximum height |
Spacing Properties
All spacing values are in character units (not pixels):
| Property | Description |
|---|---|
padding | All sides |
paddingTop, paddingBottom, paddingLeft, paddingRight | Individual sides |
paddingHorizontal | Left and right |
paddingVertical | Top and bottom |
margin | All sides |
marginTop, marginBottom, marginLeft, marginRight | Individual sides |
marginHorizontal | Left and right |
marginVertical | Top and bottom |
Opacity Property (0.1.64+)
<box style={{ opacity: 0.5 }}>
<text>50% opacity</text>
</box>Useful for dimming/fading effects, disabled states, or overlays.
Overflow
<box style={{ overflow: "hidden" }}>
{/* Content that exceeds bounds will be clipped */}
</box>Absolute Positioning (0.1.62+)
Absolute positioned elements now position relative to parent:
<box style={{ position: "relative", width: 50, height: 20 }}>
<box style={{
position: "absolute",
top: 0,
right: 0,
width: 10,
height: 3,
}}>
<text>Badge</text>
</box>
</box>Keyboard Shortcuts Footer Pattern
function Footer() {
return (
<box style={{ flexShrink: 0, paddingLeft: 1, flexDirection: "row" }}>
<text fg="#ffffff">j/k</text>
<text fg="#666666"> nav </text>
<text fg="#ffffff">Enter</text>
<text fg="#666666"> select </text>
<text fg="#ffffff">q</text>
<text fg="#666666"> quit</text>
</box>
);
}OpenTUI Patterns Reference
Advanced patterns learned building xfeed's terminal UI.
Collapsible Headers (Scroll-Based UI)
OpenTUI scrollbox does NOT have an onScroll event. Don't try to poll scrollTop with intervals. Instead, use an event-driven approach based on selection index.
Pattern: Selection-Based Header Collapse
// PostList exposes selection changes via callback
interface PostListProps {
posts: TweetData[];
focused?: boolean;
onPostSelect?: (post: TweetData) => void;
onSelectedIndexChange?: (index: number) => void; // NEW
}
// In PostList component
useEffect(() => {
onSelectedIndexChange?.(selectedIndex);
}, [selectedIndex, onSelectedIndexChange]);// Parent component tracks collapsed state
function ProfileScreen({ focused, onBack }) {
const [isCollapsed, setIsCollapsed] = useState(false);
const handleSelectedIndexChange = useCallback((index: number) => {
setIsCollapsed(index > 0); // Collapse when scrolled past first item
}, []);
return (
<box style={{ flexDirection: "column", height: "100%" }}>
{isCollapsed ? <CompactHeader /> : <FullHeader />}
<PostList
posts={tweets}
focused={focused}
onSelectedIndexChange={handleSelectedIndexChange}
/>
</box>
);
}Why this works: Selection changes only happen via keyboard navigation (j/k). This is event-driven and efficient - no polling needed.
Navigation History
For proper back navigation across multiple views, track where you came from:
Pattern: Previous View Tracking
function App() {
const [currentView, setCurrentView] = useState<View>("timeline");
const [selectedPost, setSelectedPost] = useState<TweetData | null>(null);
// Track where we came from when entering post-detail
const [postDetailPreviousView, setPostDetailPreviousView] = useState<
"timeline" | "profile"
>("timeline");
// Navigate to post-detail from timeline
const handlePostSelect = useCallback((post: TweetData) => {
setSelectedPost(post);
setPostDetailPreviousView("timeline");
setCurrentView("post-detail");
}, []);
// Navigate to post-detail from profile
const handlePostSelectFromProfile = useCallback((post: TweetData) => {
setSelectedPost(post);
setPostDetailPreviousView("profile"); // Remember we came from profile
setCurrentView("post-detail");
}, []);
// Return from post-detail to WHEREVER we came from
const handleBackFromDetail = useCallback(() => {
setCurrentView(postDetailPreviousView); // Go back to correct view
setSelectedPost(null);
}, [postDetailPreviousView]);
}Common mistake: Hardcoding setCurrentView("timeline") in back handlers. This breaks navigation when entering the same view from multiple sources.
Third-Party Library Compatibility
OpenTUI uses a custom React reconciler, NOT React DOM. This means:
1. HTML elements are not supported - Libraries that render <div>, <span>, <strong>, etc. will crash 2. window/document are undefined - Libraries assuming browser environment need configuration 3. Default components may use HTML - Libraries with fallback UIs (error boundaries, loading states) need OpenTUI-compatible replacements
TanStack Router Example
TanStack Router works with OpenTUI but requires:
import { createMemoryHistory, createRouter } from "@tanstack/react-router";
// OpenTUI-compatible default components (replace HTML defaults)
function DefaultPendingComponent() {
return <box style={{ padding: 1 }}><text>Loading...</text></box>;
}
function DefaultErrorComponent({ error }: { error: unknown }) {
return <box style={{ padding: 1 }}><text fg="#ff0000">Error: {String(error)}</text></box>;
}
function DefaultNotFoundComponent() {
return <box style={{ padding: 1 }}><text>Not found</text></box>;
}
// Memory history (no browser)
const memoryHistory = createMemoryHistory({ initialEntries: ["/"] });
// Router with TUI-specific config
const router = createRouter({
routeTree,
history: memoryHistory,
isServer: false, // We're not on a server
origin: "http://localhost", // Prevents window.origin access
defaultPendingComponent: DefaultPendingComponent,
defaultErrorComponent: DefaultErrorComponent,
defaultNotFoundComponent: DefaultNotFoundComponent,
});TanStack Query
TanStack Query works out-of-the-box since it's headless (no UI components).
General Compatibility Checklist
Before using a React library with OpenTUI:
- [ ] Does it render any HTML elements? -> Need OpenTUI replacements
- [ ] Does it access
windowordocument? -> May need configuration - [ ] Does it have default UI components? -> Provide OpenTUI-compatible alternatives
- [ ] Does it use CSS? -> Won't work (use
styleprop instead)
Modal Pattern
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<box style={{ flexGrow: 1 }}>
{/* Main content - disabled when modal open */}
<MainScreen focused={!modalOpen} />
{/* Modal overlay */}
{modalOpen && (
<box style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.5)",
}}>
<box style={{
border: true,
padding: 2,
backgroundColor: "#1a1a2e",
minWidth: 40,
}}>
<Modal focused={true} onClose={() => setModalOpen(false)} />
</box>
</box>
)}
</box>
);
}Loading States Pattern
function TimelineScreen({ focused }) {
const { isLoading, error, data: tweets } = useTimeline();
if (isLoading) {
return (
<box style={{ padding: 2 }}>
<text fg="#666666">Loading...</text>
</box>
);
}
if (error) {
return (
<box style={{ padding: 2 }}>
<text fg="#ff6b6b">Error: {error.message}</text>
</box>
);
}
return <PostList posts={tweets} focused={focused} />;
}Toast/Notification Pattern
function Toast({ message, type, visible }) {
if (!visible) return null;
const colors = {
success: "#22c55e",
error: "#ef4444",
info: "#3b82f6",
};
return (
<box style={{
position: "absolute",
bottom: 2,
left: "50%",
backgroundColor: colors[type],
padding: 1,
paddingLeft: 2,
paddingRight: 2,
}}>
<text fg="#ffffff">{message}</text>
</box>
);
}Action Feedback Pattern (Visual Pulse)
function PostCard({ isLiked, isJustLiked, onLikeClick }) {
return (
<text
fg={
isJustLiked
? "#22c55e" // Bright green flash
: isLiked
? "#ef4444" // Red when liked
: "#666666" // Muted when not liked
}
onMouse={onLikeClick}
>
{isLiked ? "\u2665" : "\u2661"}
</text>
);
}
// Parent manages the flash timing
function PostList() {
const [justLiked, setJustLiked] = useState<Set<string>>(new Set());
const handleLike = (postId: string) => {
setJustLiked(prev => new Set(prev).add(postId));
// Clear flash after 200ms
setTimeout(() => {
setJustLiked(prev => {
const next = new Set(prev);
next.delete(postId);
return next;
});
}, 200);
};
}Tab Switching Pattern
function TimelineScreen({ focused }) {
const [tab, setTab] = useState<"for-you" | "following">("for-you");
useKeyboard((key) => {
if (!focused) return;
if (key.name === "1") setTab("for-you");
if (key.name === "2") setTab("following");
});
return (
<box style={{ flexDirection: "column", height: "100%" }}>
{/* Tab bar */}
<box style={{ flexDirection: "row", flexShrink: 0 }}>
<text fg={tab === "for-you" ? "#1DA1F2" : "#666666"}>[1] For You</text>
<text> </text>
<text fg={tab === "following" ? "#1DA1F2" : "#666666"}>[2] Following</text>
</box>
{/* Tab content */}
{tab === "for-you" && <ForYouTimeline focused={focused} />}
{tab === "following" && <FollowingTimeline focused={focused} />}
</box>
);
}Error Boundary Pattern
OpenTUI doesn't support React's class-based error boundaries with HTML rendering. Use a functional approach:
function SafeComponent({ children, fallback }) {
const [error, setError] = useState<Error | null>(null);
if (error) {
return (
<box style={{ padding: 1 }}>
<text fg="#ff6b6b">Error: {error.message}</text>
{fallback}
</box>
);
}
try {
return children;
} catch (e) {
setError(e as Error);
return null;
}
}Hooks
useRenderer
const renderer = useRenderer();
// Exit the application
renderer.destroy();
// Show console
renderer.console.show();useTerminalDimensions
const { width, height } = useTerminalDimensions();
return (
<box style={{ width: Math.floor(width / 2) }}>
<text>Half-width content</text>
</box>
);useOnResize
useOnResize((width, height) => {
console.log(`Terminal resized to ${width}x${height}`);
});useTimeline (Animation)
const timeline = useTimeline({
duration: 2000,
loop: false,
});
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "linear",
onUpdate: (animation) => {
setWidth(animation.targets[0].width);
},
}
);
}, []);xfeed Reference Implementation Files
src/app.tsx- Screen routing, focus management, navigation historysrc/components/PostList.tsx- Scrollbox with scroll preservation, onSelectedIndexChangesrc/components/PostCard.tsx- Basic component styling, mouse handlingsrc/modals/FolderPicker.tsx- Windowed list pattern for modalssrc/screens/PostDetailScreen.tsx- Expand/collapse, keyboard shortcutssrc/screens/ProfileScreen.tsx- Collapsible header patternsrc/screens/TimelineScreen.tsx- Loading states, tab switchingsrc/hooks/useListNavigation.ts- Vim-style navigation hooksrc/experiments/router-spike.tsx- TanStack Router integration example
OpenTUI Scrollbox Reference
Basic Usage
import type { ScrollBoxRenderable } from "@opentui/core";
import { useRef } from "react";
function ScrollableList() {
const scrollRef = useRef<ScrollBoxRenderable>(null);
return (
<scrollbox
ref={scrollRef}
focused={true}
style={{
flexGrow: 1,
height: "100%",
}}
>
{items.map((item) => (
<ItemCard key={item.id} id={`item-${item.id}`} />
))}
</scrollbox>
);
}ScrollBoxRenderable API
const scrollbox = scrollRef.current;
// Properties
scrollbox.scrollTop // Current vertical scroll position
scrollbox.scrollHeight // Total content height
scrollbox.viewport.height // Visible area height
scrollbox.x // X position in parent
scrollbox.y // Y position in parent
// Methods
scrollbox.scrollTo(position) // Scroll to absolute position
scrollbox.scrollBy(delta) // Scroll by relative amount
scrollbox.getChildren() // Get child renderables (for finding elements)Finding Elements by ID
// Give elements IDs for scroll targeting
<ItemCard id={`item-${item.id}`} />
// Find element and scroll to it
const target = scrollbox.getChildren().find((child) => child.id === targetId);
if (target) {
const relativeY = target.y - scrollbox.y;
// Use relativeY for scroll calculations
}Scroll Margin Pattern (vim-style scrolloff)
Keep selected items visible with context around them:
useEffect(() => {
const scrollbox = scrollRef.current;
if (!scrollbox) return;
const target = scrollbox.getChildren().find((c) => c.id === selectedId);
if (!target) return;
const relativeY = target.y - scrollbox.y;
const viewportHeight = scrollbox.viewport.height;
// Asymmetric margins - bias selection toward top
const topMargin = Math.max(1, Math.floor(viewportHeight / 10)); // ~10%
const bottomMargin = Math.max(4, Math.floor(viewportHeight / 3)); // ~33%
// First item: scroll to top
if (selectedIndex === 0) {
scrollbox.scrollTo(0);
return;
}
// Last item: scroll to bottom
if (selectedIndex === items.length - 1) {
scrollbox.scrollTo(scrollbox.scrollHeight);
return;
}
// Scroll to keep selection visible with margins
if (relativeY + target.height > viewportHeight - bottomMargin) {
scrollbox.scrollBy(relativeY + target.height - viewportHeight + bottomMargin);
} else if (relativeY < topMargin) {
scrollbox.scrollBy(relativeY - topMargin);
}
}, [selectedIndex]);Scroll Position Preservation
The Problem
When switching screens, you might try to hide components with height: 0. But this causes scroll position loss because:
1. React re-renders, setting parent height to 0 2. Scrollbox viewport shrinks to 0 3. Scroll position gets clamped to 0 4. useEffect runs AFTER render, too late to save position
Solution: Save Synchronously Before State Change
function PostList({ focused, onPostSelect }) {
const scrollRef = useRef<ScrollBoxRenderable>(null);
const savedScrollTop = useRef(0);
const wasFocused = useRef(focused);
// Restore scroll position when GAINING focus
useEffect(() => {
if (!wasFocused.current && focused && savedScrollTop.current > 0) {
scrollRef.current?.scrollTo(savedScrollTop.current);
}
wasFocused.current = focused;
}, [focused]);
const { selectedIndex } = useListNavigation({
onSelect: (index) => {
// CRITICAL: Save scroll position SYNCHRONOUSLY
// before the callback triggers any state change
if (scrollRef.current) {
savedScrollTop.current = scrollRef.current.scrollTop;
}
onPostSelect?.(items[index]);
},
});
}Common Pitfall
// WRONG - useEffect runs AFTER render, scroll already reset
useEffect(() => {
if (!focused) {
savedScrollTop.current = scrollRef.current?.scrollTop; // Already 0!
}
}, [focused]);
// CORRECT - save synchronously before state change
onSelect: () => {
savedScrollTop.current = scrollRef.current?.scrollTop; // Still valid
triggerStateChange();
}Screen Management Pattern
Keep screens mounted but hidden to preserve state:
function App() {
const [currentView, setCurrentView] = useState("timeline");
return (
<box style={{ flexGrow: 1 }}>
{/* Keep mounted, hide with height: 0 */}
<box
style={{
flexGrow: currentView === "timeline" ? 1 : 0,
height: currentView === "timeline" ? "100%" : 0,
overflow: "hidden",
}}
>
<TimelineScreen focused={currentView === "timeline"} />
</box>
{/* Conditionally render overlay screens */}
{currentView === "detail" && (
<DetailScreen focused={true} onBack={() => setCurrentView("timeline")} />
)}
</box>
);
}Windowed Lists for Modals
The Problem
Scrollbox works well for full-screen lists (like PostList, NotificationList) where it fills the available space with height: "100%" and flexGrow: 1. However, constraining scrollbox height in modals is problematic:
- Setting numeric
heighton scrollbox doesn't constrain the viewport correctly - Wrapping scrollbox in a fixed-height container causes layout issues
- The scrollbar position and content rendering become misaligned
Solution: Windowed List Pattern
For modals with bounded lists (like folder pickers), use a windowed list instead of scrollbox:
const MAX_VISIBLE_ITEMS = 10;
function PickerModal({ items, onSelect }) {
const [windowStart, setWindowStart] = useState(0);
const { selectedIndex } = useListNavigation({
itemCount: items.length,
onSelect: (index) => onSelect(items[index]),
});
// Keep selected item within the visible window
useEffect(() => {
const windowEnd = windowStart + MAX_VISIBLE_ITEMS - 1;
// If selection is below the window, shift window down
if (selectedIndex > windowEnd) {
setWindowStart(selectedIndex - MAX_VISIBLE_ITEMS + 1);
}
// If selection is above the window, shift window up
else if (selectedIndex < windowStart) {
setWindowStart(selectedIndex);
}
}, [selectedIndex, windowStart]);
// Calculate visible items
const hasMoreAbove = windowStart > 0;
const hasMoreBelow = windowStart + MAX_VISIBLE_ITEMS < items.length;
const visibleItems = items.slice(windowStart, windowStart + MAX_VISIBLE_ITEMS);
return (
<box style={{ /* modal styles */ }}>
{hasMoreAbove && <text fg="#666666"> ^ more</text>}
{visibleItems.map((item, visibleIndex) => {
const actualIndex = windowStart + visibleIndex;
const isSelected = actualIndex === selectedIndex;
return (
<box key={item.id}>
<text fg={isSelected ? "#1DA1F2" : "#888888"}>
{isSelected ? "> " : " "}{item.name}
</text>
</box>
);
})}
{hasMoreBelow && <text fg="#666666"> v more</text>}
</box>
);
}When to Use Each Pattern
| Pattern | Use Case |
|---|---|
| Scrollbox | Full-screen lists that fill available space (timeline, notifications) |
| Windowed List | Modals/dialogs with bounded height (folder picker, search results) |
Key Benefits of Windowed Lists
1. Predictable sizing - No complex scrollbox height constraints 2. Simple implementation - Just slice the array and track window position 3. Clear indicators - "^ more" / "v more" show users there's more content 4. Works in any layout - No dependency on parent container sizing
Scrollbox Styling Options
<scrollbox
style={{
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
focused
>No onScroll Event
OpenTUI scrollbox does NOT have an onScroll event. Don't try to poll scrollTop with intervals. Instead, use an event-driven approach based on selection index changes.
See PATTERNS.md for the Collapsible Headers pattern.
Related skills
FAQ
Is OpenTUI the same as Ink?
No, OpenTUI is a distinct React renderer for terminals using Yoga layout, not React DOM or Ink.
Why check focused in keyboard handlers?
Keyboard handlers must return early when the component is not focused so input goes to the right view.