A library for rendering Ink applications in the browser using Xterm.js.
ink-canvas bridges the gap between Node.js-based CLI UIs built with Ink and web-based terminal emulators. It provides custom stream implementations and Node.js environment mocks that allow Ink to render directly into an Xterm.js instance running in a React application.
- 🖥️ Browser Compatibility: Run Ink applications entirely in the browser
- 🎨 Xterm.js Integration: Leverages the power and styling of Xterm.js
- 📐 Auto Resizing: Automatically handles terminal resizing and layout fitting
- ⌨️ Input Handling: Captures keyboard input from the browser and forwards it to Ink
- 🌊 Custom Streams: Built-in
stdout,stderr, andstdinstreams optimized for the browser - 🔌 Vite Plugin: Easy setup with automatic polyfill configuration
Install ink-canvas and its peer dependencies:
# npm
npm install ink-canvas ink react @xterm/xterm @xterm/addon-fit
# yarn
yarn add ink-canvas ink react @xterm/xterm @xterm/addon-fit
# pnpm
pnpm add ink-canvas ink react @xterm/xterm @xterm/addon-fitHere's a simple example to get you started:
importReact,{useState}from"react";import{Text,Box}from"ink";import{InkCanvas}from"ink-canvas";// Your Ink application componentconstMyInkApp=()=>(<BoxborderStyle="round"borderColor="green"><Text>Hello from Ink in the Browser! 👋</Text></Box>);// Main React componentconstApp=()=>{const[focused,setFocused]=useState(true);return(<divstyle={{height: "100vh",width: "100vw"}}><InkCanvasfocused={focused}terminalOptions={{fontSize: 14,theme: {background: "#1a1b26"},}}><MyInkApp/></InkCanvas></div>);};exportdefaultApp;To use ink-canvas with Vite, you need to configure polyfills for Node.js globals. The easiest way is to use the provided inkCanvasPolyfills plugin:
// vite.config.tsimport{defineConfig}from"vite";importreactfrom"@vitejs/plugin-react";import{inkCanvasPolyfills}from"ink-canvas/plugin";exportdefaultdefineConfig({plugins: [react(),inkCanvasPolyfills(),// Add this plugin],});For Webpack-based projects like Next.js, use the InkCanvasWebpackPlugin. This plugin automatically configures module aliases, fallbacks, and provides global polyfills (process, Buffer) required by Ink.
Next.js Example (next.config.mjs):
import{InkCanvasWebpackPlugin}from"ink-canvas/plugin";/** @type {import('next').NextConfig} */constnextConfig={transpilePackages: ["ink-canvas"],// Ensure ink-canvas is transpiledwebpack: (config,{ isServer })=>{// Only apply the plugin for client-side buildsif(!isServer){config.plugins.push(newInkCanvasWebpackPlugin());}returnconfig;},};exportdefaultnextConfig;Standard Webpack Example (webpack.config.js):
const{ InkCanvasWebpackPlugin }=require("ink-canvas/plugin");module.exports={// ...plugins: [newInkCanvasWebpackPlugin(),// ... other plugins],};The inkCanvasPolyfills plugin handles the following automatically:
- Process Shim: Redirects
node:processimports to a browser-compatible shim - Buffer Polyfill: Provides the
Bufferglobal for binary data operations - Global Object: Ensures the
globalobject is available (maps toglobalThis)
If you prefer to configure polyfills manually or are using a different build tool, you need to:
- Install dependencies:
npm install vite-plugin-node-polyfills- Configure your bundler to alias
node:processto the ink-canvas process shim:
// vite.config.ts (manual configuration)import{defineConfig}from"vite";importreactfrom"@vitejs/plugin-react";import{nodePolyfills}from"vite-plugin-node-polyfills";exportdefaultdefineConfig({plugins: [react(),nodePolyfills({exclude: ["process"],// We use our own process shimglobals: {Buffer: true,global: true,},protocolImports: true,}),],resolve: {alias: {"node:process": "ink-canvas/shims/process",},},});When developing the ink-canvas library itself, pass true to use local source files:
inkCanvasPolyfills(true);// Uses local shims/process.tsThe main component that wraps your Ink application.
import{InkCanvas}from"ink-canvas";| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The Ink application to render |
focused | boolean | false | Whether the terminal captures keyboard input |
cols | number | undefined | Fixed number of columns. If omitted, fits container |
rows | number | undefined | Fixed number of rows. If omitted, fits container |
terminalOptions | ITerminalOptions | {} | Configuration for the Xterm.js instance |
onResize | (dims: { cols: number, rows: number }) => void | - | Callback fired when terminal dimensions change |
...divProps | HTMLAttributes<HTMLDivElement> | - | All other props are passed to the container div |
The terminalOptions prop accepts all Xterm.js ITerminalOptions except disableStdin. Common options include:
<InkCanvasterminalOptions={{// Font settingsfontSize: 16,fontFamily: "JetBrains Mono, Fira Code, monospace",fontWeight: "normal",fontWeightBold: "bold",// Cursor settingscursorStyle: "bar",// 'block' | 'underline' | 'bar'cursorBlink: true,// Theme (colors)theme: {background: "#1a1b26",foreground: "#a9b1d6",cursor: "#c0caf5",cursorAccent: "#1a1b26",selectionBackground: "#33467c",black: "#15161e",red: "#f7768e",green: "#9ece6a",yellow: "#e0af68",blue: "#7aa2f7",magenta: "#bb9af7",cyan: "#7dcfff",white: "#a9b1d6",},// Scrollbackscrollback: 1000,// Other optionsallowProposedApi: true,convertEol: false,}}><MyApp/></InkCanvas>Access the underlying terminal instance using a ref:
import{useRef}from"react";import{InkCanvas,InkCanvasHandle}from"ink-canvas";constApp=()=>{constcanvasRef=useRef<InkCanvasHandle>(null);consthandleClick=()=>{// Access the Xterm.js terminalconstterminal=canvasRef.current?.terminal;// Get current dimensionsconstdimensions=canvasRef.current?.dimensions;console.log(`${dimensions?.cols}x${dimensions?.rows}`);// Access the Ink instanceconstinkInstance=canvasRef.current?.instance;};return(<InkCanvasref={canvasRef}><MyApp/></InkCanvas>);};| Property | Type | Description |
|---|---|---|
terminal | Terminal | null | The Xterm.js Terminal instance |
dimensions | ITerminalDimensions | null | Current terminal columns and rows |
instance | Instance | null | The Ink instance returned by render() |
A Vite plugin that configures all necessary polyfills.
import{inkCanvasPolyfills}from"ink-canvas/plugin";functioninkCanvasPolyfills(dev?: boolean): Plugin[];| Parameter | Type | Default | Description |
|---|---|---|---|
dev | boolean | false | If true, uses local source paths (for library development) |
Returns an array of Vite plugins:
vite-plugin-ink-canvas-polyfill: Aliasesnode:processto the process shimvite-plugin-node-polyfills: ProvidesBufferandglobalpolyfills
Ink is designed for Node.js environments and relies on process.stdout, process.stdin, and other system APIs. ink-canvas provides:
A browser-compatible mock of Node.js's process object:
process.env: Mocked environment variables with sensible defaultsprocess.stdout/stderr: Minimal stream mocks with TTY propertiesprocess.stdin: Input stream mockprocess.nextTick: Implemented usingsetTimeout- Other properties:
platform,version,argv,cwd(), etc.
TerminalWritableStream (stdout/stderr):
- Receives ANSI escape codes and text from Ink
- Converts LF (
\n) to CRLF (\r\n) for proper Xterm.js rendering - Provides cursor manipulation methods (
cursorTo,moveCursor,clearLine) - Emits
resizeevents when terminal dimensions change
TerminalReadableStream (stdin):
- Captures keyboard input from Xterm.js
onDataevents - Buffers input and emits
readableevents for Ink to consume - Supports raw mode for character-by-character input
A wrapper component that:
- Automatically sizes itself to match stdout dimensions
- Listens for resize events and updates accordingly
- Ensures Ink's layout fills the entire terminal viewport
The InkCanvas component manages:
- Xterm.js terminal initialization and cleanup
- Stream creation and connection
- Ink instance lifecycle (render, rerender, unmount)
- Container auto-fitting with ResizeObserver
- Focus state management
import{useState,useEffect}from"react";import{Text,Box,useInput}from"ink";import{InkCanvas}from"ink-canvas";constCounter=()=>{const[count,setCount]=useState(0);useInput((input,key)=>{if(input==="+"||key.upArrow){setCount((c)=>c+1);}elseif(input==="-"||key.downArrow){setCount((c)=>c-1);}elseif(input==="q"){// Handle quit}});return(<BoxflexDirection="column"padding={1}><Text>Count: {count}</Text><TextdimColor>Press +/- or ↑/↓ to change, q to quit</Text></Box>);};constApp=()=>(<InkCanvasfocusedstyle={{width: 400,height: 200}}><Counter/></InkCanvas>);import{useState}from"react";import{Text,Box,useInput}from"ink";import{InkCanvas}from"ink-canvas";constitems=Array.from({length: 20},(_,i)=>`Item ${i+1}`);constScrollableList=()=>{const[selectedIndex,setSelectedIndex]=useState(0);useInput((_,key)=>{if(key.upArrow){setSelectedIndex((i)=>Math.max(0,i-1));}elseif(key.downArrow){setSelectedIndex((i)=>Math.min(items.length-1,i+1));}});return(<BoxflexDirection="column">{items.map((item,index)=>(<Textkey={item}color={index===selectedIndex ? "green" : undefined}bold={index===selectedIndex}>{index===selectedIndex ? "❯ " : " "}{item}</Text>))}</Box>);};constApp=()=>(<InkCanvasfocusedstyle={{width: "100%",height: "400px"}}><ScrollableList/></InkCanvas>);import{useState}from"react";import{Text,Box}from"ink";import{InkCanvas}from"ink-canvas";constResizeDemo=()=>{const[size,setSize]=useState({cols: 0,rows: 0});return(<InkCanvasfocusedonResize={(dims)=>setSize(dims)}style={{width: "100%",height: "100vh"}}><BoxborderStyle="single"padding={1}><Text>
Terminal size: {size.cols} columns × {size.rows} rows
</Text></Box></InkCanvas>);};Ensure you have configured the polyfills correctly. Add inkCanvasPolyfills() to your Vite plugins:
// vite.config.tsimport{inkCanvasPolyfills}from"ink-canvas/plugin";exportdefaultdefineConfig({plugins: [react(),inkCanvasPolyfills()],});Make sure the container element has explicit dimensions:
// ❌ Wrong - no dimensions<InkCanvas><MyApp/></InkCanvas>// ✅ Correct - explicit dimensions via style<InkCanvasstyle={{width: "100%",height: "400px"}}><MyApp/></InkCanvas>// ✅ Correct - explicit dimensions via CSS class<InkCanvasclassName="terminal-container"><MyApp/></InkCanvas>Ensure the focused prop is set to true:
<InkCanvasfocused={true}><MyApp/></InkCanvas>The terminal must be mounted before Ink can calculate layout. If you're seeing layout issues on initial render, try using the onResize callback to trigger a re-render:
const[ready,setReady]=useState(false);<InkCanvasonResize={()=>setReady(true)}>{ready&&<MyApp/>}</InkCanvas>;Ensure your tsconfig.json includes the necessary lib:
{
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}If you encounter issues not covered here, please:
- Check the GitHub Issues
- Create a new issue with a minimal reproduction
MIT