Ink lets you build terminal UIs with React, which gives you an unmatched ecosystem and dead-simple DX. But Ink's core deliberately ships only the most basic hooks and rendering — a purity I respect, until I need keyboard conflict resolution, page routing, and mouse support.
I went looking for solutions in the Ink ecosystem: some libraries are abandoned, some fix only one corner of the problem (keyboard but no routing), and some want to replace Ink outright — but the migration cost is just too high.
So I built ink-cartridge. And yes — I think the name is pretty good, if I do say so myself.
The Problem
A useful library has to solve a real problem, so let's look at what that problem is.
Ink gives you the useInput hook. It lets you listen to keyboard events and run side effects — switch pages, toggle the inventory, quit the app. Like this:
useInput((input, key) => {
// Screen navigation
if (key.return && currentScreen === 'menu') {
startGame();
}
if (key.escape && currentScreen === 'game') {
pauseGame();
}
if (key.escape && currentScreen === 'pause') {
resumeGame();
}
// Inventory toggling
if (key.escape && currentScreen === 'game' && !isPaused) {
toggleInventory();
}
if (key.escape && currentScreen === 'game' && inventoryOpen) {
closeInventory();
}
// Dialog handling
if (key.return && dialogOpen) {
confirmDialog();
}
if (key.escape && dialogOpen) {
cancelDialog();
}
// Debug / Quit
if (key.ctrl && input === 'q') {
process.exit(0);
}
if (key.ctrl && input === 'd') {
toggleDebugMode();
}
// Global shortcuts (but often shadowed above)
if (input === 'h' && !dialogOpen && !inventoryOpen) {
showHelp();
}
if (input === 'm' && currentScreen === 'game') {
toggleMinimap();
}
// ... and the list goes on
// Real projects scatter this across components behind isActive flags,
// which fixes the mess in a different way but keeps the pain.
});
You manage all the global state yourself. You manually reason about which useInput wins. For a small app, this is the sensible choice — but as the app grows, you start needing modals, popups, and multiple components fighting over focus on the same page. At that point, plain useInput and if-else page switching stop being viable, unless you want your codebase to become spaghetti.
And then there's the mouse. I should say upfront: parsing ANSI sequences is genuinely painful, but libraries that do it already exist — the problem is that Ink itself offers no mouse API at all. Ink is keyboard-driven through and through, yet every modern terminal has supported the mouse for ages. Why not let Ink use it? Better yet, why not let the mouse drive keyboard focus? Click an input field and the keyboard focus follows automatically — no Tab (or whatever your binding is) required.
ink-cartridge is built for exactly this class of problems.
What It Is
OK, let do this one last time!
ink-cartridge is not a standalone framework. It sits on top of Ink, like a cartridge slotting into a console. You keep Ink's rendering and the whole React ecosystem; you just plug ink-cartridge into your existing app and get a complete solution.
A layered keyboard engine. Register key bindings on layers (screen layer, overlay, modal layer), and the engine resolves priority and conflicts for you. A key event starts at the highest-priority layer and travels down until some layer consumes it. No more hand-rolled
isActiveflags, no more "two components fighting over the same key."Component-tree screen navigation. Register your screen components, then navigate with
skip()andback()— the framework owns the routing state. No if-else page switching, no threading the current page through a dozen components.Built-in mouse support (powered by xterm-mouse). Any
Boxbecomes a clickable, draggable, scrollable region viauseMouseRegion. Go one step further: clicking a region transfers keyboard focus to that component — the mouse and the keyboard stop being rival input methods and start cooperating.
How to Use It
First, you'll need Node.js, Ink (v7), and React (v19). Then, inside your Ink project:
npm install ink-cartridge # requires >= 5.2.0 for the features in this article
Screens
Organize your pages like this:
import React, { useEffect } from 'react';
import { render, Box, Text } from 'ink';
import {
registerComponent,
ScenarioManagementProvider,
CurrentScreen,
KeyboardProvider,
useScreenSystem,
useKeyboard,
} from 'ink-cartridge';
// Register screens
function HomeScreen() {
const { skip } = useScreenSystem();
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const unbind = boundKeyboard(['s'], () => skip(SettingsScreen, {}));
return unbind;
}, [boundKeyboard]);
return (
<Box flexDirection="column">
<Text bold>🏠 Home</Text>
<Text>Press [s] to go to Settings</Text>
<Text>Press [q] to quit</Text>
</Box>
);
}
function SettingsScreen() {
const { back } = useScreenSystem();
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const unbind = boundKeyboard(['escape'], () => back());
return unbind;
}, [boundKeyboard]);
return (
<Box flexDirection="column">
<Text bold>⚙️ Settings</Text>
<Text>Press [Esc] to go back</Text>
</Box>
);
}
registerComponent(HomeScreen, {});
registerComponent(SettingsScreen, {}, { parent: HomeScreen });
// App entry
function App() {
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const unbind = boundKeyboard(['q'], () => process.exit(0));
return unbind;
}, [boundKeyboard]);
return <CurrentScreen />;
}
registerComponent(App, {});
render(
<ScenarioManagementProvider defaultScreen={HomeScreen}>
<KeyboardProvider>
<App />
</KeyboardProvider>
</ScenarioManagementProvider>
);
back returns to the parent page — and only the parent page. It takes an optional number of levels to jump; going too deep throws. skip navigates the other way: the target must be a child of the current component, or it throws.
For cross-branch jumps there's gotoScreen. Say Game is a child of Menu, and so is Settings — skip won't work between siblings, but gotoScreen will.
Keyboard
boundKeyboard is ink-cartridge's core API, and the replacement for Ink's native useInput. useInput is global: every key handler piles up in one place, gated by if-checks over the current page and state. boundKeyboard, on the other hand, registers its binding on the current component — it fully figures out its own ownership — and returns an unbind function you can return from useEffect for cleanup.
And that's only the beginning — the keyboard system has plenty more firepower (focus groups, sequences, composition chains, named conditions… keep reading).
Mouse
Here's ink-cartridge's mouse dragging in action:
import React, { useRef, useState } from 'react';
import { render, Box, Text } from 'ink';
import {
registerComponent,
ScenarioManagementProvider,
CurrentScreen,
KeyboardProvider,
useMouseRegion,
} from 'ink-cartridge';
function DraggableWindow() {
const [pos, setPos] = useState({ top: 3, left: 3 });
const [dragging, setDragging] = useState(false);
const offsetRef = useRef({ dx: 0, dy: 0 });
const ref = useMouseRegion({
onDragStart: (event, rect) => {
// Grab offset: where inside the window the cursor was when the drag began.
offsetRef.current = { dx: event.x - rect.x, dy: event.y - rect.y };
setDragging(true);
},
onDragMove: (event) => {
// Keep the grab offset fixed: window top/left (0-based) = cursor (1-based) - offset - 1.
setPos({
top: event.y - offsetRef.current.dy - 1,
left: event.x - offsetRef.current.dx - 1,
});
},
onDragEnd: () => setDragging(false),
});
return (
<Box position="absolute" top={pos.top} left={pos.left} width={28} height={7}
borderStyle="round" borderColor={dragging ? 'green' : 'white'} ref={ref}>
<Text bold>Draggable Window</Text>
<Text dimColor>{dragging ? 'dragging... release to drop' : 'press and drag the window'}</Text>
</Box>
);
}
function DragScreen() {
return <CurrentScreen />;
}
registerComponent(DragScreen, {});
render(
<ScenarioManagementProvider defaultScreen={DragScreen} fullScreen>
<KeyboardProvider mouse>
<CurrentScreen />
</KeyboardProvider>
</ScenarioManagementProvider>,
);
The full runnable version (click counting, reset key, window-position readout) is in the repo: MouseDrag.demo.tsx.
Register callbacks with useMouseRegion, attach the returned ref to a Box, and that Box becomes clickable, draggable, and scrollable. That's the whole trick.
Mouse ⇄ Keyboard Focus
There's also a synergy layer between mouse and keyboard. Hovering a region activates the boundKeyboard binding scoped to it; leaving deactivates it again. Notice the second box is different — it sets leaveOffFocus: false, so the focus survives the cursor leaving. And the last box only takes focus when you actually click it. Together these make TUI interaction feel absurdly smooth:
import React, { useEffect, useState } from 'react';
import { render, Box, Text } from 'ink';
import {
registerComponent,
ScenarioManagementProvider,
CurrentScreen,
KeyboardProvider,
useKeyboard,
useFocusState,
useMouseRegion,
} from 'ink-cartridge';
function HoverPanel({ label, mode, focusId, enterOnFocus, leaveOffFocus, clickOnFocus }: {
label: string;
mode: string;
focusId: string;
enterOnFocus?: boolean;
leaveOffFocus?: boolean;
clickOnFocus?: boolean;
}) {
const { boundKeyboard } = useKeyboard();
const focused = useFocusState(focusId);
const [hovered, setHovered] = useState(false);
const [keys, setKeys] = useState(0);
const ref = useMouseRegion(
{ onEnter: () => setHovered(true), onLeave: () => setHovered(false) },
{ enterOnFocus, leaveOffFocus, clickOnFocus },
);
useEffect(() => {
return boundKeyboard(['s'], () => setKeys((k) => k + 1), { ref, focusId });
}, [boundKeyboard, ref, focusId]);
return (
<Box ref={ref} borderStyle="round"
borderColor={focused ? 'green' : hovered ? 'yellow' : undefined}
paddingX={2} paddingY={1}>
<Text>{focused ? <Text color="green">●</Text> : '○'} {label}</Text>
<Text dimColor>{mode} · s → {keys}</Text>
</Box>
);
}
function HoverFocusScreen() {
return (
<Box flexDirection="column" padding={1}>
<Text bold underline>Hover Focus Demo — the mouse drives keyboard focus</Text>
<Box marginTop={1} flexDirection="row" gap={2}>
<HoverPanel label="Alpha" mode="hover (leave clears)" focusId="panel-alpha" enterOnFocus />
<HoverPanel label="Beta" mode="hover (leave keeps)" focusId="panel-beta" enterOnFocus leaveOffFocus={false} />
<HoverPanel label="Gamma" mode="click only" focusId="panel-gamma" clickOnFocus />
</Box>
</Box>
);
}
registerComponent(HoverFocusScreen, {});
render(
<ScenarioManagementProvider defaultScreen={HoverFocusScreen} fullScreen>
<KeyboardProvider mouse>
<CurrentScreen />
</KeyboardProvider>
</ScenarioManagementProvider>,
);
The full runnable version is in the repo: MouseHoverFocus.demo.tsx.
Modals
And then there are modals. Use Ink's absolute positioning for the popup, fill the background with a color, and — importantly — run fullScreen, or Ink's render mechanics may refuse to show the popup at all. The demo below stacks eight modals: hammering Esc closes only the topmost one, never accidentally triggering the modals underneath. It also shows off page navigation: on the progress-bar page, pressing Home's keys doesn't fire Home's callbacks. boundKeyboard knows exactly where it belongs:
import React, { useContext, useEffect, useState } from "react";
import { Box, Text, render } from "ink";
import {
CurrentScreen,
KeyboardProvider,
ModalLayerElementContext,
registerComponent,
ScenarioManagementProvider,
useKeyboard,
useMouseRegion,
useScreenSystem,
} from "ink-cartridge";
// ── Home ──
function Home() {
const { skip, openModalLayer, applyElementToModalLayer } = useScreenSystem();
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const toProgress = boundKeyboard(["p"], () => skip(ProgressBar, {}));
const modals = boundKeyboard(["m"], () => {
const stacked = [
["low", 1, 0, 80],
["high", 2, 4, 70],
["high-high", 3, 8, 60],
["high-high-high", 4, 10, 55],
["high-high-high-high", 5, 12, 50],
["high-high-high-high-high", 6, 13, 45],
["high-high-high-high-high-high", 7, 12, 40],
["high-high-high-high-high-high-high", 8, 11, 35],
] as const;
for (const [layerId, zIndex, top, left] of stacked) {
openModalLayer(layerId, zIndex);
applyElementToModalLayer(layerId, {
elementId: `${layerId}-modal`,
element: () => <Modal top={top} left={left} />,
});
}
});
return () => {
toProgress();
modals();
};
}, [boundKeyboard]);
return (
<Box flexDirection="column">
<Text bold>🏠 Home</Text>
<Text>Press P to open Progress Bar</Text>
<Text>Press M to open stacked modals</Text>
<Text>Click Cancel on any modal to close it</Text>
</Box>
);
}
registerComponent(Home, {});
// ── Progress Bar ──
function ProgressBar() {
const [value, setValue] = useState(50);
const { back } = useScreenSystem();
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const left = boundKeyboard(["left"], () => setValue((v) => Math.max(0, v - 5)));
const right = boundKeyboard(["right"], () => setValue((v) => Math.min(100, v + 5)));
const esc = boundKeyboard(["escape"], () => back());
return () => {
left();
right();
esc();
};
}, [boundKeyboard]);
const filled = Math.round(value * 0.4);
const bar = "█".repeat(filled) + "░".repeat(40 - filled);
return (
<Box height="100%" width="100%" justifyContent="center" alignItems="center" flexDirection="column">
<Text dimColor>← → adjust · Esc back</Text>
<Text>{bar} {value}%</Text>
</Box>
);
}
registerComponent(ProgressBar, {}, { parent: Home });
function Modal({ top, left }: { top: number; left: number }) {
const { closeModalLayer } = useScreenSystem();
const { boundKeyboard } = useKeyboard();
const modalCtx = useContext(ModalLayerElementContext);
const layerId = modalCtx?.modalLayer.layerId;
useEffect(() => {
if (!layerId) return;
return boundKeyboard(["escape"], () => closeModalLayer(layerId));
}, [boundKeyboard, closeModalLayer, layerId]);
return (
<Box
position="absolute"
top={top}
left={left}
width={46}
borderStyle="round"
borderColor="yellow"
padding={1}
backgroundColor="black"
flexDirection="column"
>
<Text bold color="yellow">
⚠ cartridge.exe — Application Error
</Text>
<Text color="yellow"> Unhandled exception has occurred in your application.</Text>
<Box flexDirection="row" justifyContent="flex-end" marginTop={1}>
{/* Click-to-close button: a child region with a higher priority
wins over the modal body, so only the button reacts. */}
<ModalButton
label="Cancel"
onPress={() => {
if (layerId) closeModalLayer(layerId);
}}
/>
</Box>
<Text dimColor>Esc closes · click Cancel to close</Text>
</Box>
);
}
// A small clickable control. `priority: 1` makes it win over any region that
// contains it (the modal body would be a priority-0 region).
function ModalButton({ label, onPress }: { label: string; onPress: () => void }) {
const [hovered, setHovered] = useState(false);
const ref = useMouseRegion(
{
onClick: onPress,
onEnter: () => setHovered(true),
onLeave: () => setHovered(false),
},
{ priority: 1 },
);
return (
<Box borderStyle="round" borderColor={hovered ? "green" : "gray"} marginLeft={1} ref={ref}>
<Text>{label}</Text>
</Box>
);
}
render(
<ScenarioManagementProvider defaultScreen={Home} fullScreen>
<KeyboardProvider mouse>
<CurrentScreen />
</KeyboardProvider>
</ScenarioManagementProvider>
);
More modal and layer demos (including mouse interactions) live under examples/ in the repo.
How It Works
Enough talk — let's look under the hood.
ink-cartridge splits into two layers: an adapter layer and an engine layer. The adapter feeds React's data into the engine; the engine does the judging and exposes entry points the adapter can use to steer it.
Every frame, the React adapter pushes the latest data into the engine:
const engine = engineRef.current;
engine.sync({
pagePath: getPath(currentPath),
layers: toKeyboardLayerState(allLayers, getPath(currentPath)),
modalLayers: toKeyboardLayerState(allModalLayers, getPath(currentPath)),
});
That data covers the current page path, every overlay, and every modal layer. Note the keyboard engine is framework-agnostic: the data is plain TS objects, no React elements anywhere. In theory, the engine runs in any JS runtime.
One more detail: key normalization. Ink's useInput hands you { input: string, key: { ctrl, shift, escape, return, ... } }, but the engine only deals with string arrays — ['ctrl+s'], ['escape']. So the adapter translates:
function normalizeKeyNames(input: string, key: Key): string[] {
const names: string[] = [];
if (key.escape) names.push('escape');
if (key.return) names.push('return');
if (key.ctrl && input) names.push(`ctrl+${input}`);
if (input && !key.ctrl && !key.meta) names.push(input);
return names;
}
From there on, the engine just compares strings. No framework-specific key-event shapes anywhere.
Inside the keyboard engine runs a pipeline:
// Simplified pipeline order
[
modal, // modal layers — highest priority
composition, // composition chains
globalSequence, // global sequences
globalKey, // global shortcuts
layer, // overlay broadcast
composition, // screen-level composition chains
globalSequence, // screen-level sequences
globalKey, // screen-level shortcuts
screenStack, // screen stack — lowest priority
]
Each processor decides whether to consume the key event. Consumed means it stops; unconsumed means it keeps flowing down. A boundKeyboard binding is just an entry on the current layer — the pipeline walks top-down looking for a match.
Mouse events follow the same shape, with one extra filtering step. Ink's useInput receives everything the terminal types, including mouse reports (the terminal encodes mouse actions as ANSI escape sequences in the input stream). So the useInput callback filters mouse events out before they can reach the keyboard pipeline:
const mouseReportFilter = new MouseReportFilter();
useInput((input, key) => {
// Part of a mouse report — swallow it, don't feed the keyboard engine
if (mouseReportFilter.consume(input)) {
return;
}
engine.processKey(input, key);
});
Mouse events take their own path. Once KeyboardProvider gets the mouse option, it starts an xterm-mouse instance that listens for mouse events and feeds them straight into the engine's mouse processor:
mouseInstance.on('click', (event) => engine.processMouseEvent(event));
mouseInstance.on('drag', (event) => engine.processMouseEvent(event));
mouseInstance.on('wheel', (event) => engine.processMouseEvent(event));
The engine keeps a table of coordinate regions — that's where the positions of every useMouseRegion-registered Box live. Incoming mouse events hit-test against it: find which region the coordinates land in, fire that region's callbacks.
And That's Not All
This article only scratched the surface. Beyond keyboard, screens, and mouse, ink-cartridge also has:
-
Composition Engine — Vim-style compound actions:
ggto jump to the top of a file,3wfor number-prefixed moves - Focus System — Tab between multiple inputs on one page, or drive focus programmatically
- Modal & Layer System — keyboard-priority management for popups, dropdowns, and tooltips
-
Named Conditions — declarative gates like
when: 'isEditing'that decide whether a binding is live - Sequence Actions — reusable multi-key sequences with timeouts and exclusive mode
- Custom Pipeline Processors — plug your own processors into the pipeline to intercept or reshape key events at any stage
Full docs and more examples live in the repo: BAIGAOa/ink-cartridge
Issues and PRs are very welcome — and if this earns a star, even better. I'll keep making this library better.



Top comments (0)