DEV Community

Cover image for Use ink-cartridge to say goodbye to keyboard conflicts and messy page routing in React Ink
aigao
aigao

Posted on

Use ink-cartridge to say goodbye to keyboard conflicts and messy page routing in React Ink

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.
});
Enter fullscreen mode Exit fullscreen mode

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 isActive flags, no more "two components fighting over the same key."

  • Component-tree screen navigation. Register your screen components, then navigate with skip() and back() — 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 Box becomes a clickable, draggable, scrollable region via useMouseRegion. 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
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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:

Dragging a window with the mouse in an Ink app

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>,
);
Enter fullscreen mode Exit fullscreen mode

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:

Hovering panels moves keyboard focus between them

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>,
);
Enter fullscreen mode Exit fullscreen mode

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:

Stacked modals: Esc and clicks only affect the topmost one

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>
);
Enter fullscreen mode Exit fullscreen mode

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)),
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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: gg to jump to the top of a file, 3w for 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)