CodeBlock
Token-coloured code display with copy support.
Live demo
01Live preview
Mix and match variants, sizes, themes, and languages.
interface User {
name: string;
age: number;
active: boolean;
}
β
const ada: User = {
name: 'Ada Lovelace',
age: 36,
active: true
};
β
function greet(user: User): string {
return `Hello, ${user.name}!`;
}Variant gallery
Plain
interface User {
name: string;
age: number;
active: boolean;
}
β
const ada: User = {
name: 'Ada Lovelace',
age: 36,
active: true
};
β
function greet(user: User): string {
return `Hello, ${user.name}!`;
}Lined Β· highlight
interface User {
name: string;
age: number;
active: boolean;
}
β
const ada: User = {
name: 'Ada Lovelace',
age: 36,
active: true
};
β
function greet(user: User): string {
return `Hello, ${user.name}!`;
}Titled
interface User {
name: string;
age: number;
active: boolean;
}
β
const ada: User = {
name: 'Ada Lovelace',
age: 36,
active: true
};
β
function greet(user: User): string {
return `Hello, ${user.name}!`;
}Diff
function greet(name) {
console.log('hi ' + name);
console.log(`hi ${name}`);
return name;
}
β
const names = ['world'];
names.forEach(n => greet(n));
for (const n of names) {
greet(n);
}Terminal
#!/bin/bash
set -euo pipefail
β
BACKUP_DIR="/var/backups/site"
DATE=$(date +%Y-%m-%d)
β
if [ ! -d "$BACKUP_DIR" ]; then
mkdir -p "$BACKUP_DIR"
fi
β
for f in *.txt; do
echo "Archiving $f"
doneLight theme
{
"name": "tfe-svelte-templates",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build"
}
}Implementation
02<script lang="ts">
import CodeBlock from '$lib/components/CodeBlock.svelte';
const code = 'function add(a: number, b: number) { return a + b; }';
</script>
β
<CodeBlock {code} language="ts" variant="lined" theme="dark" />CodeBlock pairs a small in-house tokenizer (~5 KB) with five visual variants and three sizes. Plain renders a bare pre, lined adds a gutter, titled renders a chrome header, diff highlights +/- lines, terminal renders a window with a shell prompt. The CodeBlock above this paragraph (in the Implementation panel) is rendered automatically by the page shell β the live demo below shows variants, languages, and sizes.
Logic explainer
03What Does It Do? (Plain English)
CodeBlock renders a string of source code with token-coloured syntax highlighting, optional line numbers, optional highlighted line ranges, a header bar with title or filename, and a copy-to-clipboard button. Five visual variants (plain, lined, titled, diff, terminal), three sizes, six languages, light/dark themes β but built on a tiny in-house tokenizer ($lib/tokenize, ~5 KB) instead of a 200 KB highlighter dependency.
Think of it as the lightweight, copy-paste-portable middle ground between a <pre><code> and dragging Shiki/Prism into your bundle.
How It Works (Pseudo-Code)
state:
copyState = 'idle' | 'copied' | 'failed'
showCopyButton = supportsClipboardAPI() && copyable
derive (pure helpers, all exported):
resolvedVariant = pickVariant(variant) // safe fallback
resolvedSize = pickSize(size) // safe fallback
detectedLanguage = language ?? detectLanguage(code)
tokens = tokenize(code, detectedLanguage)
lineCount = countLines(code)
highlightedLines = parseLineRange(highlight) // Set<number>
showHeader = !!(title || fileName)
showLineNumbers = lineNumbers ?? variantDefault[variant]
events:
on copy click:
if !supportsClipboardAPI(): return
success = await copyToClipboard(code)
copyState = success ? 'copied' : 'failed'
setTimeout(() => copyState = 'idle', isReducedMotion ? 0 : 1500)
render flow:
for each line in tokens.split-on-newlines:
if showLineNumbers:
render <span class="gutter">{formatLineNumber(n, lineCount)}</span>
render line with token spans:
for each token in line:
<span class="tok-{token.type}">{escapeHtml(token.value)}</span>
if highlightedLines.has(n): apply .highlight to the rowThe component is a thin shell β every interesting bit (tokenizing, language detection, line-range parsing, clipboard probe, reduced-motion probe) is a pure exported helper, individually unit-testable.
The Core Concept: In-House Tokenizer
The signature trade-off here is "ship a small tokenizer, accept it can't handle every language perfectly" vs "ship Shiki, accept the bundle hit". CodeBlock chose the small tokenizer for three reasons.
One β most demos don't need perfect. A docs site, a marketing landing, a UI kit demo β these need TS/JS/Svelte/JSON/bash recognisable, not C++ template-metaprogramming-correct.
Two β the output format is identical. tokenize(code, language) returns Token[] ({ type: 'keyword' | 'string' | 'comment' | ...; value: string }). The component walks that array and emits <span class="tok-{type}">{escapeHtml(value)}</span>. Replacing the tokenizer with Shiki later is a one-import change.
Three β XSS hygiene is owned. Token values are run through escapeHtml before being inserted via {@html}. The component never inserts user code as raw HTML β even though the input is "code", treating it as untrusted bytes is the safe default.
code = "function add(a: number, b: number) { return a + b; }"
tokenize(code, 'ts') β
[
{ type: 'keyword', value: 'function' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'add' },
{ type: 'punctuation', value: '(' },
{ type: 'identifier', value: 'a' },
{ type: 'punctuation', value: ':' },
...
]
emit:
<span class="tok-keyword">function</span>
<span> </span>
<span class="tok-identifier">add</span>
<span class="tok-punctuation">(</span>
...detectLanguage(code) runs heuristics (shebangs, JSX/TSX braces, <script lang="ts">, import syntax) when language isn't passed, so a consumer can drop a code string in without knowing which dialect it is.
XSS Protection
Code is user-supplied bytes. Each token's value field passes through escapeHtml before {@html} insertion:
escapeHtml('<script>alert(1)</script>')
// β '<script>alert(1)</script>'The {@html} insertion is then safe because:
- The tokenizer produces only
{ type, value }shapes β no HTML payload. valueis escape-encoded.- The component owns the wrapping
<span class="tok-...">and never trusts a token to carry markup.
If the tokenizer is replaced (e.g. with Shiki, which produces HTML directly), the swap point is the only place where the trust boundary moves β but the comment in the source flags this explicitly: "XSS-safe because we own every byte we insert."
Performance
- One linear tokenizer pass per render. Output is cached via
$derivedβ re-renders that don't changecodeorlanguagereuse the token array. - Suitable for snippets up to a few thousand lines of code. Past that, consider streaming or virtual scrolling β but the tokenizer cost is dominated by the
<span>flood at that scale, not the lex. parseLineRangeis forgiving: whitespace tolerant, reversed ranges normalised, non-numeric tokens dropped β single linear pass over the comma-separated string.- Copy button is gated on
supportsClipboardAPI()(feature-detected once at mount); on browsers without it, the button doesn't render, so users never click a dead control. - Reduced-motion users get instant copy feedback (no fade timeout) instead of a 1500ms transient β
aria-live="polite"still announces the result.
CSS Animation Strategy
CodeBlock has very little animation. The copy-button feedback is a brief opacity fade on the "Copied!" / "Failed" status text β gated by prefers-reduced-motion, which makes it instant. The line-highlight ranges are static background colours with no entrance animation. The terminal variant has no animations whatsoever, by design β it's meant to feel like a frozen screenshot of a real shell.
State Flow Diagram
[mounted]
β
β derive: tokens, lineCount, highlightedLines, showCopyButton
βΌ
[rendering] syntax-highlighted, gutter optional, header optional
β
β user clicks copy
βΌ
[copying] await navigator.clipboard.writeText(code)
β
ββ success βββΆ [copied] aria-live polite: "Copied"
β β
β β 1500ms timeout (or 0 if reduced-motion)
β βΌ
β [idle]
β
ββ failure βββΆ [failed] aria-live polite: "Copy failed"
β
β 1500ms timeout
βΌ
[idle]
Browser without Clipboard API:
copy button never renders. User copies via OS-level select-all.Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
code |
string |
required | Source text to render. |
language |
Language | string |
auto-detected | Language hint for the tokenizer. |
variant |
'plain' | 'lined' | 'titled' | 'diff' | 'terminal' |
'plain' |
Visual variant β lined adds gutter, titled adds header, diff colours +/- rows, terminal swaps to a shell-like skin. |
size |
'sm' | 'md' | 'lg' |
'md' |
Type scale and padding. |
title |
string |
undefined |
Header label (forces header to render). |
fileName |
string |
undefined |
Header label, monospaced (forces header to render). |
lineNumbers |
boolean |
variant default | Override the variant's gutter default. |
highlight |
string |
undefined |
Comma-separated 1-based line ranges ("1,3-5,8"). |
wrap |
boolean |
false |
Soft-wrap long lines instead of horizontal scroll. |
copyable |
boolean |
true |
Show the copy button when the Clipboard API is supported. |
theme |
'light' | 'dark' |
'dark' |
Colour palette. terminal variant ignores this. |
aria-label |
string |
'Code' |
Region label for screen readers. |
Edge Cases
| Situation | Behaviour |
|---|---|
code is empty string |
countLines returns 1 (an empty line); gutter shows 1, no tokens emitted. |
code ends with trailing newline |
countLines strips one trailing empty line; the gutter doesn't show a phantom blank row. |
code contains CRLF or CR line endings |
Normalised to \n for line counting and rendering. |
language is unrecognised |
Tokenizer falls back to 'plain' β code renders escaped but uncoloured. |
highlight = "5-3" (reversed range) |
parseLineRange swaps lo/hi so it works as 3-5. |
highlight = "abc, 2, 99-" |
Non-numeric tokens dropped; 2 is added; 99- is dropped (incomplete range). |
User has prefers-reduced-motion: reduce |
Copy feedback is instant β no fade timeout. |
Browser without navigator.clipboard.writeText |
supportsClipboardAPI() returns false; copy button doesn't render. |
code contains <script>...</script> literal |
escapeHtml encodes it; renders as literal text, never executes. |
| Line counts past 99 | formatLineNumber pads to the width of the largest number; gutter aligns under tabular-nums. |
Dependencies
- Svelte 5.x β
$state,$derived,$props, module-script exports. $lib/tokenizeβ in-house tokenizer (~5 KB). Providestokenize,detectLanguage,escapeHtml,Language,Tokentypes.navigator.clipboard(native) β feature-detected; copy button hidden when missing.- Zero external dependencies otherwise β no Shiki, no Prism, no Highlight.js.
File Structure
src/lib/components/CodeBlock.svelte # implementation
src/lib/components/CodeBlock.md # this file (rendered inside ComponentPageShell)
src/lib/components/CodeBlock.test.ts # vitest unit tests for the pure helpers
src/lib/components/CodeBlockTestHarness.test.svelte # rendering harness for tokenizer integration
src/lib/tokenize.ts # in-house tokenizer + language detection
src/routes/codeblock/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
code | string | required | Source text to render. |
language | "ts" | "js" | "svelte" | "json" | "bash" | "plain" | auto | Override the heuristic detector. |
variant | "plain" | "lined" | "titled" | "diff" | "terminal" | "plain" | Visual layout. |
size | "sm" | "md" | "lg" | "md" | Type scale and padding. |
title | string | undefined | Header label; forces header on plain variant. |
fileName | string | undefined | Header filename, monospaced. |
lineNumbers | boolean | auto | Override gutter visibility. |
highlight | string | undefined | 1-based line ranges, e.g. "1,3-5". |
wrap | boolean | false | Soft-wrap long lines. |
copyable | boolean | true | Show the copy button. |
theme | "light" | "dark" | "dark" | Colour palette (terminal ignores). |
aria-label | string | "Code" | Region label for screen readers. |