Ink.js Design
Comprehensive guide for building terminal UIs with Ink.js (React for CLI).
Quick Start
Creating a New Component
-
Determine component type: Screen / Part / Common
-
Reference component-patterns.md for similar patterns
-
Add type definitions
-
Implement component
-
Write tests
Common Issues & Solutions
Issue Reference
Emoji width misalignment ink-gotchas.md
Ctrl+C called twice ink-gotchas.md
useInput conflicts ink-gotchas.md
Layout breaking responsive-layout.md
Screen navigation multi-screen-navigation.md
Directory Conventions
src/cli/ui/ ├── components/ │ ├── App.tsx # Root component with screen management │ ├── common/ # Common input components (Select, Input) │ ├── parts/ # Reusable UI parts (Header, Footer) │ └── screens/ # Full-screen components ├── hooks/ # Custom hooks ├── utils/ # Utility functions └── types.ts # Type definitions
Component Classification
Screen (Full-page views)
-
Represents a complete screen/page
-
Handles keyboard input via useInput
-
Implements Header/Content/Footer layout
-
Manages screen-level state
Part (Reusable elements)
-
Reusable UI building blocks
-
Optimized with React.memo
-
Stateless/pure components preferred
-
Accept configuration via props
Common (Input components)
-
Basic input components
-
Support both controlled and uncontrolled modes
-
Handle focus management
-
Provide consistent UX
Essential Patterns
- Icon Width Override
Fix string-width v8 emoji width calculation issues:
const WIDTH_OVERRIDES: Record<string, number> = { "⚡": 1, "✨": 1, "🐛": 1, "🔥": 1, "🚀": 1, "🟢": 1, "🟠": 1, "✅": 1, "⚠️": 1, };
const getIconWidth = (icon: string): number => { const baseWidth = stringWidth(icon); const override = WIDTH_OVERRIDES[icon]; return override !== undefined ? Math.max(baseWidth, override) : baseWidth; };
- useInput Conflict Avoidance
Multiple useInput hooks all fire - use early return or isActive:
useInput((input, key) => { if (disabled) return; // Early return when inactive // Handle input... }, { isActive: isFocused });
- Ctrl+C Handling
render(<App />, { exitOnCtrlC: false });
// In component const { exit } = useApp(); useInput((input, key) => { if (key.ctrl && input === "c") { cleanup(); exit(); } });
- Dynamic Height Calculation
const { rows } = useTerminalSize(); const HEADER_LINES = 3; const FOOTER_LINES = 2; const contentHeight = rows - HEADER_LINES - FOOTER_LINES; const visibleItems = Math.max(5, contentHeight);
- React.memo with Custom Comparator
function arePropsEqual<T>(prev: Props<T>, next: Props<T>): boolean { if (prev.items.length !== next.items.length) return false; for (let i = 0; i < prev.items.length; i++) { if (prev.items[i].value !== next.items[i].value) return false; } return prev.selectedIndex === next.selectedIndex; }
export const Select = React.memo(SelectComponent, arePropsEqual);
- Multi-Screen Navigation
type ScreenType = "main" | "detail" | "settings";
const [screenStack, setScreenStack] = useState<ScreenType[]>(["main"]); const currentScreen = screenStack[screenStack.length - 1];
const navigateTo = (screen: ScreenType) => { setScreenStack(prev => [...prev, screen]); };
const goBack = () => { if (screenStack.length > 1) { setScreenStack(prev => prev.slice(0, -1)); } };
Detailed References
Core Patterns
-
Component Patterns - Screen/Part/Common architecture
-
Hooks Guide - Custom hook design patterns
Advanced Topics
-
Multi-Screen Navigation - Screen stack management
-
Animation Patterns - Spinners and progress bars
-
State Management - Complex state patterns
-
Responsive Layout - Terminal size handling
-
Performance Optimization - Optimization techniques
-
Input Handling - Keyboard input patterns
Troubleshooting
-
Ink Gotchas - Common issues and solutions
-
Testing Patterns - ink-testing-library usage
Examples
See examples/ for practical implementation examples.