skills/akiojin/skills/inkjs-design

inkjs-design

SKILL.md

Ink.js Design

Comprehensive guide for building terminal UIs with Ink.js (React for CLI).

Quick Start

Creating a New Component

  1. Determine component type: Screen / Part / Common
  2. Reference component-patterns.md for similar patterns
  3. Add type definitions
  4. Implement component
  5. 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

1. 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;
};

2. 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 });

3. Ctrl+C Handling

render(<App />, { exitOnCtrlC: false });

// In component
const { exit } = useApp();
useInput((input, key) => {
  if (key.ctrl && input === "c") {
    cleanup();
    exit();
  }
});

4. 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);

5. 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);

6. 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

Advanced Topics

Troubleshooting

Examples

See examples/ for practical implementation examples.

Weekly Installs
17
Repository
akiojin/skills
GitHub Stars
5
First Seen
Feb 21, 2026
Installed on
opencode17
github-copilot17
codex17
kimi-cli17
gemini-cli17
amp17