npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ink-native

v0.3.0

Published

Render Ink terminal apps in a native window

Readme

ink-native

Render Ink TUI applications in native windows instead of the terminal. Build graphical applications using React/Ink's declarative paradigm with zero system dependencies.

Why ink-native?

For plain text TUIs, a GPU-accelerated terminal like Ghostty or Kitty works great. So why render to a native window instead?

The problem appears when you need high-framerate graphics alongside your Ink UI like an emulator, game, or video player with a React-based menu system.

Even GPU-accelerated terminals struggle when using image protocols (like the Kitty graphics protocol) because they require:

Raw pixels → base64 encode (+33% size) → escape sequences →
PTY syscalls → terminal parses sequences → base64 decode → GPU upload → render

At 60fps for an 800x600 frame, that's ~110 MB/s of base64-encoded data through the PTY. Even the fastest terminals can't keep up.

Direct framebuffer rendering bypasses all of this:

Raw pixels → memcpy to framebuffer → render

No encoding, no PTY, no parsing, no process boundary - just a memory copy.

ink-native lets you combine both: render game/emulator frames directly to the framebuffer for performance, while reusing your existing Ink components for menus and UI in the same window. And since everything is bundled (native library + bitmap font), there are zero system dependencies to install.

Features

  • Zero system dependencies - no external libraries to install
  • Full ANSI color support (16, 256, and 24-bit true color)
  • Keyboard input with modifier keys (Ctrl, Shift, Alt)
  • Window resizing with automatic terminal dimension updates
  • HiDPI/Retina display support
  • Embedded Cozette bitmap font with 6,000+ glyphs
  • Cross-platform (macOS, Linux, Windows)

Installation

npm install ink-native
# or
pnpm add ink-native

No system dependencies required. The native window library and bitmap font are bundled with the package.

Demo

Run the built-in demo to see ink-native in action:

npx ink-native
# or
pnpm dlx ink-native

The demo showcases text styles, colors, box layouts, and dynamic updates. Use --help to see available options:

npx ink-native --help

Example commands:

# Custom window size
npx ink-native --width 1024 --height 768

# Dark background
npx ink-native --background "#1a1a2e"

# Custom frame rate
npx ink-native --frame-rate 30

| Flag | Description | | -------------- | ----------------------------------------- | | --title | Window title | | --width | Window width in pixels (default: 800) | | --height | Window height in pixels (default: 600) | | --background | Background color as hex (e.g., "#1a1a2e") | | --frame-rate | Force frame rate instead of default 60fps | | -h, --help | Show help message |

Usage

import React, { useState, useEffect } from "react";
import { render, Text, Box } from "ink";
import { createStreams } from "ink-native";

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1);
    }, 1_000);
    return () => clearInterval(timer);
  }, []);

  return (
    <Box flexDirection="column" padding={1}>
      <Text color="green" bold>
        Hello from ink-native!
      </Text>
      <Text>
        Counter: <Text color="cyan">{count}</Text>
      </Text>
    </Box>
  );
};

const { stdin, stdout, window } = createStreams({
  title: "My App",
  width: 800,
  height: 600,
});

render(<App />, { stdin, stdout });

window.on("close", () => process.exit(0));

Direct Framebuffer Access

For high-framerate graphics (emulators, games, video players), you can write pixels directly to the framebuffer while pausing Ink:

import { createStreams, packColor } from "ink-native";
import { render, Text, Box } from "ink";

const App = () => (
  <Box>
    <Text>Game UI overlay</Text>
  </Box>
);

const { stdin, stdout, window, renderer } = createStreams({
  title: "My Game",
  width: 800,
  height: 600,
});

render(<App />, { stdin, stdout });

// Pause Ink to take over rendering
window.pause();

const fb = renderer.getFramebuffer();

// Keyboard events keep firing when paused
window.on("keydown", (event) => {
  if (event.key === "q") {
    clearInterval(gameLoop);
    window.resume(); // hand control back to Ink
  }
});

window.on("close", () => {
  clearInterval(gameLoop);
  process.exit(0);
});

// Game loop — just render, events are handled automatically
const gameLoop = setInterval(() => {
  // Write pixels directly (0xAARRGGBB format)
  for (let y = 100; y < 200; y++) {
    for (let x = 100; x < 200; x++) {
      fb.pixels[y * fb.width + x] = packColor(255, 0, 0); // red square
    }
  }

  // Copy to native buffer (the event loop presents it)
  renderer.present();
}, 16); // ~60fps

Switching Between Ink UI and Custom Rendering

For applications that need to switch between Ink UI (e.g., menus) and custom rendering (e.g., an emulator or game), use pause() and resume() to hand off control:

import { render, Text, Box } from "ink";
import { createStreams, packColor } from "ink-native";

const { stdin, stdout, window, renderer } = createStreams({
  title: "My Emulator",
  width: 800,
  height: 600,
});

// Phase 1: Render menu UI with Ink
const MenuApp = () => (
  <Box flexDirection="column" padding={1}>
    <Text color="green" bold>My Emulator</Text>
    <Text>Press Enter to start</Text>
  </Box>
);

const { unmount } = render(<MenuApp />, { stdin, stdout });

// Phase 2: When ready, pause Ink and take over rendering
const startEmulator = () => {
  window.pause();

  const fb = renderer.getFramebuffer();
  let emuLoop: ReturnType<typeof setInterval>;

  // Keyboard events keep firing when paused
  window.on("keydown", (event) => {
    if (event.key === "Escape") {
      // Return to menu
      clearInterval(emuLoop);
      renderer.clear();
      window.resume(); // hand control back to Ink
    }
  });

  emuLoop = setInterval(() => {
    // Write emulator frame directly to the framebuffer
    renderEmulatorFrame(fb.pixels, fb.width, fb.height);

    // Copy to native buffer (the event loop presents it)
    renderer.present();
  }, 16);
};

The framebuffer is shared — Ink renders to it when active, and you write pixels directly when paused. Calling resume() hands control back to Ink seamlessly.

API Summary

| Export | Description | | --------------------------- | ------------------------------------------------------- | | packColor(r, g, b) | Pack RGB values into 0xAARRGGBB pixel format | | renderer.getFramebuffer() | Get { pixels, width, height } — the live pixel buffer | | window.pause() | Pause Ink (events keep firing) | | window.resume() | Resume Ink | | window.isPaused() | Check if Ink is paused |

API

createStreams(options?)

Creates stdin/stdout streams and a window for use with Ink.

Options (StreamsOptions)

| Option | Type | Default | Description | | ----------------- | ------------------------------------ | -------------- | ----------------------------------------------------- | | title | string | "ink-native" | Window title | | width | number | 800 | Window width in pixels | | height | number | 600 | Window height in pixels | | backgroundColor | [number, number, number] \| string | [0, 0, 0] | Background color as RGB tuple or hex string "#RRGGBB" | | frameRate | number | 60 | Target frame rate | | scaleFactor | number \| null | null | Override HiDPI scale factor (null = auto-detect) |

Returns (Streams)

{
  stdin: InputStream; // Readable stream for keyboard input
  stdout: OutputStream; // Writable stream for ANSI output
  window: Window; // Window wrapper with events
  renderer: UiRenderer; // UI renderer (for advanced use)
}

Window

Event emitter for window lifecycle and input events.

Events

  • keydown -- Emitted when a key is pressed (with NativeKeyboardEvent payload)
  • keyup -- Emitted when a key is released (with NativeKeyboardEvent payload)
  • close -- Emitted when the window is closed
  • resize -- Emitted when the window is resized (with { columns, rows })
  • sigint -- Emitted on Ctrl+C (if a listener is registered; otherwise sends SIGINT to the process)

Methods

  • getDimensions() -- Returns { columns, rows } for terminal size
  • getFrameRate() -- Returns the current frame rate
  • getOutputStream() -- Returns the output stream
  • clear() -- Clear the screen
  • close() -- Close the window
  • isClosed() -- Check if the window is closed
  • pause() -- Pause Ink for manual rendering (keydown/keyup/resize/close events keep firing)
  • resume() -- Resume Ink
  • isPaused() -- Check if Ink is paused
  • processEvents() -- Manually poll events and present the framebuffer (for custom render loops that need explicit control)

Keyboard Events

The window emits keydown and keyup events with a NativeKeyboardEvent payload:

import { createStreams, type NativeKeyboardEvent } from "ink-native";

const { window } = createStreams({ title: "My Game" });

window.on("keydown", (event: NativeKeyboardEvent) => {
  console.log(event.key);     // "a", "A", "Enter", "ArrowUp", "Shift"
  console.log(event.code);    // "KeyA", "Enter", "ArrowUp", "ShiftLeft"
  console.log(event.ctrlKey); // true if Ctrl is held
  console.log(event.type);    // "keydown"
});

window.on("keyup", (event: NativeKeyboardEvent) => {
  console.log(event.key, "released");
});

NativeKeyboardEvent

| Property | Type | Description | | ---------- | ------------------------ | ------------------------------------------------------- | | key | string | The key value: "a", "A", "Enter", "Shift" | | code | string | Physical key code: "KeyA", "Enter", "ShiftLeft" | | ctrlKey | boolean | Whether Ctrl is held | | shiftKey | boolean | Whether Shift is held | | altKey | boolean | Whether Alt is held | | metaKey | boolean | Whether Meta/Command is held | | repeat | false | Always false (fenster only reports transitions) | | type | "keydown" \| "keyup" | Whether the key was pressed or released |

Modifier keys fire their own events with left/right distinction — event.code will be "ShiftLeft" or "ShiftRight", while event.key gives the generic name "Shift".

isNativeKeyboardEvent(value)

Type guard to check if a value is a NativeKeyboardEvent:

import { isNativeKeyboardEvent } from "ink-native";

window.on("keydown", (event) => {
  if (isNativeKeyboardEvent(event)) {
    // event is typed as NativeKeyboardEvent
  }
});

Terminal Sequences

In addition to keydown/keyup events, key presses are also mapped to terminal escape sequences and pushed to stdin for Ink's built-in key handling:

  • Arrow keys (Up, Down, Left, Right)
  • Enter, Escape, Backspace, Tab, Delete
  • Home, End, Page Up, Page Down
  • Function keys (F1-F12)
  • Ctrl+A through Ctrl+Z
  • Shift for uppercase letters
  • Alt + letter sends \x1b + letter

Low-Level Components

For advanced use cases, ink-native exports its internal components:

import {
  // Main API
  createStreams,
  Window,
  InputStream,
  OutputStream,

  // Keyboard events
  createKeyboardEvent,
  isNativeKeyboardEvent,
  type NativeKeyboardEvent,

  // Renderer
  UiRenderer,
  packColor,
  type UiRendererOptions,
  type Framebuffer,
  type ProcessEventsResult,

  // Font
  BitmapFontRenderer,

  // ANSI parsing
  AnsiParser,
  type Color,
  type DrawCommand,

  // Fenster FFI bindings
  getFenster,
  Fenster,
  isFensterAvailable,
  type FensterPointer,
  type FensterKeyEvent,

  // Types
  type StreamsOptions,
  type Streams,
} from "ink-native";

isFensterAvailable()

Check if the native fenster library can be loaded on the current platform. Useful for graceful fallback to terminal rendering:

import { isFensterAvailable, createStreams } from "ink-native";
import { render } from "ink";

if (isFensterAvailable()) {
  const { stdin, stdout, window } = createStreams({ title: "My App" });
  render(<App />, { stdin, stdout });
  window.on("close", () => process.exit(0));
} else {
  render(<App />);
}

AnsiParser

Parses ANSI escape sequences into structured draw commands. Supports cursor positioning, 16/256/24-bit colors, text styles (bold, dim, reverse), screen/line clearing, and alt screen buffer.

BitmapFontRenderer

Renders text by blitting embedded Cozette bitmap font glyphs into a Uint32Array framebuffer. Supports 6,000+ glyphs including ASCII, Latin-1, box drawing, block elements, braille patterns, and more.

getFenster() / Fenster

Low-level FFI bindings to the fenster native library via koffi. Provides direct access to window creation, framebuffer manipulation, and event polling.

License

MIT