← BACK TO ENGINEERING
Runtime 8 min read

Getting Started with Freeze in TypeScript: Code and Terminal Screenshots as SVG, PNG and WebP

A picture of code shows up everywhere: in a README, a slide, a changelog, a post. Taking it by hand means a screenshot, a crop and the wrong font. Freeze, from Charm, makes the picture from the file instead: syntax highlighting, a window with buttons, a shadow, padding, and the font embedded so the image looks the same everywhere. It does the same for terminal output, colours included.

@oakoliver/freeze is our TypeScript port of freeze v0.2.2. It has the same freeze command and adds a library API for Node and Bun.

Seven short steps, from one command to a script that renders a set of images. Every picture below is the output of the command or code above it (the PNGs are scaled down to fit the page).

retry.ts rendered with the full preset: a dark window with red, yellow and green buttons, rounded corners and a soft shadow, showing a TypeScript retry function in the charm theme


I – Install

npm install @oakoliver/freeze
# or
bun add @oakoliver/freeze
# or, for the command everywhere
npm install -g @oakoliver/freeze

SVG output is plain TypeScript. PNG and WebP are drawn by resvg through @resvg/resvg-js, an optional dependency with prebuilt binaries that npm and Bun install for you on macOS, Linux and Windows. If it isn't there, freeze says so and suggests writing an .svg instead.

Check the command:

$ npx freeze --version
freeze version 1.0.0

II – Your First Image

Here is the file we'll use, a small retry helper:

// retry.ts — call fn until it succeeds, waiting longer after each failure.
export async function retry<T>(
  fn: () => Promise<T>,
  { attempts = 5, baseMs = 100 } = {},
): Promise<T> {
  let lastError: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      const delay = baseMs * 2 ** i + Math.random() * baseMs;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastError;
}

Give freeze the file:

$ npx freeze retry.ts
 WROTE  freeze.png

retry.ts with the base preset: highlighted code on a dark background with padding, no window

That took 0.35 seconds. With no -o, freeze writes freeze.png, and a PNG whose size freeze works out itself is drawn at four times the size of the SVG: this one is 2773×1408 pixels, sharp on any screen. The language came from the file name. Freeze recognises the same files as the Go version, because the port uses chroma's own list of languages and file names.

For an SVG, name one:

$ npx freeze retry.ts -o retry.svg
 WROTE  retry.svg

The same image as an SVG, drawn by your browser with the font embedded in the file

The image above is that SVG, drawn by your browser. It weighs 371 KB, and almost all of that is the font: freeze embeds JetBrains Mono in the file, so it renders the same in a browser, a design tool or a slide, whatever fonts the viewer has installed.


III – The Window

The look comes from a preset. The default, base, is what you saw: padding and a background. full adds the window controls, a border with rounded corners, a margin and a drop shadow, as in the picture at the top:

npx freeze retry.ts --config full

Every part of it is a flag, so you can build your own:

npx freeze retry.ts --window \
  --border.radius 10 --border.width 1 --border.color "#3a3a3a" \
  --padding 24,32 --margin 40 \
  --shadow.blur 30 --shadow.y 16 \
  -o custom.png

retry.ts in a window with rounded corners, a thin grey border, wider padding and a deep shadow below it

Padding and margin take one, two or four comma-separated values in CSS order (top, right, bottom, left), so --padding 24,32 is 24 above and below and 32 on the sides. --width and --height fix the image size instead of fitting it to the code.


IV – Themes, Lines and Line Numbers

--theme picks one of 75 themes: chroma's 74 (Dracula, Nord, GitHub, the Catppuccin and Tokyo Night families, Solarized and the rest) and freeze's own charm, the default. --lines keeps part of the file, and --show-line-numbers numbers what's left with the file's real line numbers:

npx freeze retry.ts --config full --theme catppuccin-latte \
  --lines 6,15 --show-line-numbers -o lines.png

Lines 6 to 15 of retry.ts in the light Catppuccin Latte theme, numbered 6 to 15

--lines counts from 1 and includes both ends. The window takes the theme's background colour; for themes without one, such as charm, --background sets it.


V – Terminal Output

--execute (or -x) runs a command and freezes what it prints. It runs it in a pseudo-terminal, so tools that colour their output for a terminal do so. Here is a coloured git diff after changing two lines of retry.ts:

npx freeze --execute "git --no-pager diff --color=always" --config full -o diff.png

A git diff in a window: the diff header, hunk headers in cyan, a removed line in red and two added lines in green

Colours, backgrounds, italics, underline and strikethrough come through; bold is ignored, as in the Go version. Under Bun the command runs in Bun's built-in terminal, and under Node.js through script, which macOS and Linux ship with. Freeze stops the command after 10 seconds (--execute.timeout 30s changes that).

Output you already have works too: a file containing escape sequences, or anything piped in, is recognised as terminal output and drawn the same way.


VI – From TypeScript

Everything the command does is a function call. renderSVG returns the SVG as a string:

// hello.ts
import { writeFileSync } from 'node:fs';
import { renderSVG } from '@oakoliver/freeze';

const svg = renderSVG({
  code: 'console.log("Hello, freeze!");\n',
  language: 'typescript',
  config: 'full',
});

writeFileSync('hello.svg', svg);
console.log(`hello.svg: ${(svg.length / 1024).toFixed(0)} KB`);
$ node hello.ts
hello.svg: 359 KB

The options are the command's flags, in camelCase and grouped: theme, window, lines, showLineNumbers, padding, margin, border: { radius, width, color }, shadow: { blur, x, y }, font: { family, size, ligatures }. Pass code (with a language) or a file.

A script can render a whole set. freeze() writes the file and picks the format from the extension, like the command; renderPNG() returns the bytes:

// shots.ts: three themes of retry.ts, plus a social card.
import { writeFileSync } from 'node:fs';
import { freeze, renderPNG } from '@oakoliver/freeze';

const started = performance.now();

// Like the CLI: the format comes from the output's extension.
for (const theme of ['dracula', 'github', 'nord']) {
  const r = freeze({ file: 'retry.ts', config: 'full', theme, output: `retry-${theme}.png` });
  console.log(`${r.output.padEnd(18)} ${Math.round(r.width)}x${Math.round(r.height)} (${r.language})`);
}

// A fixed size is drawn at 1x: exactly 1200x630 for a link preview.
const card = renderPNG({
  file: 'retry.ts',
  theme: 'catppuccin-mocha',
  config: 'full',
  width: 1200,
  height: 630,
  margin: [60],
  font: { size: 22 },
  lines: [2, 15],
});
writeFileSync('card.png', card);
console.log(`card.png           1200x630, ${(card.length / 1024).toFixed(0)} KB`);
console.log(`done in ${Math.round(performance.now() - started)} ms`);
$ node shots.ts
retry-dracula.png  3253x1888 (TypeScript)
retry-github.png   3253x1888 (TypeScript)
retry-nord.png     3253x1888 (TypeScript)
card.png           1200x630, 82 KB
done in 1462 ms

card.png: retry.ts in the Catppuccin Mocha theme, in a window centred in a 1200 by 630 image

Four images in about 1.4 seconds, on Node 26 and on Bun alike. render() returns the SVG together with its size and detected language without writing anything, freezeCommand() is --execute as a function, and renderWebP() gives lossless WebP.


VII – Your Own Preset

Settings you use every time belong in a JSON file. The keys are the flag names, nested at the dots:

{
  "theme": "tokyonight-night",
  "window": true,
  "border": { "radius": 12, "width": 1, "color": "#3b4261" },
  "shadow": { "blur": 20, "x": 0, "y": 10 },
  "padding": [24, 32],
  "margin": [40],
  "font": { "family": "JetBrains Mono", "size": 16, "ligatures": false },
  "line_height": 1.4
}
npx freeze retry.ts --config freeze.json -o preset.png

retry.ts in the Tokyo Night theme with a 16px font, more line spacing, no ligatures and a rounded window

Flags on the command line still win over the file. With "ligatures": false, => and != keep their two characters instead of becoming arrows.

Or let freeze ask. --interactive opens a form with the same settings, writes the image, and saves your answers as the user preset, which --config user loads next time:

freeze --interactive: the output name is typed, the theme picker steps through themes, padding and margin are set, window controls turned on, and border radius and shadow filled in; then freeze writes the user preset and the PNG


VIII – Gotchas

These are the ones we hit while writing this guide. Each behaves the same way in the Go version.

A config file replaces the base preset rather than extending it. A file with only {"theme": "charm"} gives an image with no size, no padding and no font, because everything it doesn't set is zero. Start from a full file like the one above.

Freeze reads standard input whenever it's a pipe. In a script or a CI job where stdin is not a terminal, freeze retry.ts waits for input that never comes. Add < /dev/null.

Name the language of piped code. Freeze guesses the language of piped code from a few content signals, as chroma does, and the guess can be wrong: cat retry.ts | npx freeze takes the file for GDScript 3, because text containing const or export scores for it, and the colours come out wrong. cat retry.ts | npx freeze -l typescript fixes it.

Pagers wait. git log in a terminal opens a pager, so freeze -x "git log" hangs until the timeout. Use git --no-pager.

PNGs are big because they're 4×. An automatically sized PNG is drawn at four times the SVG's size. For an exact size, set --width and --height: then it's drawn at 1×, as for the card above.


IX – What to Read Next


X – Requirements

  • Node.js 18 or later (the package's engines field). We ran the command, PNG output and --execute on Node 18.12, 18.20, 20.6, 20.18, 22.16, 24.16 and 26.8, and got the same SVG from each. To run the .ts scripts directly, use Node 24 or later, or Bun; older Node versions can run them through tsx.
  • Bun: everything was also run on Bun 1.4.
  • PNG and WebP: @resvg/resvg-js, installed with the package where its prebuilt binaries exist.
  • --execute under Node.js needs script, which macOS and Linux include. On Windows the command runs without a terminal, with FORCE_COLOR set.
"Simplicity is the ultimate sophistication."