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

nib-ink

v0.1.1

Published

Svelte 5 terminal UI renderer. Like Ink but for Svelte.

Readme


Build rich, interactive terminal apps using Svelte 5 components, reactive state, and flexbox layout powered by Yoga.

<script>
  import { Box, Text, onInput } from 'nib-ink'

  let count = $state(0)

  onInput((input, key) => {
    if (key.upArrow) count++
    if (key.downArrow) count--
    if (input === 'q') process.exit(0)
  })
</script>

<Box flexDirection="column" padding={1} borderStyle="single">
  <Text bold>Counter: <Text color="cyan">{count}</Text></Text>
  <Text dimColor>up/down to change, q to quit</Text>
</Box>

Why nib-ink?

Svelte 5's reactivity ($state, $derived, $effect) is a perfect fit for terminal UIs. No virtual DOM diffing, no hooks rules, no re-render headaches. Just write components and let the compiler handle the rest.

nib-ink replaces the browser DOM with a lightweight fake DOM (TermNode), feeds it through Yoga for flexbox layout, and renders ANSI escape sequences to stdout. Svelte never knows the difference.

Features

  • Svelte 5 reactivity ($state, $derived, $effect) works unchanged
  • Flexbox layout via Yoga (padding, margin, gap, grow/shrink, wrapping)
  • Box borders (single, double, round, bold, classic, and more)
  • Colors (named, hex, RGB), text styles (bold, italic, dim, underline, strikethrough)
  • Keyboard and mouse input handling
  • Focus management (tab/shift+tab cycling, click-to-focus)
  • Scrollable containers with mouse wheel and scrollbar
  • Text wrapping (wrap, truncate, truncate-start, truncate-middle)
  • Static output area for logs and append-only content
  • Theme system with presets and variants
  • renderToString() for headless rendering and snapshots
  • Test harness for component testing
  • Dirty-region diffing (only redraws changed cells, not full screen)

Install

bun add nib-ink svelte

Requires Bun >= 1.0. Uses Bun's plugin system for Svelte compilation.

Setup

nib-ink needs a Bun plugin to compile .svelte files at import time. Create two files in your project root:

// svelte-loader.ts
import { plugin } from "bun";

plugin({
  name: "svelte",
  setup(build) {
    build.onLoad({ filter: /\.svelte$/ }, async (args) => {
      const { compile } = await import("svelte/compiler");
      const source = await Bun.file(args.path).text();
      const result = compile(source, {
        generate: "client",
        dev: false,
        filename: args.path,
        css: "injected",
      });
      return { contents: result.js.code, loader: "js" };
    });
  },
});
# bunfig.toml
preload = ["./svelte-loader.ts"]

Quick start

Create a component:

<!-- App.svelte -->
<script>
  import { Box, Text } from 'nib-ink'
</script>

<Box borderStyle="round" padding={1}>
  <Text color="green" bold>Hello from nib-ink!</Text>
  <Text>Svelte 5 in the terminal</Text>
</Box>

Mount it:

// index.ts
import { render } from 'nib-ink'
import App from './App.svelte'

render(App)

Run with --conditions=browser (required for Svelte 5 lifecycle hooks):

bun --conditions=browser index.ts

Important: always use --conditions=browser when running nib-ink apps. Without it, Svelte 5 lifecycle hooks (onMount, $effect, etc.) won't work and you'll get cryptic errors.

Components

| Component | Description | |-----------|-------------| | Box | Flexbox container with borders, padding, margin, scroll, absolute positioning | | Text | Styled text with colors, bold, italic, dim, underline, wrapping/truncation | | Newline | Blank line | | Spacer | Flexible whitespace (pushes siblings apart) | | Static | Render-once area for logs and append-only output | | Transform | Apply text transformations to children |

Hooks

| Hook | Description | |------|-------------| | onInput(callback) | Keyboard input handler | | onMouse(callback) | Mouse click, scroll, and drag handler | | getApp() | App lifecycle (exit) | | getFocus(options, callback) | Register as focusable | | getFocusManager() | Control focus programmatically | | getStdout() / getStderr() | Direct stream access | | log(...args) | Write above the TUI (static area) | | flushSync(fn?) | Force synchronous re-render |

render() API

const instance = render(App, props?, options?)

Options: stdout, stdin, exitOnCtrlC (default true), fps (default 30).

Instance methods: unmount(), rerender(), waitUntilExit(), clear().

Headless rendering

import { renderToString } from 'nib-ink'

const output = await renderToString(App, { name: 'world' }, { columns: 80 })

Testing

import { createTestHarness } from 'nib-ink'

const t = await createTestHarness(Counter, { count: 0 })
console.log(t.lastFrame())  // ANSI-stripped text output

t.stdin.write('j')           // simulate keypress
t.unmount()

How it works

Svelte components
       |
       v
  Compiled JS (svelte/internal/client)
       |
       v
  Fake DOM (TermNode tree)
       |
       v
  Yoga layout (flexbox)
       |
       v
  ANSI renderer --> stdout

Svelte 5 components compile to JS that calls svelte/internal/client DOM functions. nib-ink shims globalThis.document and window with a fake DOM before Svelte loads. Svelte's reactivity works unchanged, only the rendering target is replaced.

Examples

21 runnable examples included:

bun run example:hello          # minimal hello world
bun run example:counter        # interactive counter
bun run example:todo           # todo list with keyboard nav
bun run example:dashboard      # real-time system dashboard
bun run example:table          # sortable process table
bun run example:spinner        # build pipeline with spinners
bun run example:text-input     # form with text fields
bun run example:focus-demo     # tab/click focus navigation
bun run example:scroll         # scrollable log viewer
bun run example:mouse          # mouse click canvas
bun run example:theme          # theme switching

See all examples in examples/ or the examples doc.

Documentation

Architecture internals: docs/internals/

License

MIT