· Tutorials · By Simon Chiu

Code Snippet to Image: Shiki, a Frame and One Capture

Turn code into a PNG or WebP image from a script: Shiki highlighting, a window frame, element capture and light and dark themes. Sizes and timings measured.

To turn a code snippet into an image from a script, highlight it with Shiki to get HTML with inline colours, wrap that HTML in a small page with a window frame, and capture only the frame element in a headless browser. You get the "Carbon look" from a command you can run in CI, for every changelog entry or blog post, with the same theme and font every time. This post builds that pipeline, measures it, and shows the one-request version through an API.

It is part of the HTML to image API cluster. If your source is a whole Markdown document rather than one snippet, Markdown to image is the closer fit.

Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro): [email protected], [email protected] (Chrome for Testing 155.0.8059.79). Font: JetBrains Mono from Google Fonts.

Why highlight on the server and capture HTML

There are three common ways to make code images, and they fail in different places:

Approach Good at Breaks when
A web tool (paste, export) One-off images You need 40 of them, or the same style next month
A canvas or SVG library that draws text No browser Ligatures, font fallback, wide Unicode, tab widths
Highlighted HTML in a real browser Exact text rendering, any CSS You need to run (or call) a browser

Shiki uses the same TextMate grammars and themes as VS Code, so the colours match what your readers see in their editor. Its output is plain HTML with inline style colours. That means no stylesheet to ship for the theme and no client-side JavaScript: a browser only has to lay out text, which it does best.

Step 1: highlight and frame

// snippet.mjs: highlight code with Shiki and wrap it in a framed HTML page.
import { createHighlighter } from 'shiki';

export const highlighter = await createHighlighter({
  themes: ['github-dark', 'github-light', 'vitesse-dark'],
  langs: ['ts', 'python', 'bash', 'diff'],
});

const esc = (s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');

export function snippetHtml(code, { lang = 'ts', theme = 'github-dark', title = '', wrap = false } = {}) {
  // codeToHtml escapes the code, so <script> in a snippet is shown, not run.
  const body = highlighter.codeToHtml(code.replace(/\n$/, ''), { lang, theme });
  const t = highlighter.getTheme(theme);
  return `<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=block">
<style>
  html, body { margin: 0; background: transparent; }
  .stage { display: inline-block; padding: 48px; background: linear-gradient(135deg, #0e7490, #1e3a8a); }
  .frame { border-radius: 12px; overflow: hidden; background: ${t.bg};
           box-shadow: 0 20px 50px rgb(0 0 0 / .35); min-width: 480px; max-width: 1000px; }
  .bar { display: flex; align-items: center; gap: 8px; padding: 14px 16px; color: ${t.fg};
         opacity: .8; font: 14px 'JetBrains Mono', monospace; }
  .bar i { width: 12px; height: 12px; border-radius: 50%; background: currentColor; opacity: .35; }
  .bar span { margin-left: 8px; }
  pre.shiki { margin: 0; padding: 8px 24px 24px; font: 18px/1.6 'JetBrains Mono', monospace;
              counter-reset: line;
              ${wrap ? 'white-space: pre-wrap; overflow-wrap: anywhere;' : 'overflow: hidden;'} }
  pre.shiki .line::before { counter-increment: line; content: counter(line); display: inline-block;
              width: 2ch; margin-right: 2ch; text-align: right; opacity: .35; }
</style></head>
<body><div class="stage"><div class="frame">
<div class="bar"><i></i><i></i><i></i><span>${esc(title)}</span></div>
${body}
</div></div></body></html>`;
}

Design notes:

  • Load each grammar and theme once. Importing Shiki and creating the highlighter is the slow part (68 ms for three themes and four languages in our run). codeToHtml on a 12-line file then took a median of 1.97 ms.
  • Line numbers in CSS. Shiki wraps each line in <span class="line">, so a CSS counter numbers them. The numbers are not part of the text, and you can switch them off with one rule.
  • The frame takes its colours from the theme. getTheme() returns the theme's background and foreground, so the title bar matches any theme you pass.
  • The title is escaped. File names come from user input more often than you would think.

Step 2: capture only the frame

// render-local.mjs: node render-local.mjs <file> [lang] [theme]
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { snippetHtml } from './snippet.mjs';

const [file, lang = 'ts', theme = 'github-dark'] = process.argv.slice(2);
if (!file) {
  console.error('usage: node render-local.mjs <file> [lang] [theme]');
  process.exit(2);
}
const code = await readFile(file, 'utf8');
const html = snippetHtml(code, { lang, theme, title: path.basename(file) });

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
  await page.setContent(html, { waitUntil: 'load' });
  await page.evaluate(() => document.fonts.ready);
  const stage = await page.$('.stage');
  await stage.screenshot({ path: 'snippet.png' }); // element capture: the image fits the code
  console.log('wrote snippet.png');
} finally {
  await browser.close();
}

.stage is inline-block, so its box is exactly as big as the code plus padding. An element screenshot of it gives an image that fits each snippet. You do not have to guess a height. For our 12-line TypeScript test file (a small retry() helper), the PNG came out at 2130 x 1040 pixels at 2x.

The viewport still matters. It is the space the frame can grow into. A frame wider than the viewport would be clipped, which is why the viewport (1200) is wider than the frame's max-width (1000) plus the stage padding.

Step 3: choose a format

We captured the same element with each format, 7 times each, on a warm page:

Theme Format Bytes Capture median
github-dark PNG 272,346 124 ms
github-dark WebP, quality 100 188,832 176 ms
github-dark WebP, quality 85 52,516 153 ms
github-dark JPEG, quality 85 98,499 61 ms
github-light PNG 273,435 119 ms
github-light WebP, quality 85 55,942 154 ms
github-light JPEG, quality 85 105,380 64 ms

The whole warm pipeline (highlight, setContent, wait for fonts, PNG capture) took a median of 128 ms.

What the numbers say:

  • The gradient costs the most bytes, not the code. Smooth gradients compress badly as PNG. If size matters more than looks, use a flat stage colour.
  • WebP 85 is about a fifth of the PNG. It is the default we would pick for blogs and docs. Look at the text edges with your own theme before you lower the quality further.
  • JPEG is the fastest to encode, but it has no advantage in size here. Its compression is designed for photos, not for sharp text on flat colour, and WebP 85 was smaller.
  • PNG is right for diffs and archives, where you want identical pixels on every run.

The PNG vs JPEG vs WebP comparison covers these trade-offs for full-page screenshots.

To write WebP with Puppeteer, change the capture call: stage.screenshot({ type: 'webp', quality: 85, path: 'snippet.webp' }).

Automating it for a blog or changelog

The script is a building block. Two patterns we like:

Render every fenced block marked for images. Mark the blocks you want as images with an info-string flag, for example `ts image. A build step finds them, renders each with snippetHtml, writes snippet-<hash>.webp where the hash comes from the code, the language and the theme, and skips files that already exist. Unchanged snippets cost nothing on the next build.

Light and dark pairs. Render each snippet with github-light and github-dark and serve them with <picture>:

<picture>
  <source srcset="/img/retry-dark.webp" media="(prefers-color-scheme: dark)">
  <img src="/img/retry-light.webp" alt="retry() helper in TypeScript with exponential backoff" width="1065" height="520">
</picture>

The width and height are the CSS size of the element (half the 2x pixel size), which prevents layout shift. Always keep the code as text next to the image or in a link. Readers cannot copy code from a picture, and screen readers cannot read it.

The same capture with one API request

If you do not want Chrome in your build, send the HTML from snippetHtml to an HTML to image API and ask for an element capture of .stage. With the Node SDK:

// render-api.mjs
import { readFile, writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';
import { snippetHtml } from './snippet.mjs';

const client = new Client(process.env.RS_API_KEY);
const code = await readFile('retry.ts', 'utf8');

const image = await client.take(
  TakeOptions.html(snippetHtml(code, { lang: 'ts', theme: 'github-dark', title: 'retry.ts' }))
    .width(1200)
    .height(800)
    .scale(2)
    .element('.stage')
    .format('webp')
    .quality(85)
);
await writeFile('snippet.webp', image);

The highlighting still runs in your process; only the capture moves. .element('.stage') sets the selector for an element capture. If the selector is missing, the API falls back to a viewport capture, unless you send capture.selector_required: true in a POST body, which makes it fail with selector_not_found instead. The capture options page lists the rest. This listing uses only documented SDK methods. It was not run against the live API for this post.

Troubleshooting

Symptom Cause Fix
Long lines cut off at the right max-width plus overflow: hidden on the frame Pass wrap: true, or raise max-width and the viewport width
Monospace fallback font in the image Captured before JetBrains Mono loaded Keep display=block and wait for document.fonts.ready
Image is the whole viewport, not the frame Page screenshot instead of element screenshot Capture .stage, or .element('.stage') in the SDK
Lines numbered from 1 in an excerpt The CSS counter always starts at 1 Set counter-reset: line N where N is the first line minus 1
Tabs look too wide Browser default tab-size is 8 Add tab-size: 2 to pre.shiki, or convert tabs to spaces
Language shows as plain text Language not loaded in createHighlighter Add it to langs, or call loadLanguage() first
Big PNG files Gradient background WebP 85, or a flat stage colour
Smudged edges around small text Lossy compression at a low quality Raise the quality, or use PNG
<script> from the snippet ran It cannot with this code; check for a custom pre-processing step Let Shiki do the escaping; never insert raw code yourself

Summary

  1. Highlight with Shiki once per process, then call codeToHtml per snippet.
  2. Put the result in a frame whose colours come from the theme.
  3. Capture the frame element at 2x, as WebP 85 for the web.
  4. Cache by content hash so a build renders only changed snippets.

To skip the browser, create a free account and send the framed HTML as an element capture. For the rest of the HTML to image options, start at the HTML to image API page. For utility-class templates, see Tailwind HTML to image.

Related in this topic