nib-ink
v0.1.1
Published
Svelte 5 terminal UI renderer. Like Ink but for Svelte.
Maintainers
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 svelteRequires 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.tsImportant: always use
--conditions=browserwhen 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 --> stdoutSvelte 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 switchingSee all examples in examples/ or the examples doc.
Documentation
- Getting Started - install, setup, first app
- Components - Box, Text, Static, Transform props
- Hooks - input, mouse, focus, app lifecycle
- Theme - presets, variants, custom themes
- render() API - options, instance methods, testing
- Examples - runnable demos
Architecture internals: docs/internals/
License
MIT
