Terminal demos as animated SVG.
Write a tape, get something you can drop straight into a README.
A 3 MB GIF becomes 6 KB, stays sharp at any zoom, and follows light and dark.
CLI projects usually show their demo as a GIF. GIFs are heavy, capped at 256 colors so the text goes mushy, and the background is baked in, so a demo that looks right on a dark README looks wrong on a light one.
| GIF | termcast | |
|---|---|---|
| Size | megabytes | kilobytes |
| Zoom | blurs | stays sharp |
| Themes | one, baked in | follows prefers-color-scheme |
| Editing | re-record | change a line |
The one thing you give up: an SVG loaded through <img> is an image, so the text in it is
not selectable.
Open termcast, pick a preset, edit the tape, and copy the markdown it gives you.
<img src="https://termcast.xyz/t/v3/w24-94/<code>.svg" width="100%" alt="demo">A README column is near 250px on a phone and near 840px on a desktop. Scale one image to
fit both and 14px text lands at 6px on the phone; leave it alone and it runs off the side.
So the demo reflows instead: the text is laid down once, and CSS inside the SVG works out
the wrap width from the box the page actually gave the image, anywhere between the two
column counts in the path. The text stays the size it was written at everywhere, lines
break where they meet the padding, and the demo needs no <picture> and no second address.
width="100%" is doing work there. Without it the image falls back to 300px.
Copy for Markdown wraps that line in a <picture> with one variant per viewport
class. An <img> box cannot give its spare height back, so a single address always
reserves the height of its narrowest wrap; the picture keys the reserve on the screen
instead, so a desktop is not paying for a phone. Every variant still reflows, which is why
a mispredicted breakpoint costs a couple of blank rows and never a character, and the
block only grows as long as the tape needs: bands that would reserve the same height
collapse, and a tape without wrapping lines stays a single line.
Or use Download SVG and commit the file: one fixed-width image, no blank space under it, and it works anywhere with no service behind it.
git clone https://github.com/bbjbc/termcast
cd termcast
pnpm install
pnpm dev # http://localhost:3210title mytool
prompt ❯
speed 55ms
type npm i -g mytool
wait 500ms
ok ✓ added 1 package in 1.2s
out
type mytool init
ok ✓ created mytool.config.ts
dim → next: mytool build
type
| Command | Does |
|---|---|
type <text> |
types after the prompt, one character at a time |
out <text> |
prints a line immediately |
dim <text> |
prints it faint |
ok <text> |
green |
err <text> |
red |
warn <text> |
yellow |
out |
blank line |
wait 500ms |
pause |
type |
prompt alone, cursor blinking |
| Directive | Default | Does |
|---|---|---|
title |
none | window title, hidden when empty |
prompt |
$ |
prompt string |
speed |
55ms |
per-character typing interval |
hold |
1.5s |
pause after the last line |
theme |
dark |
dark · light · auto |
chrome |
mac |
mac · plain · none |
font |
14 |
font size in px |
cols |
0 |
width in columns, 20 to 200, 0 fits the content |
rows |
0 |
minimum height in lines, 0 fits the content |
radius |
9 |
corner radius in px |
loop |
on |
on · off |
color <key> <hex> |
none | bg bar bd dot ti fg dim ok err warn |
A few things worth knowing:
- The prompt is not typed. It is there when the line starts, the way a real terminal behaves.
speedandpromptcan appear again mid-tape and apply from that point on, so an install can crawl while a build log flies past.colsandrowssize the window the way a terminal is sized, in characters and lines.cols 80androws 24gives you the classic one.rowsis a floor, not a ceiling: a static SVG cannot scroll, so longer output grows the window rather than losing lines.colsis a ceiling: a line past it wraps onto the next one, at the character, the way a terminal does. Withcols 0the window grows to the content instead and nothing wraps.- Quote to keep leading spaces:
out " nested.txt"
Korean, Chinese, Japanese, fullwidth punctuation and emoji take two cells in a terminal.
termcast measures that and places glyphs on the cell grid, so they neither squash nor push the
rest of the line out of alignment. Every run carries an explicit textLength, which holds the
columns together whatever font the reader has.
Animation survives on GitHub because of the headers GitHub serves repository SVGs with:
Content-Type: image/svg+xml
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
style-src 'unsafe-inline' is open, so an inline <style> block runs and CSS keyframes play.
Scripts are blocked by sandbox, so termcast only ever emits declarative animation.
Fonts are the other consequence of that header: no external font loads, so web font links inside the SVG are ignored and the renderer falls back to a system monospace.
Links need no database. The tape is deflated and base64url-encoded into the path, so
/t/v3/<code>.svg renders it on the spot. Same input, same output, so the response is cached
as immutable. Long tapes exceed what a URL can hold; the editor notices and points you at the
download instead.
The v3 is the renderer version. Since the response is immutable for a year, a URL you have
already pasted somewhere keeps the picture it was given; improving the renderer bumps the
version, which is a new address, so nothing published changes under you.
The w24-94 is the column range the image reflows across, and it belongs in the address
for the same reason: it is a property of where the demo is being shown, not of the demo.
/t/v3/<code>.svg without it still renders the fixed-width picture it always did.
Nothing is recorded. You are not capturing a session, you are writing one. No typos, no awkward waits, no re-running a command to get a clean take, and you can demo a flag you have not built yet. The cost is honesty: a tape can claim any output it likes. If a number in a demo matters, it is on you to make it a real one.
src/app/ routes: the editor page and /t/[...seg]
src/components/ ui/ primitives, then tape · output · workbench
src/hooks/ tape state, URL encoding, clipboard
src/lib/ parser, renderer, highlighter, encoder, all pure
src/lib has no React and no DOM, which is why the browser preview and the server route can
call the same code.
The tape string is the only state. The settings panel does not hold values of its own; it writes directives into the tape. So the form and the text cannot disagree, and putting the tape in a URL carries everything.
Next.js and React are the only runtime dependencies. Compression uses CompressionStream,
so there is no compression library.
pnpm dev # http://localhost:3210
pnpm build
pnpm lint
pnpm test
pnpm coverage # the same run, gated at 100%The suite covers the logic: the parser, the renderer, the width arithmetic, the encoder, the SVG route and the locale redirect. It runs in plain node, because none of that needs a browser. Components are left to the browser check instead, since asserting markup shape breaks on every layout change without catching much.
The site splits into /en and /ko, switchable from the status bar at the top. Switching
carries the tape you are working on along in the URL rather than dropping it.
Tape syntax errors stay in English. The command names are English, so the messages match them.
MIT © bbjbc