Topic 6.2
Code Blocks
In one line
Code in an answer must be exact, readable on a phone, easy to copy and, if you like, coloured. A block also has three states while it streams (writing, ready, cut off), and the copy button and the colouring must respect them.
Think of it like this
A recipe in a cookbook has its ingredient list set apart in a box, with a small label ("Dough") on top. You would not want it in running text, because every quantity matters. A code block is that box for code: set apart, in a fixed-width font, with a label for the language, because every space and symbol matters.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Fence
- Three (or more) backticks on a line of their own. Code goes between an opening and a closing fence.
- Info string
- The word after the opening fence, such as
js. A hint about the language. - <pre>
- An HTML element that keeps spaces and line breaks exactly as written.
- Clipboard API
- The browser feature behind copy buttons:
navigator.clipboard.writeText(text). - Secure context
- A page loaded over HTTPS (or localhost). Some features, like the clipboard, only work there.
- Syntax highlighting
- Colouring code by meaning: keywords, strings, numbers, comments.
- Grammar
- The rules that tell a highlighter what counts as a keyword or a string in one language.
- Singleton
- One shared object created once and reused. Highlighters are built once, because creating one is slow.
Step by step
01Parse the fence
The renderer from Topic 6.1 already reads a fence: the line must be three or more backticks (or tildes) followed by an optional label. It collects lines until a line that starts with the same fence, and records closed as true or false.
That one boolean, closed, drives everything in this topic.
02Draw the box
The first live example draws each block as a <figure> with a caption (language label and Copy button) and a <pre>. An empty label shows text. The CSS gives pre overflow-x: auto, so a very long line scrolls inside its box.
The example then measures the long block: wider than its box, the box can scroll, and the page does not scroll sideways.
03Copy, with every outcome handled
copyText(text, clipboard) takes the clipboard as a parameter. In the page it defaults to the real navigator.clipboard; in the example we pass stand-ins so we can see all three outcomes: copied, denied (the browser refused) and unavailable (there is no clipboard object).
The button shows the result in a role="status" element, which screen readers announce without moving focus.
04Three states while it streams
The second example is a real useChat chat. The block's state is computed from two facts: is the closing fence there, and is the chat still streaming. A second button makes the stream break in the middle of the block, and the state becomes cut off.
Copy is enabled only in ready. The SDK's own logger prints the broken stream's error; your user sees the message the server chose to send (Topic 5.6).
05What highlighting costs
The third example is a tiny highlighter (comments, strings, numbers, keywords) and three ways to use it while 254 characters stream in. It counts calls and characters looked at. Recolouring everything each time looks at 32,385 characters in total, colouring finished lines once looks at 5,298, and colouring at the end looks at 254.
Real highlighters need state across lines (a comment that spans lines), so a finished-lines cache must keep that state. Libraries handle it; knowing the cost tells you why they do.
06Real highlighting with Shiki
In an app, use Shiki or a Markdown renderer that bundles it (Streamdown's @streamdown/code plugin). Shiki returns an HTML string with coloured spans, and it escapes the code itself. That is the one place where setting HTML is acceptable, but only for Shiki's output, and only with a language name you checked against your own list.
Try it yourself
- 1
Take away the scrolling
In the first example, open the CSS tab and delete
overflow-x: auto;from theprerule. Run it.The long line is cut off at the edge of its box and nobody can reach the rest.
- 2
Press the real Copy button
In the first example, press the Copy button of the
bashblock.The note beside it says
denied. The preview is a secure context, sonavigator.clipboardexists, but this frame is not allowed to use the clipboard, andwriteTextrejects withNotAllowedError(a run of the button gavedenied). That is the second outcome, shown for real. - 3
Press the button while it writes
In the second example, press *Ask* by hand and try to press Copy while the caption says
writing.The button is disabled. Change
disabled={state !== "ready"}todisabled={false}and press Copy early: a half command would reach the clipboard. - 4
Make the highlighter smarter
In the third example, add the keyword
whileto the set and add a linewhile (true) {}to the snippet.Run it: all three call counts change, but the order of the three strategies stays the same.
Code & diagrams
The three clipboards at the end are stand-ins, so the result is the same on every browser. Press a Copy button yourself to use the real clipboard: in this preview the object exists (the page is a secure context), but the frame has no clipboard permission, so the real call rejects with `NotAllowedError` and the note beside the button says `denied`. On your own site, after a click, the same code says `copied`.
Preview
Console
Nothing logged yet.Expected console output
labels: bash, text, js
long line is wider than its box: true | the box can scroll: true | the page scrolls sideways: false
working clipboard: copied
permission refused: denied
no clipboard object: unavailableThe scripted visitor asks twice: a normal answer, then one that breaks inside the code. The line starting with AI_APICallError is the SDK's own logger; the user sees only the message the server chose.
Preview
Console
Nothing logged yet.Expected console output
code block is writing
code block is ready
status: ready
code block is writing
AI_APICallError: upstream connection lost
code block is cut off
status: error | The connection to the model was lost.The numbers are exact counts, not timings, so they are the same on every machine. Real highlighters are slower per character than this one, which makes the first row hurt more.
Preview
Console
Nothing logged yet.Expected console output
code length: 254 characters
first line: async:keyword function:keyword
colour the whole block on every update: {"calls":254,"scanned":32385}
colour each finished line once: {"calls":254,"scanned":5298}
colour once, when the block closes: {"calls":1,"scanned":254}shiki 4.5.0. codeToHtml loads the language and theme on demand; createHighlighter builds one shared highlighter. Names come from Shiki's install guide.
import { createHighlighter } from "shiki";
// Build ONCE (it is slow) and reuse it. Load only the languages you will show.
const LANGS = ["bash", "js", "ts", "json", "python"];
const highlighterPromise = createHighlighter({ themes: ["vitesse-dark"], langs: LANGS });
export async function highlight(code: string, label: string) {
const lang = LANGS.includes(label) ? label : "text"; // never pass an unchecked name from the model
const highlighter = await highlighterPromise;
// Shiki escapes the code itself; its output is the one HTML string you may set with dangerouslySetInnerHTML.
return highlighter.codeToHtml(code, { lang, theme: "vitesse-dark" });
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
No scrolling box
In the first example's CSS, delete overflow-x: auto; from the pre rule and run it.
Myth vs fact
Myth
Wrapping long code lines is friendlier.
Fact
A wrapped command can look like two commands, and indentation stops meaning anything. Scroll inside the box instead.
Myth
navigator.clipboard always works.
Fact
It is missing on pages that are not secure, can be refused by the browser, a permissions policy or a sandboxed frame (this very preview refuses it with NotAllowedError), and needs a user action. Handle all three.
Myth
Syntax highlighting is free decoration.
Fact
It runs a grammar over every character and loads language data. Colouring again on every token multiplies the work.
Myth
The language label is reliable.
Fact
It is whatever the model wrote. Check it against your own list and fall back to plain text.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Copy exactly what the user sees as code. Do not add a trailing newline you did not mean to, and do not include the prompt character (
$) if the model printed one in front of shell commands, unless you show it as a separate, non-selectable span. - ▸
Shiki's HTML is trusted only because Shiki escapes the code. If you pass untrusted text anywhere else in that string (a title, a filename), you have created an injection route. Keep the model's text out of the HTML string except as the code argument.
- ▸
For very long blocks, cap the number of lines you colour (say a few hundred) and show the rest as plain text with a note. Highlighting a 20,000-line log freezes the page.
- ▸
Dark mode matters here: a highlighter theme is a set of fixed colours. Pick a light and a dark theme and switch with the page theme, or the code box will glow white at night.
Remember this
- 1
The problem this solves: Models put commands and code inside answers all the time. If you draw that code like ordinary text, spaces collapse and a long line wraps in the wrong place or pushes the whole page sideways. Users want to copy it in one click. And while the answer streams, the block is unfinished, and a copy button that copies half a command is a bug.
- 2
Fences and the language label. A fenced block opens with three backticks, then an optional info string (
js,bash,python), and closes with three backticks on a line of their own. The label is whatever the model wrote:js,JS,javascript,node, or nothing. Treat it as a hint. Showtextwhen it is empty, and never crash on a name you do not know. - 3
Layout. Draw code in a
<pre>(which keeps spaces and line breaks) with a monospace font. Do not wrap long lines: a shell command wrapped in the middle looks like two commands. Let the block scroll sideways inside its box withoverflow-x: auto, and give ittabindex="0"so a keyboard user can scroll it. Inside flex or grid parents addmin-width: 0, or the block will push the page wider than the screen. - 4
The copy button uses the Clipboard API:
navigator.clipboard.writeText(text). It exists only on secure pages (HTTPS), it needs a user click, and it can fail withNotAllowedErrorif the browser refuses. Write the call so every outcome has a message: copied, refused, or no clipboard at all. Copy exactly the code, without the fences, the label, or line numbers. - 5
Three states while streaming. *Writing*: the opening fence has arrived and the closing one has not, and the stream is still open. *Ready*: the closing fence arrived. *Cut off*: the stream ended or broke while the fence was still open. Disable Copy unless the block is ready, and never leave a block saying "writing" after the stream is over.
- 6
Syntax highlighting has a cost. Colouring code means running a grammar (a set of rules) over every character. A library such as Shiki (4.5.0) does this accurately, but it loads language and theme data on demand, and the first call is slow. If you recolour the whole block on every new character, the work grows with the square of the block's length (the third example counts it). Colour once when the block closes, or colour finished lines once and only the growing line again.
- 7
Edge cases. A language with no grammar loaded: show plain text. A block of 5,000 lines: do not highlight it all; cap it or fold it. A closing fence inside the code (a Markdown file shown in a Markdown answer): the model should use four backticks outside, and your parser must match the length of the opening fence, as the renderer here does.
Explain it without notes
Why use overflow-x: auto on a code block instead of wrapping the lines?
What can go wrong with navigator.clipboard.writeText, and how should the button behave in each case?
A code block has three states while an answer streams. What are they and what changes in each?
Why does highlighting on every token cost so much, and how do you avoid it?
Practice
Add a "Download" link to the code block that saves the code as a file named after the language (track.js, commands.bash). Use a Blob and an object URL.
Make the code box show "Show all 120 lines" when a block has more than 20 lines, keeping only the first 20 until the user asks.
Trade-offs
- ↔
Colouring only when the block closes is cheap and never wrong, but the block is plain while it streams and then changes colour in one jump. Colouring per line keeps it lively at the price of a cache and grammar state.
- ↔
Bundling Shiki gives accurate colours for many languages and costs download size and start-up time. A tiny home-made highlighter is small and wrong for most real code. Load only the languages you actually show.
Done when you can
I can draw a code block with a language label and a scrolling box.
I can write a copy button that handles copied, refused and unavailable.
I can show writing, ready and cut-off states for a block that streams.
I can explain why recolouring on every token is expensive and name two ways around it.
I can use Shiki without passing it an unchecked language name.