Migration from Ink
Silvery is a drop-in replacement for Ink. Change your imports, and your app works.
Quick Start
Step 1: Install Silvery
bun remove ink ink-testing-library
bun add silveryStep 2: Update Imports
- 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:
// Ink
const { unmount, waitUntilExit } = render(<App />)
// Silvery — just add await
const { unmount, waitUntilExit } = await render(<App />)Step 3: Run Tests
bun testMost apps should work at this point.
Advanced: Explicit Terminal Control
For production apps that need more control, you can create a term explicitly:
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
usingkeyword (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:
| Category | APIs |
|---|---|
| Render | render(<App />) — no term parameter needed |
| Components | <Box>, <Text>, <Newline>, <Spacer>, <Static> |
| Hooks | useInput(), useApp(), useStdout() |
| Styling | All Chalk styles work unchanged |
| Flexbox | All flexbox props (direction, justify, align, wrap, grow, shrink, basis) |
| Borders | All border styles (single, double, round, bold, etc.) |
What's Different
1. Components Know Their Size (The Big Win)
Ink: Must manually thread width props.
// 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.
// 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.
// 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 2Migration: 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.
// 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).
// 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:
// 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.
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:
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.
// Ink: Complex setup
<ScrollableList
items={items}
height={availableHeight}
estimateHeight={(item) => calculateHeight(item, width)}
renderItem={(item) => <Card item={item} />}
/>Silvery: Just render everything.
// 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.
const ref = useRef()
const { width } = measureElement(ref.current)
// Need manual re-render to use widthSilvery: measureElement() works for compatibility, but useContentRect() is simpler.
const { width } = useContentRect()
// Automatically re-renders with correct values7. Hook Naming
Ink: useLayout (if available)
Silvery: useContentRect() is preferred. useLayout is a deprecated alias.
- const { width } = useLayout()
+ const { width } = useContentRect()Known Incompatibilities
By Design
| Behavior | Ink | Silvery | Reason |
|---|---|---|---|
Default flexDirection | column | row | W3C CSS spec compliance |
| Text overflow | Overflows | Wraps | Better default |
| First render dimensions | N/A | Zeros | Required for responsive layout |
| Internal APIs | Exposed | Hidden | Not 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:
bun add yoga-wasm-webimport { 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)
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)
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:
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:
// 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