Export
Save any ASCII frame as plain text, ANSI escape art, vector SVG, high-DPI PNG or video, with ascii-export.
ascii-export turns frames into files. Every renderer fills a handle ref you pass it, and the handle gives you the current frame, its colours and its cell size: everything the exporters need. Try each button on the live scene below.
Installation
pnpm dlx shadcn@latest add https://asciicn.dev/r/ascii-export.jsonUsage
import { useRef } from "react";
import type { AsciiHandle } from "@/lib/ascii";
import {
downloadBlob,
downloadText,
toAnsi,
toPng,
toSvg,
toText,
} from "@/lib/ascii/export";
const handle = useRef<AsciiHandle | null>(null);
<AsciiImage src="/portrait.jpg" handle={handle} />;
const save = async () => {
const live = handle.current;
const frame = live?.capture();
if (!(live && frame)) return;
downloadText(toText(frame, { trimEnd: true }), "portrait.txt");
downloadText(toAnsi(frame), "portrait.ans");
downloadText(
toSvg(frame, {
ink: live.colors().ink,
metrics: live.metrics() ?? undefined,
}),
"portrait.svg",
"image/svg+xml"
);
downloadBlob(await toPng(live, { scale: 3 }), "portrait.png");
};capture() returns null until the renderer has drawn its first frame. toText, toAnsi and toSvg are pure string builders: they run in scripts, on the server and in tests. The PNG, video and file helpers need a browser.
Which format
| Format | Function | Use it for |
|---|---|---|
| Text | toText | READMEs, code comments, terminals, anywhere colour doesn't matter |
| ANSI | toAnsi | Colour art for terminals: cat art.ans, MOTDs, CLI banners |
| SVG | toSvg | Sharp at any size, editable in Figma, small for mono frames |
| PNG | toPng | Social images and anywhere SVG text isn't supported |
| Video | createRecorder, recordCanvas | Loops of animated scenes for posts and docs |
Text
toText(frame, { trimEnd }) joins rows with \n. trimEnd: true drops trailing spaces from every line, which keeps files small and editors quiet.
ANSI
toAnsi writes SGR escape codes before each run of same-coloured glyphs and a reset at the end of every line, so a terminal that cuts a line short never bleeds colour into your prompt. The output ends with \x1b[0m.
toAnsi(frame); // 24-bit colour: \x1b[38;2;r;g;bm
toAnsi(frame, { mode: "ansi16", ansi: "vga" }); // classic codes 30-37 and 90-97
toAnsi(frame, { ink: "#33ff66", paper: "#020a04", background: true });| Option | Default | Meaning |
|---|---|---|
mode | "truecolor" | truecolor or ansi16 |
ansi | "vga" | ansi16 table: vga or xterm |
ink | terminal's own | Colour of glyphs without their own, #rrggbb or bytes |
paper | none | Background colour, used with background |
background | false | Paint paper behind every cell |
trimEnd | false | Drop trailing spaces (ignored when painting a background) |
SVG
toSvg writes one <text> per row, a <tspan> per colour run, and a textLength on every row. textLength pins each row to exact columns, so the art lines up even when the viewer substitutes a font with a different advance. Blocks, shades, Braille, quadrants, dots and box lines are written as vector paths instead of text, one path per colour, using the same geometry the renderers draw. They join without seams and need no font. Pass shapes: false to keep them as text.
const svg = toSvg(frame, {
metrics: handle.current?.metrics() ?? undefined, // cell width and height; default 6×12
ink: "#e4e4e7",
paper: "#09090b", // or null for transparent
font: "JetBrains Mono, monospace",
});Colours here must be real CSS colours, not variables, because the file leaves the page. handle.colors() returns the renderer's resolved ink and paper.
PNG
toPng takes a canvas, a handle or a frame:
- A handle without
scalesaves the live canvas as it is, including any CRT effects. - A handle with
scalere-renders the captured frame at that many device pixels per CSS pixel, in the handle's colours and font.scale: 3gives a crisp image for social cards. - A frame is re-rendered at
scale(default 2) with theink,paper,cellandfontyou pass.
renderFrame(frame, options) returns the canvas itself if you want to draw on it first. Images are capped at 16,384 pixels on their longest side.
Video
createRecorder(canvas) gives you start() and stop() for a record button. stop() resolves with the video. recordCanvas(canvas, { durationMs }) records for a fixed time.
const recorder = createRecorder(handle.current.canvas, {
fps: 60,
onProgress: (ms) => setElapsed(ms),
});
recorder.start();
// later
const video = await recorder.stop();
downloadBlob(video, `scene.${videoExtension(video.type)}`);The recorder picks the first type the browser supports: VP9 WebM, VP8 WebM, then MP4 (Safari). pickVideoType() tells you which. The default bitrate of 8 Mbps keeps small glyphs sharp. Keep the renderer animating while it records: a paused canvas records a still, and renderers pause when scrolled offscreen.
Files and clipboard
| Helper | Does |
|---|---|
downloadBlob(blob, filename) | Saves a blob through a temporary link |
downloadText(text, filename, mime?) | Saves text, UTF-8 plain text by default |
copyText(text) | Copies to the clipboard; resolves false if the browser refuses |
Static art without exporting
If the art never changes, skip the export step: build a frame with MutableFrame or mapTones and render it with AsciiPre. It is real text on the server, so search engines and screen readers can read it, and readers can copy it straight from the page.