From raw escape sequences to a polished Ink component

Building Modern Terminal Apps with Mouse Support


Text-mode applications were once keyboard-only, but today users expect to click, scroll, and drag—even inside the terminal. Adding pointer support:

  • Speeds navigation - skip arrow-key marathons
  • Feels familiar - re-uses GUI muscle memory
  • Enables richer widgets - scrollable lists, draggable panes, resizable splits

Because a terminal is still a bidirectional byte stream, the app must:

  1. Tell the emulator to emit mouse reports (special escape codes).
  2. Parse those bytes from stdin.
  3. Map them to UI state (hover, press, wheel, drag).

How Terminals Report Mouse Events

Modern emulators support the XTerm mouse protocols. Request SGR (1006) first and fall back if needed.

ProtocolEnable Seq.CoordinatesButtons
X10 (1989)ESC[?9h1-based 0-255press only
VT200ESC[?1000h1-based 0-255press + release
UTF-8ESC[?1005h1-based ∞>255 cols
SGR (1006)ESC[?1006h1-based ∞press, release, drag, wheel

SGR Packet Anatomy

SGR packets’ escape sequences look like this:

ESC [ < btn ; col ; row (M | m)

Where:

  • ESC = escape character (0x1b)
  • [ = CSI (Control Sequence Introducer)
  • < = start of mouse report
  • btn - button/wheel + modifiers (Shift+4, Alt+8, Ctrl+16)
  • col / row - 1-based position
  • M = press/drag, m = release

A scroll wheel sends btn = 64 (up) or 65 (down) with only a press event.

Enabling & Cleaning Up

Set the stream to raw so bytes arrive immediately, then toggle SGR mode on mount and off on exit.

process.stdin.setRawMode?.(true);
process.stdin.resume();

export const enableMouse = () =>
  process.stdout.write("\x1b[?1002h\x1b[?1006h"); // drag + SGR

export const disableMouse = () =>
  process.stdout.write("\x1b[?1002l\x1b[?1006l");

Guard with process.stdout.isTTY so tests and CI logs stay clean.

From Bytes to a MouseEvent

We ‘ll convert each SGR packet into a strongly-typed object the rest of the app can use.

// types.ts
export interface MouseEvent {
  x: number; // 0-based col
  y: number; // 0-based row
  button: "left" | "middle" | "right" | "wheelUp" | "wheelDown";
  action: "press" | "release" | "drag";
  shift: boolean;
  alt: boolean;
  ctrl: boolean;
}

Parsing Helper

// parseMouse.ts
const RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/;

export const parseSGR = (buf: Buffer): MouseEvent | null => {
  const m = RE.exec(buf.toString());
  if (!m) return null;
  const [ , codeS, colS, rowS, suf ] = m;
  const code = +codeS;

  const wheel = code & 0b11000000;
  const btnId = code & 0b11;

  const button = wheel === 64 ? "wheelUp"   :
                 wheel === 65 ? "wheelDown" :
                 btnId === 0  ? "left"      :
                 btnId === 1  ? "middle"    :
                 "right";

  return {
    x: +colS - 1,
    y: +rowS - 1,
    button,
    action: suf === "M" && !wheel ? "press" :
            suf === "m"            ? "release" : "drag",
    shift: !!(code & 4),
    alt:   !!(code & 8),
    ctrl:  !!(code & 16),
  };
};

The regex is the only heavy work; everything else is bit-twiddling.

useMouse - A Thin Ink Hook

Ink’s useInput mixes keys and mouse bytes. Wrapping our parser in a custom hook keeps components clean.

import {useEffect} from "react";
import {enableMouse, disableMouse} from "./mouseMode.js";
import {parseSGR, MouseEvent} from "./parseMouse.js";

export const useMouse = (onEvent: (e: MouseEvent) => void) => {
  useEffect(() => {
    enableMouse();
    const handler = (buf: Buffer) => parseSGR(buf) && onEvent(parseSGR(buf)!);
    process.stdin.on("data", handler);
    return () => {
      process.stdin.off("data", handler);
      disableMouse();
    };
  }, [onEvent]);
};

Composable Widgets

<MouseArea> - limit pointer events to a region

A wrapper that forwards events only when the cursor is inside its box.

import React, {useLayoutEffect, useRef, useState} from "react";
import {Box, measureElement} from "ink";
import {useMouse} from "../hooks/useMouse.js";
import {MouseEvent} from "../utils/parseMouse.js";

export const MouseArea: React.FC<{onMouse?: (e: MouseEvent) => void}> = ({onMouse, children}) => {
  const ref = useRef<any>(null);
  const [rect, setRect] = useState({x:0, y:0, w:0, h:0});

  useLayoutEffect(() => {
    if (ref.current) {
      const m = measureElement(ref.current);
      setRect({x:m.x, y:m.y, w:m.width, h:m.height});
    }
  });

  useMouse(e => {
    const inside = e.x >= rect.x && e.x < rect.x + rect.w &&
                   e.y >= rect.y && e.y < rect.y + rect.h;
    inside && onMouse?.(e);
  });

  return <Box ref={ref}>{children}</Box>;
};

<ClickableButton> - hover & click feedback

A minimal button that changes color on hover and reports clicks.

import React, {useState} from "react";
import {Box, Text} from "ink";
import {MouseArea} from "./MouseArea.js";
import {MouseEvent} from "../utils/parseMouse.js";

export const ClickableButton: React.FC<{label: string; onPress: () => void}> = ({label, onPress}) => {
  const [hover, setHover] = useState(false);
  const [active, setActive] = useState(false);

  const handle = (e: MouseEvent) => {
    if (e.action === "press" && e.button === "left") setActive(true);
    if (e.action === "release") {
      active && onPress();
      setActive(false);
    }
    setHover(e.action !== "release");
  };

  return (
    <MouseArea onMouse={handle}>
      <Box borderStyle="round" borderColor={active ? "yellow" : hover ? "cyan" : "gray"} paddingX={1}>
        <Text bold={hover}>{label}</Text>
      </Box>
    </MouseArea>
  );
};

Putting It All Together

import React from "react";
import {render, Box, Text} from "ink";
import {ClickableButton} from "./components/ClickableButton.js";

const App = () => (
  <Box flexDirection="column" gap={1}>
    <Text>Try clicking the button ⬇︎</Text>
    <ClickableButton label="Hello world" onPress={() => console.log("Button pressed")}/>
  </Box>
);

render(<App />);

Run:

node app.js

You now handle click, hover, wheel, and drag events—ready for lists, panes, or custom widgets.

Best Practices & Pitfalls

  1. Always clean up - disable mouse mode and raw input on exit or error.
  2. Skip when not TTY - piping output? Don ‘t emit escapes.
node app.js