Svelte 5 terminal UI renderer.
Like Ink (React), but for Svelte.
Install · Quick Start · Components · Hooks · Docs
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>
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.
$state, $derived, $effect) works unchangedwrap, truncate, truncate-start, truncate-middle)renderToString() for headless rendering and snapshotsbun add nib-ink svelte
Requires Bun >= 1.0. Uses Bun's plugin system for Svelte compilation.
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"]
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=browserwhen running nib-ink apps. Without it, Svelte 5 lifecycle hooks (onMount,$effect, etc.) won't work and you'll get cryptic errors.
| 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 |
| 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 |
const instance = render(App, props?, options?)
Options: stdout, stdin, exitOnCtrlC (default true), fps (default 30).
Instance methods: unmount(), rerender(), waitUntilExit(), clear().
import { renderToString } from 'nib-ink'
const output = await renderToString(App, { name: 'world' }, { columns: 80 })
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()
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.
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.
Architecture internals: docs/internals/
MIT