React component for calm, terminal-flavored interfaces. It can be a read-only display, a controlled editable surface, a controller-driven terminal, or a small command prompt.
6
stars
109
commits
TypeScript
primary language
Aug 26, 2026
updated
Storybook: smysnk.com/projects/react-retro-screen/storybook
react-retro-display-tty-ansi-ascii is a React component for calm, terminal-flavored interfaces.
It can be a read-only display, a controlled editable surface, a controller-driven terminal,
or a small command prompt without changing visual language. It also understands ANSI styling,
semantic display color modes, and an xterm-checked terminal behavior surface for real control
character playback. It can also project itself onto either dark or light LCD glass without
asking the whole app shell to follow.
Want to see the component running in a real product surface instead of a contained story? Check out ascii.gallery, which uses the retro player and viewer stack to honor the ANSI and ASCII scenes of old through a living gallery of scrolling artwork, fullscreen playback, and preserved retro display behavior.
Latest CI runs: github.com/smysnk/react-retro-display-tty-ansi-ascii/actions/workflows/cicd.yml
Install the package, bring in the shared stylesheet, and start with the simplest thing:
npm install react-retro-display-tty-ansi-ascii
Complete example: this block includes every import required in an existing React app.
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function StatusCard() {
return (
<RetroScreen
mode="value"
value="SYSTEM READY"
color="#97ff9b"
/>
);
}
That is the whole entry point. You hand the component a mode, a value or controller when needed, and let it handle the grid, wrapping, cursor rendering, and terminal feel.
Use touchInput when the display itself should behave like a touch surface and report
grid-aligned cell hits back to the host application.
The touch contract is intentionally simple:
row and col coordinatesrows and cols that were active for that touchdown event for each pressThat makes it a good fit for grid-driven interfaces such as soft terminals, retro games, touch menus, and keypad-like overlays where the host wants to interpret one deliberate press at a time.
See it in action in the live m68k-interpreter touch demo.
Complete example: the rendered <output> makes the emitted coordinates visible to the host.
import { useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function TouchDemo() {
const [lastTouch, setLastTouch] = useState("Tap the screen");
return (
<>
<RetroScreen
mode="terminal"
gridMode="static"
rows={12}
cols={32}
value={[
"┌──────────────────────────────┐",
"│ │",
"│ TOUCH ME │",
"│ │",
"│ ↑ up right → │",
"│ │",
"│ ← left down ↓ │",
"│ │",
"│ │",
"│ │",
"│ │",
"└──────────────────────────────┘"
].join("\n")}
touchInput={{
enabled: true,
onTouchCell: ({ row, col, rows, cols, pointerType }) => {
setLastTouch(`${pointerType} @ ${row},${col} inside ${rows}x${cols}`);
}
}}
/>
<output>{lastTouch}</output>
</>
);
}
The host remains responsible for deciding what a touch means. RetroScreen only handles the overlay, pointer capture, hit testing, and the single-press-until-release behavior.
Use displayPadding when the screen content should sit tighter to the glass or breathe a little
more. The prop accepts:
block and inlinetop, right, bottom, and leftAPI fragment: these calls assume RetroScreen and the package stylesheet are already imported.
<RetroScreen mode="value" value="Tight framing" displayPadding={8} />
<RetroScreen mode="value" value="Room to breathe" displayPadding="1.25rem" />
<RetroScreen
mode="terminal"
displayPadding={{ block: 10, inline: 14 }}
value="measured from the padded screen area"
/>
<RetroScreen
mode="prompt"
displayPadding={{ top: 6, right: 10, bottom: 12, left: 10 }}
/>
RetroScreen also shows a focus glow around the shell by default so editable, prompt, and terminal surfaces clearly read as active. Disable it when you want a quieter shell:
API fragment:
<RetroScreen mode="terminal" focusGlow={false} />
Use displayFrame={false} when the glass itself should stay visible but the outer shell chrome
should drop away. This is especially useful for fullscreen mobile artwork, embedded canvases, or
layouts where the host surface already provides the surrounding frame.
API fragment: this assumes the Getting Started imports.
<RetroScreen
mode="terminal"
value="frameless but still live"
displayFrame={false}
displayPadding={0}
/>
Frameless mode removes the bezel, inset border, outer radius, and shell shadow while keeping the same display surface, grid, ANSI rendering, and optional touch overlay behavior.
Because rows and columns are measured from the visible screen area, tighter padding yields a denser grid and looser padding yields fewer cells.
Use resizable when the panel itself should be draggable instead of only responding to layout
changes around it. The live Storybook demo now shows a visible mouse cursor grabbing the real
handles, including the optional leading-edge handles, so the docs match the shipped interaction.
Complete example: the Storybook capture adds a scripted mouse only to demonstrate the real handles; the component below is everything the application needs for user-driven resizing.
import { useState } from "react";
import {
RetroScreen,
type RetroScreenGeometry
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function ResizablePanel() {
const [geometry, setGeometry] = useState<RetroScreenGeometry | null>(null);
return (
<RetroScreen
mode="terminal"
resizable="both"
resizableLeadingEdges
displayPadding={{ block: 12, inline: 14 }}
value={[
"All resize handles are live here.",
"",
"Drag left, right, top, bottom, or any corner.",
geometry
? `grid: ${geometry.cols} cols x ${geometry.rows} rows`
: "grid: measuring"
].join("\n")}
onGeometryChange={setGeometry}
/>
);
}
Reach for:
resizable="width" when the panel should only stretch sidewaysresizable="height" when it should stack or collapse verticallyresizable or resizable="both" for freeform terminal panesresizableLeadingEdges when left, top, and top-left handles should join the same interaction surfaceUse displaySurfaceMode when the LCD itself should read like a light instrument panel or a
dark night-ops surface. This is separate from the host page theme, so the same ANSI-rich
terminal content can sit inside bright docs, dark dashboards, or a side-by-side comparison view.
Complete multi-file example: the synchronized typing, both host cards, responsive layout, and
cleanup are in examples/readme/light-dark-surfaces/.
API fragment: the two calls below isolate the light and dark display props without repeating the host layout or animation engine.
<RetroScreen
mode="terminal"
value={[
"\u001b[1mLIGHT SURFACE\u001b[0m",
"\u001b[38;5;160mR\u001b[38;5;214mA\u001b[38;5;190mI\u001b[38;5;45mN\u001b[38;5;39mB\u001b[38;5;141mO\u001b[38;5;201mW\u001b[0m contrast check",
"\u001b[38;2;194;94;0mamber\u001b[0m \u001b[38;2;0;104;181mblue\u001b[0m \u001b[38;2;108;40;148mviolet\u001b[0m"
].join("\n")}
displaySurfaceMode="light"
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
<RetroScreen
mode="terminal"
value={[
"\u001b[1mDARK SURFACE\u001b[0m",
"\u001b[38;5;160mR\u001b[38;5;214mA\u001b[38;5;190mI\u001b[38;5;45mN\u001b[38;5;39mB\u001b[38;5;141mO\u001b[38;5;201mW\u001b[0m contrast check",
"\u001b[38;2;255;176;86mamber\u001b[0m \u001b[38;2;102;198;255mblue\u001b[0m \u001b[38;2;214;145;255mviolet\u001b[0m"
].join("\n")}
displaySurfaceMode="dark"
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
Reach for displaySurfaceMode="light" when the LCD should feel like paper, enamel, or a sunlit
instrument panel. Keep displaySurfaceMode="dark" for the classic terminal-glass look. The
same ANSI palette will still be remapped for readable contrast against each surface.
Examples are labeled deliberately: a Complete example is copy-pasteable in an existing React app, a Complete multi-file example links every required source and asset, and an API fragment isolates one concept while linking back to a complete setup.
Use mode="value" when the display is just there to speak.
Complete example: this includes the typing cadence shown by the animated capture.
import { useEffect, useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const MESSAGE = "LINK STABLE\nAwaiting operator input.";
export function QuietOutput() {
const [value, setValue] = useState("");
useEffect(() => {
const timers: number[] = [];
let nextAt = 220;
for (let index = 1; index <= MESSAGE.length; index += 1) {
const character = MESSAGE[index - 1];
timers.push(
window.setTimeout(() => {
setValue(MESSAGE.slice(0, index));
}, nextAt)
);
nextAt += character === "\n" ? 260 : 92;
}
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, []);
return <RetroScreen mode="value" value={value} color="#97ff9b" />;
}
Use a controller when the display should reveal text over time and the cadence matters as much as the message.
Complete example: this is the four-message sequence and cadence used by the capture.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const SIGNAL_PHRASES = [
{ text: "Wake up, Neo...", pauseAfter: 1360 },
{ text: "The Matrix has you...", pauseAfter: 1220 },
{ text: "Follow the white rabbit.", pauseAfter: 1220 },
{ text: "Knock, knock, Neo.", pauseAfter: 0 }
] as const;
export function WhiteRabbitSignal() {
const [controller] = useState(() =>
createRetroScreenController({
rows: 5,
cols: 34,
cursorMode: "solid"
})
);
useEffect(() => {
controller.reset();
controller.resize(5, 34);
controller.setCursorMode("solid");
controller.setCursorVisible(true);
const timers: number[] = [];
let nextAt = 380;
const getTypingDelay = (character: string, nextCharacter?: string) => {
if (character === " ") return 86;
if (character === ",") return 214;
if (character === "." && nextCharacter === ".") return 70;
if (character === ".") return 320;
if (/[A-Z]/u.test(character)) return 154;
return 122;
};
const scheduleTypedWrite = (text: string) => {
const characters = Array.from(text);
for (let index = 0; index < characters.length; index += 1) {
const character = characters[index]!;
timers.push(
window.setTimeout(() => {
controller.write(character);
}, nextAt)
);
nextAt += getTypingDelay(character, characters[index + 1]);
}
};
const scheduleReset = () => {
timers.push(
window.setTimeout(() => {
controller.reset();
controller.resize(5, 34);
controller.setCursorMode("solid");
controller.setCursorVisible(true);
}, nextAt)
);
nextAt += 360;
};
SIGNAL_PHRASES.forEach(({ text, pauseAfter }, index) => {
if (index > 0) {
scheduleReset();
}
scheduleTypedWrite(text);
nextAt += pauseAfter;
});
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
color="#97ff9b"
displayPadding={{ block: 14, inline: 16 }}
style={{ minHeight: "212px" }}
/>
);
}
This is the same four-message sequence, per-character cadence, screen clearing, and display styling used by the Storybook demo.
The effect keeps a seeded glyph field fixed on the grid while independently moving brightness heads illuminate each column. Five per-cell RGB shades form the trails, and small glyph mutations near each active trail create motion without scrolling strings through the terminal.
Complete multi-file example and implementation walkthrough: the seeded glyph field, column
engine, distance-based shading, selective glyph mutation, cell rasterization, animation loop,
styling, tuning controls, and cleanup are documented in
examples/readme/matrix-code-rain/.
The runnable entry point is:
import { MatrixCodeRainScreen } from "./MatrixCodeRain";
export function App() {
return <MatrixCodeRainScreen />;
}
This replaces the previous empty-controller fragment, which configured a screen but never supplied glyphs or animation frames.
Turn on editable when you want the same surface to behave like a controlled input.
Complete example: the capture automatically types into this same controlled surface; that capture choreography is not required by the application.
import { useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function EditableDrafting() {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState("Nothing submitted yet.");
return (
<>
<RetroScreen
mode="value"
value={value}
editable
autoFocus
color="#97ff9b"
cursorMode="solid"
placeholder="Write a line, breathe, then press Enter."
onChange={setValue}
onSubmit={(nextValue) => {
setSubmitted(nextValue.length > 0 ? nextValue : "(empty)");
}}
/>
<output>Last Enter press: {submitted}</output>
</>
);
}
Use a controller when the display should follow external writes over time.
Complete example: controller construction, all timed writes, and cleanup are included.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const OUTPUT_SEQUENCE = [
{
at: 260,
text: "BOOT react-retro-display-tty-ansi-ascii",
appendNewline: true
},
{
at: 1120,
text: "CHECK controller attached",
appendNewline: true
},
{
at: 2080,
text: "\u001b[1mREADY\u001b[0m ansi parser online",
appendNewline: true
},
{
at: 3140,
text: "\u001b[2msoft notes stay readable without stealing focus\u001b[0m",
appendNewline: true
},
{
at: 4320,
text: "\u001b[7mLIVE\u001b[0m output keeps pace with external writes",
appendNewline: false
}
] as const;
export function TerminalOutput() {
const [controller] = useState(() =>
createRetroScreenController({
rows: 9,
cols: 46,
cursorMode: "solid"
})
);
useEffect(() => {
controller.reset();
controller.setCursorMode("solid");
controller.setCursorVisible(true);
const timers = OUTPUT_SEQUENCE.map(({ at, text, appendNewline }) =>
window.setTimeout(() => {
if (appendNewline) {
controller.writeln(text);
} else {
controller.write(text);
}
}, at)
);
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
color="#97ff9b"
/>
);
}
If you already have a terminal-like buffer as a string, mode="terminal" also accepts value
or initialBuffer.
RetroScreen can also act as the browser-side surface for a real TTY session. The transport
stays outside the component, while the component handles geometry, keyboard capture, paste,
focus reporting, mouse reporting, alternate-screen rendering, title updates, and bell metadata.
The demo sequence is recorded from a live shell session and stages the kind of workload this
bridge is built for: a live-updating top session, a fullscreen vim pass, and a nano
screen with help bars and cursor-owned chrome.
Complete multi-file example: the browser client is synchronized below. The required
node-pty server, its security boundary, and the full source tree are documented in
examples/readme/live-tty-websocket-bridge/.
import { useMemo } from "react";
import {
RetroScreen,
createRetroScreenWebSocketSession
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function LiveShell({
url = "ws://127.0.0.1:8787",
cwd = "/workspace"
}: {
url?: string;
cwd?: string;
}) {
const session = useMemo(
() =>
createRetroScreenWebSocketSession({
url,
openPayload: {
cwd,
term: "xterm-256color"
}
}),
[cwd, url]
);
return (
<RetroScreen
mode="terminal"
session={session}
closeSessionOnUnmount
autoFocus
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
);
}
For local development, the repo includes a reference node-pty websocket backend:
yarn tty:server
By default, that example server starts a themed demo shell rooted at ~/tty-demo with the
prompt operator@retro:~/tty-demo$, so the live story and the recorded bridge demo share the
same shell framing.
There is also a dedicated Storybook story for this path. It now defaults to the local example
server at ws://127.0.0.1:8787, so if yarn tty:server is already running you can open the
Live Tty Terminal Bridge story directly without adding any extra query params.
If you want to override the target or the open payload, use:
window.__RETRO_SCREEN_TTY_DEMO__ = {
url: "ws://127.0.0.1:8787",
openPayload: {
cwd: "/workspace",
term: "xterm-256color"
}
};
The example server now supports token checks, origin checks, idle timeouts, payload-size limits, and optional command/cwd/env override restrictions. See examples/node-tty-websocket-server/README.md for the available flags.
Use mode="prompt" when the interface should feel like a guided shell.
Complete example: command latency, accepted responses, and rejection behavior are included.
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const wait = (duration: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, duration);
});
export function PromptInteraction() {
return (
<RetroScreen
mode="prompt"
autoFocus
color="#97ff9b"
cursorMode="solid"
promptChar="$"
acceptanceText="READY"
rejectionText="DENIED"
onCommand={async (command) => {
await wait(420);
switch (command.trim()) {
case "status":
return {
accepted: true,
response: ["grid synced", "cursor stable", "story ready"]
};
case "scan":
return {
accepted: true,
response: ["signal sweep", "north: clear", "south: clear"]
};
default:
return {
accepted: false,
response: "unknown command"
};
}
}}
/>
);
}
The Apple DOS 3.3 demo sits nicely in the same family when you want the interface to feel boot-first and command-led instead of form-like.
Under the hood, the Apple II shell is built as a small userland runtime on top of mode="terminal" instead of trying to force the one-shot prompt path to behave like BASIC. The Storybook surface feeds keyboard bytes into a session store that owns the boot transcript, prompt state, numbered program lines, and shell mode transitions. That session then hands stored BASIC lines to a parser/interpreter pair that supports immediate commands like LIST, NEW, and RUN, plus resumable program execution for INPUT and BREAK.
Implementation notes:
src/stories/Apple2Basic.stories.tsx wires RetroScreen to the Apple session and powers both the interactive story and deterministic capture variants.src/stories/apple2-basic/apple2-basic-shell-session.ts manages uppercase input, line editing, transcript updates, prompt switching, and asynchronous runner scheduling.src/stories/apple2-basic/apple2-basic-parser.ts parses immediate commands, numbered program lines, and the supported BASIC statement/expression subset.src/stories/apple2-basic/apple2-basic-interpreter.ts compiles stored lines into a resumable runner so RUN, INPUT, GOTO, IF ... THEN, END, and CTRL+C / BREAK behave like a shell instead of a static transcript.Complete multi-file example: the required module map and run command are collected in
examples/readme/apple2-basic/.
Terminal and prompt surfaces now expose a real display buffer instead of only showing the live viewport. That means you can scroll back through recent output, inspect older lines, then return to the live tail when you are ready to follow the stream again.
Built-in behavior:
PageUp and PageDown move through the display bufferEnd returns terminal mode to the live tailUse bufferSize to control how many rows of history the component-managed terminal or prompt
surface keeps, and defaultAutoFollow if you want the view to start detached from the tail.
API fragment: use the complete Terminal Output example above for imports and controller lifecycle.
<RetroScreen
mode="terminal"
bufferSize={400}
defaultAutoFollow
value={[
"line-01 warm boot",
"line-02 telemetry stable",
"line-03 waiting for operator"
].join("\n")}
/>
If you are driving the component with your own controller, configure the underlying buffer size on the controller itself:
const controller = createRetroScreenController({
rows: 9,
cols: 46,
scrollback: 400
});
<RetroScreen mode="terminal" controller={controller} />
The browser suite now covers this path directly, including paging, wheel scrolling, anchored scrollback while new lines arrive, and auto-follow recovery back to the live tail.
When rows and columns matter to the program inside the display, listen to onGeometryChange,
turn that measurement into a terminal-style reply, and redraw from the reported size. The demo
below simulates a terminal app issuing CSI 18 t, receiving CSI 8;<rows>;<cols>t, then
repainting a full border and centered dimensions every time the panel resizes. The current demo
shows a visible cursor dragging the real resize handles, pauses if you intervene manually, and
still cycles through tight screen padding, multiple border alphabets, oversized glyph styles,
plus every monochrome and ANSI display mode so the same terminal program can be watched under
different visual projections.
Complete multi-file example: the real redrawBorderAndMetrics helper, controller lifecycle,
geometry deduplication, terminal reply, and runnable component are in
examples/readme/auto-resize-probe/. The scripted mouse
and visual-variant cycling in the capture are Storybook presentation choreography.
The runnable entry point is:
import { AutoResizeProbe } from "./AutoResizeProbe";
export function App() {
return <AutoResizeProbe />;
}
This is useful for terminal-style dashboards, resize-aware prompts, or retro UIs that need to
center content, draw frames, or adapt layouts from the actual LCD grid instead of from CSS alone.
It is also a good place to project displayColorMode changes when you want the terminal behavior
to stay fixed while the display mood shifts around it.
Use displayColorMode to decide how semantic terminal color should be projected onto the screen.
The phosphor modes keep the retro LCD personality even when the source emits ANSI color. The ANSI
modes preserve more of the source terminal palette.
Available modes:
phosphor-greenphosphor-amberphosphor-iceansi-vgaansi-classicansi-extendedComplete example:
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const VALUE = [
"\u001b[31mALERT\u001b[0m \u001b[32mlink stable\u001b[0m",
"\u001b[38;5;196mindexed 196\u001b[0m from the 256-color palette",
"\u001b[38;2;255;180;120mtruecolor 255,180,120\u001b[0m"
].join("\n");
export function DisplayColorModes() {
return (
<RetroScreen
mode="terminal"
displayColorMode="ansi-extended"
value={VALUE}
/>
);
}
Reach for ansi-vga when you need the exact IBM PC/DOS 16-color palette used by ANSI art,
ansi-classic when you want the softened 16-color terminal profile, or
ansi-extended when 256-color and truecolor cells should survive all the way to the display.
The Midjourney galaxy demo uses the same RetroScreen cell pipeline to start from a drifting text field and pull it into a bright spiral structure.
Complete multi-file example: the text grid, particle model, spiral rasterizer, animation loop,
and cleanup are in
examples/readme/midjourney-vortex/.
Open it here: smysnk.com/projects/react-retro-screen/storybook/?path=/story/retroscreen--midjourney-vortex
Storybook now includes a dedicated Bad Apple ANSI demo that loads the real ANSI release,
decodes the original IBM VGA / CP437 bytes outside the display component, and then feeds those
bytes into the reusable RetroScreenAnsiPlayer wrapper. The player incrementally materializes
one mutable 80 x 25 terminal state at the configured baud while the parent owns byte loading and streaming. The
demo uses the full BADAPPLE.ANS payload, not a trimmed excerpt, and now uses the current
retained bitmap canvas backend with IBM VGA 8x16 glyphs.
The README clip is a 30-second capture of the real ANSI-art playback path, not a separate video renderer.
Credit for the original ANSI release goes to Mistigris.
Open it here: smysnk.com/projects/react-retro-screen/storybook/?path=/story/retroscreen-display-buffer--bad-apple-ansi
The Storybook demo is backed by the bundled asset at src/stories/assets/bad-apple.ans.
Complete multi-file example: loading, SAUCE parsing, byte splitting, error handling, player
wiring, asset requirements, and attribution are in
examples/readme/ansi-art-playback/.
API fragment: once the complete example has loaded asset, the key player props are:
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={25}
cols={80}
baud={14_400}
complete
loop
canvasAccessibleText={false}
displayColorMode="ansi-classic"
displayGlyphMode="ibm-vga-8x16"
displayLayoutMode="fit-width"
displayPadding={0}
displayScanlines={false}
renderBackend="canvas"
style={{ width: "100%" }}
/>
Use RetroScreenAnsiPlayer when a parent is responsible for supplying ANSI bytes or byte chunks,
including incremental streams. Keep the asset loading outside the display component, pass the
native rows and cols so the art is not reflowed. Playback state reports processedBytes,
totalBytes, status, and estimatedDurationMs; snapshots use drain to reach the same engine's
final state immediately. For ANSI art, the most useful display props
are:
displayCharacterSizingMode="font": lets browser font rendering own glyph sizing instead of
forcing explicit cell dimensions.displayFontSizingMode="fit-cols": chooses the largest integer font size that still fits the
requested columns horizontally.displayLayoutMode="fit-width": lets the component fill the available width and derive its
height from the resolved grid.displayScanlines={false} and disableCellRowScale: opt out of CRT-style effects that can
create unwanted seams in ANSI art.Large, read-only ANSI documents can opt into the bitmap canvas backend. It rasterizes CP437 glyphs
into retained ImageData, updates only changed cells during playback, and divides tall documents
into canvases no taller than 256 text rows. In explicit canvas mode no .retro-screen__line or
.retro-screen__cell elements are mounted.
API fragment: asset is loaded by the complete ANSI Art Playback example linked above.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
displayColorMode="ansi-vga"
displayGlyphMode="ibm-vga-8x16"
renderBackend="canvas"
canvasAccessibilityLabel={`${asset.title} by ${asset.author}`}
/>
renderBackend="canvas" requires a bitmap displayGlyphMode; font rendering and interactive
value, terminal, prompt, and editor surfaces resolve to the DOM backend. If the browser cannot
create a 2D canvas context the component also falls back to DOM. Omit renderBackend to preserve
the legacy rendering behavior, or use renderBackend="dom" to force rows and cells. Canvas mode
includes one visually hidden plain-text node by default; set canvasAccessibleText={false} when a
stable accessible label is preferable for animated artwork.
BADAPPLE.ANS uses lots of upper-half and lower-half block characters (▀ / ▄), so the demo
disables scanlines and rasterizes the IBM VGA glyph data directly.
RetroScreenAnsiPlayer can also render a fixed viewport over a larger ANSI buffer. This is useful
for gallery viewers, panning surfaces, and giant sparse ANSI files where the parent wants to keep a
stable 80 x 25 window while the underlying source geometry remains larger.
API fragment: this reuses the loader and asset state from the complete ANSI example.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
baud={14_400}
complete={asset.complete}
loop={asset.complete}
viewportRows={25}
viewportCols={80}
viewportRowOffset={rowOffset}
viewportColOffset={colOffset}
displayCharacterSizingMode="font"
displayFontSizingMode="fit-cols"
displayLayoutMode="fit-width"
displayPadding={0}
displayScanlines={false}
disableCellRowScale
/>
Set viewportFollowMode="cursor" when the viewport should remain fixed until the parser cursor
reaches its final visible row, then follow new output without discarding the rows above it. Combine
it with scrollMode="canvas" so the complete source document remains available for later panning,
export, or a full-document reveal. The default viewportFollowMode="fixed" preserves explicit
viewportRowOffset behavior.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
viewportRows={25}
viewportFollowMode="cursor"
scrollMode="canvas"
renderBackend="canvas"
/>
The playback callback reports source geometry and byte progress, so a parent can keep status UI in sync with the rendered stream:
<RetroScreenAnsiPlayer
// ...
onPlaybackStateChange={(state) => {
console.log(state.sourceRows, state.sourceCols);
console.log(state.processedBytes, state.totalBytes, state.status);
}}
/>
The terminal path is now tested against an xterm oracle and can faithfully replay real control character effects like carriage return rewrites, erase-in-line, scroll regions, insert-line updates, ANSI 16-color, indexed 256-color, and truecolor output.
Complete example: writes run inside a component effect and timers are cleaned up on unmount.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function ControlCharacterReplay() {
const [controller] = useState(() =>
createRetroScreenController({ rows: 6, cols: 34, cursorMode: "solid" })
);
useEffect(() => {
controller.reset();
controller.setCursorVisible(true);
const writes = [
"Downloading fixtures... 12%",
"\rDownloading fixtures... 73%",
"\r\u001b[32mDownloaded fixtures.\u001b[0m\u001b[K\r\n",
"\u001b[2;6r",
"\u001b[6;1H\u001b[L\u001b[38;2;255;180;120mrecorded regression fixture\u001b[0m"
];
const timers = writes.map((value, index) =>
window.setTimeout(() => {
controller.write(value);
}, 280 + index * 720)
);
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
displayColorMode="ansi-extended"
/>
);
}
The same trace fixtures used in Storybook are also exercised in the terminal verification layers:
yarn check:ansi-display-report
yarn test:e2e:ansi-display
yarn test:conformance
yarn test:tty
yarn test:e2e:tty
yarn test:e2e
The TTY-specific checks skip themselves automatically in environments where node-pty cannot
allocate a TTY session, but they run normally on TTY-capable developer machines and CI runners.
The authoritative display-facing ANSI ledger lives in
docs/ansi-display-support-matrix.md. It is generated
from the conformance matrix source and verified in CI so the published status stays in sync with
the implementation.
The component is intentionally small at the edge:
mode="value" when all you need is a beautiful terminal-like readout.editable if the content should be controlled by React state.mode="terminal" when output is driven by a stream or controller.mode="prompt" when commands and responses should live in one transcript.onGeometryChange if rows and columns matter to the rest of your app.Storybook now acts as the living demo surface for the package. It includes stories for the main user journeys:
Run it locally with:
npm install
npm run storybook
Releases from main publish through GitHub OIDC without a persistent npm token. Maintainers can find the one-time npm package configuration and migration checklist in npm trusted publishing.
npm install
npm run build
npm run test
npm run test:unit
npm run storybook
Useful extra checks:
yarn test:tty
yarn test:e2e:tty
yarn perf:terminal
109 commits
TypeScript
74.2%
JavaScript
22.2%
CSS
3.1%
React component for calm, terminal-flavored interfaces. It can be a read-only display, a controlled editable surface, a controller-driven terminal, or a small command prompt.
6
stars
109
commits
TypeScript
primary language
Aug 26, 2026
updated
Storybook: smysnk.com/projects/react-retro-screen/storybook
react-retro-display-tty-ansi-ascii is a React component for calm, terminal-flavored interfaces.
It can be a read-only display, a controlled editable surface, a controller-driven terminal,
or a small command prompt without changing visual language. It also understands ANSI styling,
semantic display color modes, and an xterm-checked terminal behavior surface for real control
character playback. It can also project itself onto either dark or light LCD glass without
asking the whole app shell to follow.
Want to see the component running in a real product surface instead of a contained story? Check out ascii.gallery, which uses the retro player and viewer stack to honor the ANSI and ASCII scenes of old through a living gallery of scrolling artwork, fullscreen playback, and preserved retro display behavior.
Latest CI runs: github.com/smysnk/react-retro-display-tty-ansi-ascii/actions/workflows/cicd.yml
Install the package, bring in the shared stylesheet, and start with the simplest thing:
npm install react-retro-display-tty-ansi-ascii
Complete example: this block includes every import required in an existing React app.
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function StatusCard() {
return (
<RetroScreen
mode="value"
value="SYSTEM READY"
color="#97ff9b"
/>
);
}
That is the whole entry point. You hand the component a mode, a value or controller when needed, and let it handle the grid, wrapping, cursor rendering, and terminal feel.
Use touchInput when the display itself should behave like a touch surface and report
grid-aligned cell hits back to the host application.
The touch contract is intentionally simple:
row and col coordinatesrows and cols that were active for that touchdown event for each pressThat makes it a good fit for grid-driven interfaces such as soft terminals, retro games, touch menus, and keypad-like overlays where the host wants to interpret one deliberate press at a time.
See it in action in the live m68k-interpreter touch demo.
Complete example: the rendered <output> makes the emitted coordinates visible to the host.
import { useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function TouchDemo() {
const [lastTouch, setLastTouch] = useState("Tap the screen");
return (
<>
<RetroScreen
mode="terminal"
gridMode="static"
rows={12}
cols={32}
value={[
"┌──────────────────────────────┐",
"│ │",
"│ TOUCH ME │",
"│ │",
"│ ↑ up right → │",
"│ │",
"│ ← left down ↓ │",
"│ │",
"│ │",
"│ │",
"│ │",
"└──────────────────────────────┘"
].join("\n")}
touchInput={{
enabled: true,
onTouchCell: ({ row, col, rows, cols, pointerType }) => {
setLastTouch(`${pointerType} @ ${row},${col} inside ${rows}x${cols}`);
}
}}
/>
<output>{lastTouch}</output>
</>
);
}
The host remains responsible for deciding what a touch means. RetroScreen only handles the overlay, pointer capture, hit testing, and the single-press-until-release behavior.
Use displayPadding when the screen content should sit tighter to the glass or breathe a little
more. The prop accepts:
block and inlinetop, right, bottom, and leftAPI fragment: these calls assume RetroScreen and the package stylesheet are already imported.
<RetroScreen mode="value" value="Tight framing" displayPadding={8} />
<RetroScreen mode="value" value="Room to breathe" displayPadding="1.25rem" />
<RetroScreen
mode="terminal"
displayPadding={{ block: 10, inline: 14 }}
value="measured from the padded screen area"
/>
<RetroScreen
mode="prompt"
displayPadding={{ top: 6, right: 10, bottom: 12, left: 10 }}
/>
RetroScreen also shows a focus glow around the shell by default so editable, prompt, and terminal surfaces clearly read as active. Disable it when you want a quieter shell:
API fragment:
<RetroScreen mode="terminal" focusGlow={false} />
Use displayFrame={false} when the glass itself should stay visible but the outer shell chrome
should drop away. This is especially useful for fullscreen mobile artwork, embedded canvases, or
layouts where the host surface already provides the surrounding frame.
API fragment: this assumes the Getting Started imports.
<RetroScreen
mode="terminal"
value="frameless but still live"
displayFrame={false}
displayPadding={0}
/>
Frameless mode removes the bezel, inset border, outer radius, and shell shadow while keeping the same display surface, grid, ANSI rendering, and optional touch overlay behavior.
Because rows and columns are measured from the visible screen area, tighter padding yields a denser grid and looser padding yields fewer cells.
Use resizable when the panel itself should be draggable instead of only responding to layout
changes around it. The live Storybook demo now shows a visible mouse cursor grabbing the real
handles, including the optional leading-edge handles, so the docs match the shipped interaction.
Complete example: the Storybook capture adds a scripted mouse only to demonstrate the real handles; the component below is everything the application needs for user-driven resizing.
import { useState } from "react";
import {
RetroScreen,
type RetroScreenGeometry
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function ResizablePanel() {
const [geometry, setGeometry] = useState<RetroScreenGeometry | null>(null);
return (
<RetroScreen
mode="terminal"
resizable="both"
resizableLeadingEdges
displayPadding={{ block: 12, inline: 14 }}
value={[
"All resize handles are live here.",
"",
"Drag left, right, top, bottom, or any corner.",
geometry
? `grid: ${geometry.cols} cols x ${geometry.rows} rows`
: "grid: measuring"
].join("\n")}
onGeometryChange={setGeometry}
/>
);
}
Reach for:
resizable="width" when the panel should only stretch sidewaysresizable="height" when it should stack or collapse verticallyresizable or resizable="both" for freeform terminal panesresizableLeadingEdges when left, top, and top-left handles should join the same interaction surfaceUse displaySurfaceMode when the LCD itself should read like a light instrument panel or a
dark night-ops surface. This is separate from the host page theme, so the same ANSI-rich
terminal content can sit inside bright docs, dark dashboards, or a side-by-side comparison view.
Complete multi-file example: the synchronized typing, both host cards, responsive layout, and
cleanup are in examples/readme/light-dark-surfaces/.
API fragment: the two calls below isolate the light and dark display props without repeating the host layout or animation engine.
<RetroScreen
mode="terminal"
value={[
"\u001b[1mLIGHT SURFACE\u001b[0m",
"\u001b[38;5;160mR\u001b[38;5;214mA\u001b[38;5;190mI\u001b[38;5;45mN\u001b[38;5;39mB\u001b[38;5;141mO\u001b[38;5;201mW\u001b[0m contrast check",
"\u001b[38;2;194;94;0mamber\u001b[0m \u001b[38;2;0;104;181mblue\u001b[0m \u001b[38;2;108;40;148mviolet\u001b[0m"
].join("\n")}
displaySurfaceMode="light"
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
<RetroScreen
mode="terminal"
value={[
"\u001b[1mDARK SURFACE\u001b[0m",
"\u001b[38;5;160mR\u001b[38;5;214mA\u001b[38;5;190mI\u001b[38;5;45mN\u001b[38;5;39mB\u001b[38;5;141mO\u001b[38;5;201mW\u001b[0m contrast check",
"\u001b[38;2;255;176;86mamber\u001b[0m \u001b[38;2;102;198;255mblue\u001b[0m \u001b[38;2;214;145;255mviolet\u001b[0m"
].join("\n")}
displaySurfaceMode="dark"
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
Reach for displaySurfaceMode="light" when the LCD should feel like paper, enamel, or a sunlit
instrument panel. Keep displaySurfaceMode="dark" for the classic terminal-glass look. The
same ANSI palette will still be remapped for readable contrast against each surface.
Examples are labeled deliberately: a Complete example is copy-pasteable in an existing React app, a Complete multi-file example links every required source and asset, and an API fragment isolates one concept while linking back to a complete setup.
Use mode="value" when the display is just there to speak.
Complete example: this includes the typing cadence shown by the animated capture.
import { useEffect, useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const MESSAGE = "LINK STABLE\nAwaiting operator input.";
export function QuietOutput() {
const [value, setValue] = useState("");
useEffect(() => {
const timers: number[] = [];
let nextAt = 220;
for (let index = 1; index <= MESSAGE.length; index += 1) {
const character = MESSAGE[index - 1];
timers.push(
window.setTimeout(() => {
setValue(MESSAGE.slice(0, index));
}, nextAt)
);
nextAt += character === "\n" ? 260 : 92;
}
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, []);
return <RetroScreen mode="value" value={value} color="#97ff9b" />;
}
Use a controller when the display should reveal text over time and the cadence matters as much as the message.
Complete example: this is the four-message sequence and cadence used by the capture.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const SIGNAL_PHRASES = [
{ text: "Wake up, Neo...", pauseAfter: 1360 },
{ text: "The Matrix has you...", pauseAfter: 1220 },
{ text: "Follow the white rabbit.", pauseAfter: 1220 },
{ text: "Knock, knock, Neo.", pauseAfter: 0 }
] as const;
export function WhiteRabbitSignal() {
const [controller] = useState(() =>
createRetroScreenController({
rows: 5,
cols: 34,
cursorMode: "solid"
})
);
useEffect(() => {
controller.reset();
controller.resize(5, 34);
controller.setCursorMode("solid");
controller.setCursorVisible(true);
const timers: number[] = [];
let nextAt = 380;
const getTypingDelay = (character: string, nextCharacter?: string) => {
if (character === " ") return 86;
if (character === ",") return 214;
if (character === "." && nextCharacter === ".") return 70;
if (character === ".") return 320;
if (/[A-Z]/u.test(character)) return 154;
return 122;
};
const scheduleTypedWrite = (text: string) => {
const characters = Array.from(text);
for (let index = 0; index < characters.length; index += 1) {
const character = characters[index]!;
timers.push(
window.setTimeout(() => {
controller.write(character);
}, nextAt)
);
nextAt += getTypingDelay(character, characters[index + 1]);
}
};
const scheduleReset = () => {
timers.push(
window.setTimeout(() => {
controller.reset();
controller.resize(5, 34);
controller.setCursorMode("solid");
controller.setCursorVisible(true);
}, nextAt)
);
nextAt += 360;
};
SIGNAL_PHRASES.forEach(({ text, pauseAfter }, index) => {
if (index > 0) {
scheduleReset();
}
scheduleTypedWrite(text);
nextAt += pauseAfter;
});
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
color="#97ff9b"
displayPadding={{ block: 14, inline: 16 }}
style={{ minHeight: "212px" }}
/>
);
}
This is the same four-message sequence, per-character cadence, screen clearing, and display styling used by the Storybook demo.
The effect keeps a seeded glyph field fixed on the grid while independently moving brightness heads illuminate each column. Five per-cell RGB shades form the trails, and small glyph mutations near each active trail create motion without scrolling strings through the terminal.
Complete multi-file example and implementation walkthrough: the seeded glyph field, column
engine, distance-based shading, selective glyph mutation, cell rasterization, animation loop,
styling, tuning controls, and cleanup are documented in
examples/readme/matrix-code-rain/.
The runnable entry point is:
import { MatrixCodeRainScreen } from "./MatrixCodeRain";
export function App() {
return <MatrixCodeRainScreen />;
}
This replaces the previous empty-controller fragment, which configured a screen but never supplied glyphs or animation frames.
Turn on editable when you want the same surface to behave like a controlled input.
Complete example: the capture automatically types into this same controlled surface; that capture choreography is not required by the application.
import { useState } from "react";
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function EditableDrafting() {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState("Nothing submitted yet.");
return (
<>
<RetroScreen
mode="value"
value={value}
editable
autoFocus
color="#97ff9b"
cursorMode="solid"
placeholder="Write a line, breathe, then press Enter."
onChange={setValue}
onSubmit={(nextValue) => {
setSubmitted(nextValue.length > 0 ? nextValue : "(empty)");
}}
/>
<output>Last Enter press: {submitted}</output>
</>
);
}
Use a controller when the display should follow external writes over time.
Complete example: controller construction, all timed writes, and cleanup are included.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const OUTPUT_SEQUENCE = [
{
at: 260,
text: "BOOT react-retro-display-tty-ansi-ascii",
appendNewline: true
},
{
at: 1120,
text: "CHECK controller attached",
appendNewline: true
},
{
at: 2080,
text: "\u001b[1mREADY\u001b[0m ansi parser online",
appendNewline: true
},
{
at: 3140,
text: "\u001b[2msoft notes stay readable without stealing focus\u001b[0m",
appendNewline: true
},
{
at: 4320,
text: "\u001b[7mLIVE\u001b[0m output keeps pace with external writes",
appendNewline: false
}
] as const;
export function TerminalOutput() {
const [controller] = useState(() =>
createRetroScreenController({
rows: 9,
cols: 46,
cursorMode: "solid"
})
);
useEffect(() => {
controller.reset();
controller.setCursorMode("solid");
controller.setCursorVisible(true);
const timers = OUTPUT_SEQUENCE.map(({ at, text, appendNewline }) =>
window.setTimeout(() => {
if (appendNewline) {
controller.writeln(text);
} else {
controller.write(text);
}
}, at)
);
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
color="#97ff9b"
/>
);
}
If you already have a terminal-like buffer as a string, mode="terminal" also accepts value
or initialBuffer.
RetroScreen can also act as the browser-side surface for a real TTY session. The transport
stays outside the component, while the component handles geometry, keyboard capture, paste,
focus reporting, mouse reporting, alternate-screen rendering, title updates, and bell metadata.
The demo sequence is recorded from a live shell session and stages the kind of workload this
bridge is built for: a live-updating top session, a fullscreen vim pass, and a nano
screen with help bars and cursor-owned chrome.
Complete multi-file example: the browser client is synchronized below. The required
node-pty server, its security boundary, and the full source tree are documented in
examples/readme/live-tty-websocket-bridge/.
import { useMemo } from "react";
import {
RetroScreen,
createRetroScreenWebSocketSession
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function LiveShell({
url = "ws://127.0.0.1:8787",
cwd = "/workspace"
}: {
url?: string;
cwd?: string;
}) {
const session = useMemo(
() =>
createRetroScreenWebSocketSession({
url,
openPayload: {
cwd,
term: "xterm-256color"
}
}),
[cwd, url]
);
return (
<RetroScreen
mode="terminal"
session={session}
closeSessionOnUnmount
autoFocus
displayColorMode="ansi-extended"
displayPadding={{ block: 12, inline: 14 }}
/>
);
}
For local development, the repo includes a reference node-pty websocket backend:
yarn tty:server
By default, that example server starts a themed demo shell rooted at ~/tty-demo with the
prompt operator@retro:~/tty-demo$, so the live story and the recorded bridge demo share the
same shell framing.
There is also a dedicated Storybook story for this path. It now defaults to the local example
server at ws://127.0.0.1:8787, so if yarn tty:server is already running you can open the
Live Tty Terminal Bridge story directly without adding any extra query params.
If you want to override the target or the open payload, use:
window.__RETRO_SCREEN_TTY_DEMO__ = {
url: "ws://127.0.0.1:8787",
openPayload: {
cwd: "/workspace",
term: "xterm-256color"
}
};
The example server now supports token checks, origin checks, idle timeouts, payload-size limits, and optional command/cwd/env override restrictions. See examples/node-tty-websocket-server/README.md for the available flags.
Use mode="prompt" when the interface should feel like a guided shell.
Complete example: command latency, accepted responses, and rejection behavior are included.
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const wait = (duration: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, duration);
});
export function PromptInteraction() {
return (
<RetroScreen
mode="prompt"
autoFocus
color="#97ff9b"
cursorMode="solid"
promptChar="$"
acceptanceText="READY"
rejectionText="DENIED"
onCommand={async (command) => {
await wait(420);
switch (command.trim()) {
case "status":
return {
accepted: true,
response: ["grid synced", "cursor stable", "story ready"]
};
case "scan":
return {
accepted: true,
response: ["signal sweep", "north: clear", "south: clear"]
};
default:
return {
accepted: false,
response: "unknown command"
};
}
}}
/>
);
}
The Apple DOS 3.3 demo sits nicely in the same family when you want the interface to feel boot-first and command-led instead of form-like.
Under the hood, the Apple II shell is built as a small userland runtime on top of mode="terminal" instead of trying to force the one-shot prompt path to behave like BASIC. The Storybook surface feeds keyboard bytes into a session store that owns the boot transcript, prompt state, numbered program lines, and shell mode transitions. That session then hands stored BASIC lines to a parser/interpreter pair that supports immediate commands like LIST, NEW, and RUN, plus resumable program execution for INPUT and BREAK.
Implementation notes:
src/stories/Apple2Basic.stories.tsx wires RetroScreen to the Apple session and powers both the interactive story and deterministic capture variants.src/stories/apple2-basic/apple2-basic-shell-session.ts manages uppercase input, line editing, transcript updates, prompt switching, and asynchronous runner scheduling.src/stories/apple2-basic/apple2-basic-parser.ts parses immediate commands, numbered program lines, and the supported BASIC statement/expression subset.src/stories/apple2-basic/apple2-basic-interpreter.ts compiles stored lines into a resumable runner so RUN, INPUT, GOTO, IF ... THEN, END, and CTRL+C / BREAK behave like a shell instead of a static transcript.Complete multi-file example: the required module map and run command are collected in
examples/readme/apple2-basic/.
Terminal and prompt surfaces now expose a real display buffer instead of only showing the live viewport. That means you can scroll back through recent output, inspect older lines, then return to the live tail when you are ready to follow the stream again.
Built-in behavior:
PageUp and PageDown move through the display bufferEnd returns terminal mode to the live tailUse bufferSize to control how many rows of history the component-managed terminal or prompt
surface keeps, and defaultAutoFollow if you want the view to start detached from the tail.
API fragment: use the complete Terminal Output example above for imports and controller lifecycle.
<RetroScreen
mode="terminal"
bufferSize={400}
defaultAutoFollow
value={[
"line-01 warm boot",
"line-02 telemetry stable",
"line-03 waiting for operator"
].join("\n")}
/>
If you are driving the component with your own controller, configure the underlying buffer size on the controller itself:
const controller = createRetroScreenController({
rows: 9,
cols: 46,
scrollback: 400
});
<RetroScreen mode="terminal" controller={controller} />
The browser suite now covers this path directly, including paging, wheel scrolling, anchored scrollback while new lines arrive, and auto-follow recovery back to the live tail.
When rows and columns matter to the program inside the display, listen to onGeometryChange,
turn that measurement into a terminal-style reply, and redraw from the reported size. The demo
below simulates a terminal app issuing CSI 18 t, receiving CSI 8;<rows>;<cols>t, then
repainting a full border and centered dimensions every time the panel resizes. The current demo
shows a visible cursor dragging the real resize handles, pauses if you intervene manually, and
still cycles through tight screen padding, multiple border alphabets, oversized glyph styles,
plus every monochrome and ANSI display mode so the same terminal program can be watched under
different visual projections.
Complete multi-file example: the real redrawBorderAndMetrics helper, controller lifecycle,
geometry deduplication, terminal reply, and runnable component are in
examples/readme/auto-resize-probe/. The scripted mouse
and visual-variant cycling in the capture are Storybook presentation choreography.
The runnable entry point is:
import { AutoResizeProbe } from "./AutoResizeProbe";
export function App() {
return <AutoResizeProbe />;
}
This is useful for terminal-style dashboards, resize-aware prompts, or retro UIs that need to
center content, draw frames, or adapt layouts from the actual LCD grid instead of from CSS alone.
It is also a good place to project displayColorMode changes when you want the terminal behavior
to stay fixed while the display mood shifts around it.
Use displayColorMode to decide how semantic terminal color should be projected onto the screen.
The phosphor modes keep the retro LCD personality even when the source emits ANSI color. The ANSI
modes preserve more of the source terminal palette.
Available modes:
phosphor-greenphosphor-amberphosphor-iceansi-vgaansi-classicansi-extendedComplete example:
import { RetroScreen } from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
const VALUE = [
"\u001b[31mALERT\u001b[0m \u001b[32mlink stable\u001b[0m",
"\u001b[38;5;196mindexed 196\u001b[0m from the 256-color palette",
"\u001b[38;2;255;180;120mtruecolor 255,180,120\u001b[0m"
].join("\n");
export function DisplayColorModes() {
return (
<RetroScreen
mode="terminal"
displayColorMode="ansi-extended"
value={VALUE}
/>
);
}
Reach for ansi-vga when you need the exact IBM PC/DOS 16-color palette used by ANSI art,
ansi-classic when you want the softened 16-color terminal profile, or
ansi-extended when 256-color and truecolor cells should survive all the way to the display.
The Midjourney galaxy demo uses the same RetroScreen cell pipeline to start from a drifting text field and pull it into a bright spiral structure.
Complete multi-file example: the text grid, particle model, spiral rasterizer, animation loop,
and cleanup are in
examples/readme/midjourney-vortex/.
Open it here: smysnk.com/projects/react-retro-screen/storybook/?path=/story/retroscreen--midjourney-vortex
Storybook now includes a dedicated Bad Apple ANSI demo that loads the real ANSI release,
decodes the original IBM VGA / CP437 bytes outside the display component, and then feeds those
bytes into the reusable RetroScreenAnsiPlayer wrapper. The player incrementally materializes
one mutable 80 x 25 terminal state at the configured baud while the parent owns byte loading and streaming. The
demo uses the full BADAPPLE.ANS payload, not a trimmed excerpt, and now uses the current
retained bitmap canvas backend with IBM VGA 8x16 glyphs.
The README clip is a 30-second capture of the real ANSI-art playback path, not a separate video renderer.
Credit for the original ANSI release goes to Mistigris.
Open it here: smysnk.com/projects/react-retro-screen/storybook/?path=/story/retroscreen-display-buffer--bad-apple-ansi
The Storybook demo is backed by the bundled asset at src/stories/assets/bad-apple.ans.
Complete multi-file example: loading, SAUCE parsing, byte splitting, error handling, player
wiring, asset requirements, and attribution are in
examples/readme/ansi-art-playback/.
API fragment: once the complete example has loaded asset, the key player props are:
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={25}
cols={80}
baud={14_400}
complete
loop
canvasAccessibleText={false}
displayColorMode="ansi-classic"
displayGlyphMode="ibm-vga-8x16"
displayLayoutMode="fit-width"
displayPadding={0}
displayScanlines={false}
renderBackend="canvas"
style={{ width: "100%" }}
/>
Use RetroScreenAnsiPlayer when a parent is responsible for supplying ANSI bytes or byte chunks,
including incremental streams. Keep the asset loading outside the display component, pass the
native rows and cols so the art is not reflowed. Playback state reports processedBytes,
totalBytes, status, and estimatedDurationMs; snapshots use drain to reach the same engine's
final state immediately. For ANSI art, the most useful display props
are:
displayCharacterSizingMode="font": lets browser font rendering own glyph sizing instead of
forcing explicit cell dimensions.displayFontSizingMode="fit-cols": chooses the largest integer font size that still fits the
requested columns horizontally.displayLayoutMode="fit-width": lets the component fill the available width and derive its
height from the resolved grid.displayScanlines={false} and disableCellRowScale: opt out of CRT-style effects that can
create unwanted seams in ANSI art.Large, read-only ANSI documents can opt into the bitmap canvas backend. It rasterizes CP437 glyphs
into retained ImageData, updates only changed cells during playback, and divides tall documents
into canvases no taller than 256 text rows. In explicit canvas mode no .retro-screen__line or
.retro-screen__cell elements are mounted.
API fragment: asset is loaded by the complete ANSI Art Playback example linked above.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
displayColorMode="ansi-vga"
displayGlyphMode="ibm-vga-8x16"
renderBackend="canvas"
canvasAccessibilityLabel={`${asset.title} by ${asset.author}`}
/>
renderBackend="canvas" requires a bitmap displayGlyphMode; font rendering and interactive
value, terminal, prompt, and editor surfaces resolve to the DOM backend. If the browser cannot
create a 2D canvas context the component also falls back to DOM. Omit renderBackend to preserve
the legacy rendering behavior, or use renderBackend="dom" to force rows and cells. Canvas mode
includes one visually hidden plain-text node by default; set canvasAccessibleText={false} when a
stable accessible label is preferable for animated artwork.
BADAPPLE.ANS uses lots of upper-half and lower-half block characters (▀ / ▄), so the demo
disables scanlines and rasterizes the IBM VGA glyph data directly.
RetroScreenAnsiPlayer can also render a fixed viewport over a larger ANSI buffer. This is useful
for gallery viewers, panning surfaces, and giant sparse ANSI files where the parent wants to keep a
stable 80 x 25 window while the underlying source geometry remains larger.
API fragment: this reuses the loader and asset state from the complete ANSI example.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
baud={14_400}
complete={asset.complete}
loop={asset.complete}
viewportRows={25}
viewportCols={80}
viewportRowOffset={rowOffset}
viewportColOffset={colOffset}
displayCharacterSizingMode="font"
displayFontSizingMode="fit-cols"
displayLayoutMode="fit-width"
displayPadding={0}
displayScanlines={false}
disableCellRowScale
/>
Set viewportFollowMode="cursor" when the viewport should remain fixed until the parser cursor
reaches its final visible row, then follow new output without discarding the rows above it. Combine
it with scrollMode="canvas" so the complete source document remains available for later panning,
export, or a full-document reveal. The default viewportFollowMode="fixed" preserves explicit
viewportRowOffset behavior.
<RetroScreenAnsiPlayer
byteStream={asset.byteStream}
rows={asset.height}
cols={asset.width}
viewportRows={25}
viewportFollowMode="cursor"
scrollMode="canvas"
renderBackend="canvas"
/>
The playback callback reports source geometry and byte progress, so a parent can keep status UI in sync with the rendered stream:
<RetroScreenAnsiPlayer
// ...
onPlaybackStateChange={(state) => {
console.log(state.sourceRows, state.sourceCols);
console.log(state.processedBytes, state.totalBytes, state.status);
}}
/>
The terminal path is now tested against an xterm oracle and can faithfully replay real control character effects like carriage return rewrites, erase-in-line, scroll regions, insert-line updates, ANSI 16-color, indexed 256-color, and truecolor output.
Complete example: writes run inside a component effect and timers are cleaned up on unmount.
import { useEffect, useState } from "react";
import {
RetroScreen,
createRetroScreenController
} from "react-retro-display-tty-ansi-ascii";
import "react-retro-display-tty-ansi-ascii/styles.css";
export function ControlCharacterReplay() {
const [controller] = useState(() =>
createRetroScreenController({ rows: 6, cols: 34, cursorMode: "solid" })
);
useEffect(() => {
controller.reset();
controller.setCursorVisible(true);
const writes = [
"Downloading fixtures... 12%",
"\rDownloading fixtures... 73%",
"\r\u001b[32mDownloaded fixtures.\u001b[0m\u001b[K\r\n",
"\u001b[2;6r",
"\u001b[6;1H\u001b[L\u001b[38;2;255;180;120mrecorded regression fixture\u001b[0m"
];
const timers = writes.map((value, index) =>
window.setTimeout(() => {
controller.write(value);
}, 280 + index * 720)
);
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [controller]);
return (
<RetroScreen
mode="terminal"
controller={controller}
displayColorMode="ansi-extended"
/>
);
}
The same trace fixtures used in Storybook are also exercised in the terminal verification layers:
yarn check:ansi-display-report
yarn test:e2e:ansi-display
yarn test:conformance
yarn test:tty
yarn test:e2e:tty
yarn test:e2e
The TTY-specific checks skip themselves automatically in environments where node-pty cannot
allocate a TTY session, but they run normally on TTY-capable developer machines and CI runners.
The authoritative display-facing ANSI ledger lives in
docs/ansi-display-support-matrix.md. It is generated
from the conformance matrix source and verified in CI so the published status stays in sync with
the implementation.
The component is intentionally small at the edge:
mode="value" when all you need is a beautiful terminal-like readout.editable if the content should be controlled by React state.mode="terminal" when output is driven by a stream or controller.mode="prompt" when commands and responses should live in one transcript.onGeometryChange if rows and columns matter to the rest of your app.Storybook now acts as the living demo surface for the package. It includes stories for the main user journeys:
Run it locally with:
npm install
npm run storybook
Releases from main publish through GitHub OIDC without a persistent npm token. Maintainers can find the one-time npm package configuration and migration checklist in npm trusted publishing.
npm install
npm run build
npm run test
npm run test:unit
npm run storybook
Useful extra checks:
yarn test:tty
yarn test:e2e:tty
yarn perf:terminal
109 commits
TypeScript
74.2%
JavaScript
22.2%
CSS
3.1%