Ink Integration - greydragon888/real-router GitHub Wiki
@real-router/react/ink โ subpath of the React adapter for building terminal UIs with Ink v7+. Same hooks as the DOM adapter, plus two terminal-specific primitives: InkRouterProvider and InkLink.
npm install @real-router/react @real-router/core @real-router/memory-plugin inkPeer dependencies: react >= 19.2.0 and ink >= 7.0.0 (Ink v7 itself pins React 19.2+). ink is an optional peer โ DOM consumers of @real-router/react are not prompted to install it.
The official Ink routing recipe (vadimdemedes/ink#874, merged Feb 2026) recommends pairing React Router's MemoryRouter with hand-rolled useInput / useNavigate for every menu item. There is no <Link> equivalent because React Router renders HTML anchors, which terminals cannot process.
@real-router/react/ink ships that recipe packaged:
-
InkLinkโ focus-aware text link. Joins Ink's focus ring viauseFocus, listens for Enter viauseInput, renders colored<Text>reflecting focus + active state. No per-menu-item boilerplate. -
InkRouterProviderโ drop-in provider with no DOM dependencies and noaria-liveannouncer. -
@real-router/memory-pluginโ replacesMemoryRouter. Pure JS history stack, nowindow.history. -
All the shared hooks โ
useRouter,useRoute,useRouteNode,useNavigator,useRouteUtils,useRouterTransition. Identical to the DOM adapter.
import { createRouter } from "@real-router/core";
import { memoryPluginFactory } from "@real-router/memory-plugin";
import {
InkLink,
InkRouterProvider,
useRouteNode,
} from "@real-router/react/ink";
import { Box, Text, render, useApp, useInput } from "ink";
const router = createRouter(
[
{ name: "home", path: "/" },
{
name: "users",
path: "/users",
children: [{ name: "view", path: "/:id" }],
},
{ name: "about", path: "/about" },
],
{ defaultRoute: "home", allowNotFound: true },
);
router.usePlugin(memoryPluginFactory());
await router.start("/");
const Menu = () => (
<Box marginTop={1} columnGap={2}>
<InkLink routeName="home" focusColor="cyan" activeColor="green" autoFocus>
[ Home ]
</InkLink>
<InkLink routeName="users" focusColor="cyan" activeColor="green">
[ Users ]
</InkLink>
<InkLink routeName="about" focusColor="cyan" activeColor="green">
[ About ]
</InkLink>
</Box>
);
const Body = () => {
const { route } = useRouteNode("");
const top = route?.name.split(".")[0];
switch (top) {
case "home":
return <Text>Home</Text>;
case "users":
return <Text>Users</Text>;
case "about":
return <Text>About</Text>;
default:
return <Text>Unknown: {top ?? "โ"}</Text>;
}
};
const App = () => {
const { exit } = useApp();
useInput((input, key) => {
if (input === "q" || key.escape) exit();
});
return (
<Box flexDirection="column" paddingX={1}>
<Text bold>real-router + Ink demo</Text>
<Text dimColor>Tab to move focus, Enter to navigate, q to quit.</Text>
<Menu />
<Body />
</Box>
);
};
render(
<InkRouterProvider router={router}>
<App />
</InkRouterProvider>,
);A full runnable app lives at examples/react/ink-demo.
import {
// Components
InkRouterProvider,
InkLink,
RouterErrorBoundary,
// Hooks (shared with DOM adapter)
useRouter,
useRoute,
useRouteNode,
useNavigator,
useRouteUtils,
useRouterTransition,
// Types
InkLinkProps,
InkRouterProviderProps,
RouterErrorBoundaryProps,
Navigator,
RouterTransitionSnapshot,
} from "@real-router/react/ink";What is not exported from /ink (and why):
| Missing export | Reason |
|---|---|
Link |
DOM-only โ renders <a> and filters MouseEvent<HTMLAnchorElement>. Use InkLink. |
RouteView |
<Activity> behavior in terminal UIs is untested. Compose routes with useRouteNode("") + switch. |
announceNavigation |
InkRouterProvider does not forward it; createRouteAnnouncer uses document.querySelector + rAF. |
scrollRestoration |
DOM-only โ createScrollRestoration reads window.scrollY / history.scrollRestoration. No terminal equivalent. InkRouterProvider does not forward this prop. |
Root provider for Ink apps. Thin wrapper over the shared RouterProvider that omits the announceNavigation prop โ the DOM route announcer has no terminal equivalent.
| Prop | Type | Required | Description |
|---|---|---|---|
router |
Router |
Yes | Router instance from @real-router/core
|
children |
ReactNode |
Yes | Ink component tree |
<InkRouterProvider router={router}>
<App />
</InkRouterProvider>Under the hood InkRouterProvider reuses the same three-context architecture (RouterContext, NavigatorContext, RouteContext) as the DOM provider โ all shared hooks work unchanged.
Focusable text link. Integrates with Ink's focus manager and keyboard input:
-
Focus โ joins the focus ring via
useFocus. Tab/Shift+Tab cycle throughInkLinks. -
Activation โ
useInput({ isActive: isFocused })listens for Enter and callsrouter.navigate(...). -
Styling โ resolves a final
colorandinversefrom three precedence tiers (focus โ active โ base).
| Prop | Type | Default | Description |
|---|---|---|---|
routeName |
string |
โ | Target route name (required) |
routeParams |
Params |
{} |
Route parameters |
routeOptions |
NavigationOptions |
{} |
Navigation options (e.g. { replace: true }) |
activeStrict |
boolean |
false |
Exact-match active state (no ancestor match) |
ignoreQueryParams |
boolean |
true |
Ignore query params when computing active state |
color |
string |
โ | Base <Text> color when neither focused nor active |
activeColor |
string |
โ | Color when the route is active |
focusColor |
string |
โ | Color when Ink focus is on this link |
inverse |
boolean |
โ | Base inverse-video |
activeInverse |
boolean |
โ | Inverse-video when active |
focusInverse |
boolean |
โ | Inverse-video when focused |
id |
string |
โ | Passed to useFocus({ id }) for explicit focus targeting |
autoFocus |
boolean |
โ | Passed to useFocus({ autoFocus }) โ first link gets initial focus |
onSelect |
() => void |
โ | Fires on Enter before router.navigate() (telemetry, local UI reactions) |
children |
ReactNode |
โ | Link content โ typically a string, <Text>, or composed <Box>
|
For each style dimension (color, inverse) the resolution is:
focus focusColor ?? activeColor ?? color
active activeColor ?? color
default color
Same rule for inverse / activeInverse / focusInverse. This lets you specify only what differs โ e.g. activeColor="green" alone gives green on active and Ink's default everywhere else.
-
Tab / Shift+Tab โ moves focus between
InkLinks (Ink focus ring). -
Enter โ calls
onSelect?.()thenrouter.navigate(routeName, routeParams, routeOptions). Errors are swallowed (fire-and-forget); useRouterErrorBoundaryor callrouter.navigate()directly for per-click handling. -
Everything else โ passes through to your own
useInputhandlers.
<InkLink
routeName="users.view"
routeParams={{ id: "alice" }}
focusColor="cyan"
activeColor="green"
activeInverse
id="menu-user-alice"
onSelect={() => analytics.track("menu_open", { id: "alice" })}
>
{"Alice"}
</InkLink>InkLink is wrapped in memo() with a custom areInkLinkPropsEqual comparator โ identical strategy to the DOM Link:
-
===for primitives (routeName,activeStrict,ignoreQueryParams, colors, inverse flags,id,autoFocus,onSelect,children). -
shallowEqual(Object.is per key, order-insensitive) forrouteParamsandrouteOptions.
Inline <InkLink routeParams={{ id: "alice" }}> is cheap. Nested objects inside params are not deep-compared โ stabilize with useMemo if necessary (the standard React pattern).
RouteView is absent from /ink. Use useRouteNode("") at the root and switch on route.name or its top segment:
const Body = () => {
const { route } = useRouteNode("");
const top = route?.name.split(".")[0];
switch (top) {
case "home":
return <HomePage />;
case "users":
return <UsersPage />;
case "about":
return <AboutPage />;
default:
return <Text>Unknown route: {top ?? "โ"}</Text>;
}
};Nested routes follow the same pattern per subtree:
const UsersPage = () => {
const { route } = useRouteNode("users");
const id = route?.params.id;
if (route?.name === "users.view" && typeof id === "string") {
return <UserCard id={id} />;
}
return <UserList />;
};Because useRouteNode subscribes to a specific node, UsersPage does not re-render on top-level navigations like home โ about โ only when the users.* subtree enters/leaves or its params change.
useNavigator() returns the stable navigator reference. Combine it with Ink's useInput for hotkeys:
const UserList = () => {
const navigator = useNavigator();
const router = useRouter();
const { route } = useRouteNode("users");
const selectedId = route?.params.id;
useInput((_input, key) => {
if (key.backspace && selectedId !== undefined) {
router.back(); // history-only method provided by the memory plugin
return;
}
if (key.return) {
navigator.navigate("users.view", { id: "alice" }).catch(() => {});
}
});
return /* ... */;
};navigator.navigate() returns a Promise<State> โ you can await it for per-click error handling, or call .catch(() => {}) for fire-and-forget like InkLink does.
Browser history APIs don't exist in a terminal. Use @real-router/memory-plugin โ it provides router.back(), router.forward(), and router.go(n) over a pure in-memory stack.
import { memoryPluginFactory } from "@real-router/memory-plugin";
router.usePlugin(memoryPluginFactory({ maxHistoryLength: 100 }));
await router.start("/");See the memory-plugin page for full options.
RouterErrorBoundary works identically to the DOM adapter โ it subscribes to createDismissableError(router) with no DOM assumptions. Render a fallback <Box> + <Text> alongside your main tree:
import { RouterErrorBoundary } from "@real-router/react/ink";
import { Box, Text } from "ink";
<RouterErrorBoundary
fallback={(error, resetError) => (
<Box borderStyle="round" borderColor="red" paddingX={1}>
<Text color="red">
{error.code}: {error.message}
</Text>
<Text dimColor>Press "r" to dismiss.</Text>
</Box>
)}
onError={(error) => log(`navigation error: ${error.code}`)}
>
<App />
</RouterErrorBoundary>;Auto-resets on the next successful transition. resetError() is also available via the fallback render-prop โ wire it to a key binding with useInput if you want manual dismissal.
| Constraint | Detail |
|---|---|
| React version | 19.2+ (Ink v7 requires it โ there is no /legacy/ink) |
| Ink version | 7.0.0+ (optional peer โ not installed unless you use /ink) |
No <Link>
|
DOM-only primitive; use InkLink
|
No <RouteView>
|
<Activity> in terminal UIs is untested; use useRouteNode("") + switch |
No announceNavigation
|
createRouteAnnouncer depends on document.querySelector and requestAnimationFrame
|
| History API | Unavailable in terminals โ use @real-router/memory-plugin
|
ignoreQueryParams default |
true (same as DOM Link) |
activeClassName โ activeColor/etc. |
DOM styling props replaced by Ink <Text> props |
onClick โ onSelect
|
No mouse events; activation is Enter-while-focused |
Ink's ink-testing-library renders to a string buffer. Enable ANSI colors in the non-TTY test stdout so color assertions work:
// tests/setup.ts
process.env.FORCE_COLOR = "3";Then assert against lastFrame() output. Because vitest stdout is not a TTY, Ink writes ANSI escape sequences literally โ which is what FORCE_COLOR=3 forces.
The full Ink test suite lives alongside the DOM tests in packages/react/tests/functional/.
| Package | Role |
|---|---|
| @real-router/core | Router engine (required) |
| @real-router/memory-plugin | History stack for terminal/SSR/RN environments (required) |
| @real-router/react | Host package โ /ink is one of its three entry points |
- @real-router/react README โ overview of all three entry points
- Link ยท RouterProvider โ DOM counterparts
- useRouteNode ยท useNavigator ยท useRouterTransition โ shared hooks
- memory-plugin โ required history backend for terminal apps
- RouterErrorBoundary โ navigation error handling (works unchanged in Ink)
- View-Agnostic Design โ why a single router powers DOM and terminal alike