Skip to content

Migration from Ink

Silvery is a drop-in replacement for Ink. Change your imports, and your app works.

Quick Start

Step 1: Install Silvery

bash
bun remove ink ink-testing-library
bun add silvery

Step 2: Update Imports

diff
- import { Box, Text, render, useInput, useApp } from 'ink'
+ import { Box, Text, render, useInput, useApp } from 'silvery'

- import { render } from 'ink-testing-library'
+ import { render } from '@silvery/test'

That's it. render(<App />) works without any term parameter — just add await:

tsx
// Ink
const { unmount, waitUntilExit } = render(<App />)

// Silvery — just add await
const { unmount, waitUntilExit } = await render(<App />)

Step 3: Run Tests

bash
bun test

Most apps should work at this point.

Advanced: Explicit Terminal Control

For production apps that need more control, you can create a term explicitly:

tsx
import { render, createTerm } from "silvery"

using term = createTerm()
const { unmount, waitUntilExit } = await render(<App />, term)

Why use createTerm()?

  • Different contexts: Swap term configurations for production, testing, or CI (colors, dimensions, capabilities).
  • Better testing: Mock terms that capture output, simulate terminal sizes, or disable colors.
  • Explicit cleanup: The using keyword (TC39 Explicit Resource Management) automatically restores cursor, raw mode, and alternate screen when the scope exits.

Without createTerm(), Silvery creates a default term internally — matching Ink's behavior exactly.

Why is render() async?

render() returns a handle synchronously (like Ink), but await-ing it waits for layout engine initialization. With Flexily (the default), this is near-instant — just a dynamic import(). With Yoga, it's a genuine WASM compilation step. For fully synchronous rendering, use renderSync() after initializing the engine manually. Most apps should just await render(<App />).

What Works Identically

These APIs are 100% compatible:

CategoryAPIs
Renderrender(<App />) — no term parameter needed
Components<Box>, <Text>, <Newline>, <Spacer>, <Static>
HooksuseInput(), useApp(), useStdout()
StylingAll Chalk styles work unchanged
FlexboxAll flexbox props (direction, justify, align, wrap, grow, shrink, basis)
BordersAll border styles (single, double, round, bold, etc.)

What's Different

1. Components Know Their Size (The Big Win)

Ink: Must manually thread width props.

tsx
// Ink: Width must be passed down
function Card({ width }: { width: number }) {
  return <Text>{truncate(title, width)}</Text>
}

function App() {
  return <Card width={availableWidth - padding * 2} />
}

Silvery: Components can ask for their size.

tsx
// Silvery: Just ask
function Card() {
  const { width } = useContentRect()
  return <Text>{truncate(title, width)}</Text>
}

function App() {
  return <Card /> // No width prop needed!
}

2. flexDirection Defaults to row (CSS spec)

Ink: Box defaults to flexDirection="column" (non-standard, but convenient for document flow).

Silvery: Box defaults to flexDirection="row" (W3C CSS spec). The root node and <Screen> still default to column.

tsx
// Ink: children stack vertically by default
<Box>
  <Text>Line 1</Text>
  <Text>Line 2</Text>
</Box>
// Output:
// Line 1
// Line 2

// Silvery: children flow horizontally by default
<Box>
  <Text>Line 1</Text>
  <Text>Line 2</Text>
</Box>
// Output: Line 1Line 2

Migration: Add flexDirection="column" to any <Box> that relies on Ink's vertical stacking default. The root element and <Screen> already default to column, so top-level layouts usually work without changes.

Why not match Ink's default?

Silvery follows the CSS spec so that flexbox knowledge from web development transfers directly. See Flexily vs Yoga Philosophy for the full rationale. If you prefer exact Ink layout behavior, you can use Yoga as the layout engine.

3. Text Wraps by Default

Ink: Text overflows its container.

tsx
// Ink: Broken layout
<Box width={10}>
  <Text>This is a very long text</Text>
</Box>
// Output: "This is a very long text" (overflows)

Silvery: Text wraps to fit its container by default (word-aware wrapping).

tsx
// Silvery: Text wraps to container width
<Box width={10}>
  <Text>This is a very long text</Text>
</Box>
// Output:
// "This is a"
// "very long"
// "text"

You can also truncate with an ellipsis instead of wrapping:

tsx
// Truncation modes
<Text wrap="truncate">This is a very long text</Text>      // "This is a…"
<Text wrap="truncate-start">This is a very long text</Text> // "…long text"
<Text wrap="truncate-middle">This is a very long text</Text> // "This…text"

Migration: If you rely on overflow, add wrap={false} to disable both wrapping and truncation.

4. First Render Shows Zeros

Ink: Components render once with final output.

Silvery: Components using useContentRect() render twice. First render has { width: 0, height: 0 }, second has actual values.

tsx
function Header() {
  const { width } = useContentRect()
  // First render: width=0
  // Second render: width=80
  return <Text>{"=".repeat(width)}</Text>
}

This is usually invisible (both renders happen before first paint). Add a guard if needed:

tsx
function Header() {
  const { width } = useContentRect()
  if (width === 0) return null
  return <Text>{"=".repeat(width)}</Text>
}

5. Scrolling Just Works

Ink: Manual virtualization with height estimation.

tsx
// Ink: Complex setup
<ScrollableList
  items={items}
  height={availableHeight}
  estimateHeight={(item) => calculateHeight(item, width)}
  renderItem={(item) => <Card item={item} />}
/>

Silvery: Just render everything.

tsx
// Silvery: No config needed
<Box overflow="scroll" scrollTo={selectedIdx}>
  {items.map((item) => (
    <Card key={item.id} item={item} />
  ))}
</Box>

Migration: Replace virtualization components with overflow="scroll".

6. measureElement() -> useContentRect()

Ink: Use measureElement() after render.

tsx
const ref = useRef()
const { width } = measureElement(ref.current)
// Need manual re-render to use width

Silvery: measureElement() works for compatibility, but useContentRect() is simpler.

tsx
const { width } = useContentRect()
// Automatically re-renders with correct values

7. Hook Naming

Ink: useLayout (if available)

Silvery: useContentRect() is preferred. useLayout is a deprecated alias.

diff
- const { width } = useLayout()
+ const { width } = useContentRect()

Known Incompatibilities

By Design

BehaviorInkSilveryReason
Default flexDirectioncolumnrowW3C CSS spec compliance
Text overflowOverflowsWrapsBetter default
First render dimensionsN/AZerosRequired for responsive layout
Internal APIsExposedHiddenNot public API

Layout Engine Differences

If your Ink app uses advanced flexbox features (flexWrap, alignContent, percentage flexBasis, absolute positioning with offsets), the default Flexily layout engine may produce slightly different results than Yoga. This is because Flexily follows the CSS spec where Yoga diverges — see Flexily vs Yoga Philosophy.

For exact Ink layout parity, install Yoga and switch the layout engine:

bash
bun add yoga-wasm-web
tsx
import { render } from "silvery"

await render(<App />, { layoutEngine: "yoga" })

Or set SILVERY_ENGINE=yoga to switch globally without code changes.

Most Ink apps use simple layouts that work identically in both engines.

Removing Width Prop Threading

After migrating, you can simplify your code by removing manual width calculations:

Before (Ink)

tsx
function Board({ width }: { width: number }) {
  const colWidth = Math.floor((width - 2) / 3)
  return (
    <Box>
      <Column width={colWidth} />
      <Column width={colWidth} />
      <Column width={colWidth} />
    </Box>
  )
}

function Column({ width, items }) {
  return (
    <Box width={width}>
      {items.map((item) => (
        <Card width={width - 2} item={item} />
      ))}
    </Box>
  )
}

After (Silvery)

tsx
function Board() {
  return (
    <Box>
      <Column />
      <Column />
      <Column />
    </Box>
  )
}

function Column({ items }) {
  return (
    <Box flexGrow={1}>
      {items.map((item) => (
        <Card item={item} />
      ))}
    </Box>
  )
}

function Card({ item }) {
  const { width } = useContentRect()
  // Use width only where actually needed
}

The Compat Layer

If you're using Silvery's runtime (pipe() + createApp()), the Ink compat layer is a composable plugin:

tsx
import { pipe, createApp, withReact, withTerminal, withInk } from "@silvery/tea/plugins"

const app = pipe(
  createApp(store),
  withReact(<App />),
  withTerminal(process),
  withInk(), // enables Ink's useFocus, useFocusManager, useCursor
)
await app.run()

withInk() composes two thin adapters: withInkCursor() (bridges Ink's useCursor to silvery's CursorStore) and withInkFocus() (provides Ink's flat-list focus system). You can use them individually:

tsx
// Only need Ink cursor compat, not focus
const app = pipe(createApp(store), withReact(<App />), withTerminal(process), withInkCursor())

As you adopt silvery-native APIs (useFocusable() instead of useFocus(), silvery's useCursor() instead of Ink's), you can drop the adapters one by one. See Compat Layer Architecture for details.

Getting Help

  • GitHub Issues: Report bugs or request features
  • Migration Problems: Tag issue with migration

Released under the MIT License.