Code block
Code with file header, tabs, copy button, line numbers, highlights and diffs. Syntax colors come from the theme and the accent.
One of 2 components in Code and JSON
Details
What Code block brings along, read from the registry and the source.
- Category
- Code and JSON
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/code-block.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 9 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CodeBlockProps interface from components/ui/code-block.tsx, with the comments from the code.
codeoptionalstringA single snippet. Use files for tabs.
languageoptionalCodeLanguagetsxtsjsxjscsshtmlbashjsontext
filenameoptionalstringFile name in the header of a single snippet
highlightoptionalLineRange[]Lines to highlight, e.g. [3, [7, 9]]
diffoptionalbooleanLines start with + (added), - (removed) or a space
filesoptionalCodeFile[]Several files as tabs
variantoptional'default' | 'terminal'default: a code card. terminal: a shell window, lines starting with "$ " are commands, the rest is output.
titleoptionalstringTitle in the terminal bar
lineNumbersoptionalbooleanDefault: on for code, off for the terminal
maxHeightoptionalnumberScroll vertically above this height in px
animateoptionalbooleanLines rise in when the block scrolls into view, the terminal types its commands. Default true.
Built-in texts
Code block writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 9 texts
| Key | English | German |
|---|---|---|
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| failed | English Copy failed | German Kopieren fehlgeschlagen |
| copyCode | English Copy code | German Code kopieren |
| code | English Code | German Code |
| files | English Files | German Dateien |
| terminal | English Terminal | German Terminal |
| added | English added | German hinzugefügt |
| removed | English removed | German entfernt |
Accessibility
Read from the source of the component.
- Code block handles the keys Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-controls, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles tab, tablist and tabs.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 847 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const TEXTS = {
en: { copy: 'Copy', copied: 'Copied', failed: 'Copy failed', copyCode: 'Copy code', code: 'Code', files: 'Files', terminal: 'Terminal', added: 'added', removed: 'removed' },
de: { copy: 'Kopieren', copied: 'Kopiert', failed: 'Kopieren fehlgeschlagen', copyCode: 'Code kopieren', code: 'Code', files: 'Dateien', terminal: 'Terminal', added: 'hinzugefügt', removed: 'entfernt' },
};
type Texts = (typeof TEXTS)['en'];
/*
* One typeface for the whole kit, code included: no monospace. Digits are
* tabular, and the indent of every line is a fixed width per column
* (INDENT em per space, a tab counts as two), so nested code still lines up
* although spaces in a proportional face are narrow.
*/
const INDENT = 0.6;
export type CodeLanguage = 'tsx' | 'ts' | 'jsx' | 'js' | 'css' | 'html' | 'bash' | 'json' | 'text';
/** A line number or an inclusive range [from, to], counted from 1 */
export type LineRange = number | [number, number];
/* ------------------------------------------------------------------ */
/* Tokenizer */
/* ------------------------------------------------------------------ */
type TokenType =
| 'plain' | 'comment' | 'string' | 'keyword' | 'literal' | 'number' | 'function' | 'type'
| 'tag' | 'attr' | 'property' | 'punct' | 'operator' | 'variable' | 'prompt' | 'output';
interface Token { t: TokenType; v: string }
const JS_KEYWORDS = new Set(
'import export from default const let var function return if else for while do switch case break continue new class extends async await try catch finally throw typeof instanceof in of as type interface enum implements public private protected readonly static get set delete yield void satisfies keyof declare abstract'.split(' '),
);
const JS_LITERALS = new Set(['true', 'false', 'null', 'undefined', 'this', 'NaN', 'Infinity']);
const TS_TYPES = new Set(['string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'bigint', 'symbol']);
/** Reads with sticky regexes from a position */
function scanner(src: string) {
let i = 0;
return {
get i() { return i; },
done: () => i >= src.length,
at(re: RegExp) {
re.lastIndex = i;
const m = re.exec(src);
return m && m[0].length ? m[0] : null;
},
take(n: number) { i += n; },
peek: (n = 0) => src[i + n] ?? '',
rest: () => src.slice(i),
};
}
type JsFrame = { k: 'brace' } | { k: 'jsx' } | { k: 'tag'; closing: boolean };
function tokenizeJs(src: string): Token[] {
const s = scanner(src);
const out: Token[] = [];
const stack: JsFrame[] = [];
const top = () => stack[stack.length - 1];
const lastSignificant = () => {
for (let j = out.length - 1; j >= 0; j--) if (out[j].v.trim()) return out[j];
return undefined;
};
const jsxAllowed = () => {
const p = lastSignificant();
if (!p) return true;
if (p.t === 'keyword') return true;
if (p.t === 'operator') return true;
if (p.t === 'punct') return p.v !== ')' && p.v !== ']';
return p.t === 'tag';
};
const push = (t: TokenType, v: string) => {
out.push({ t, v });
s.take(v.length);
};
while (!s.done()) {
const mode = top();
let m: string | null;
// Inside a JSX tag: attributes, strings, {expressions}, the end of the tag
if (mode?.k === 'tag') {
if ((m = s.at(/\s+/y))) push('plain', m);
else if ((m = s.at(/\/>/y))) {
stack.pop();
push('tag', m);
} else if ((m = s.at(/>/y))) {
const frame = stack.pop() as { k: 'tag'; closing: boolean };
if (frame.closing) {
if (top()?.k === 'jsx') stack.pop();
} else stack.push({ k: 'jsx' });
push('tag', m);
} else if ((m = s.at(/"[^"]*"?|'[^']*'?/y))) push('string', m);
else if ((m = s.at(/\{/y))) {
stack.push({ k: 'brace' });
push('punct', m);
} else if ((m = s.at(/[A-Za-z_$][\w$:-]*/y))) push('attr', m);
else if ((m = s.at(/=/y))) push('operator', m);
else push('plain', s.peek());
continue;
}
// JSX children: text until a tag or an expression
if (mode?.k === 'jsx') {
if ((m = s.at(/<\/?[A-Za-z][\w.:-]*|<\/?>/y))) {
const closing = m.startsWith('</');
if (m === '<>' ) {
stack.push({ k: 'jsx' });
push('tag', m);
} else if (m === '</>') {
stack.pop();
push('tag', m);
if (s.peek() === '>') push('tag', '>');
} else {
stack.push({ k: 'tag', closing });
push('tag', m);
}
} else if ((m = s.at(/\{/y))) {
stack.push({ k: 'brace' });
push('punct', m);
} else if ((m = s.at(/[^<{]+/y))) push('plain', m);
else push('plain', s.peek());
continue;
}
// Plain JavaScript and TypeScript
if ((m = s.at(/\s+/y))) push('plain', m);
else if ((m = s.at(/\/\/[^\n]*/y)) || (m = s.at(/\/\*[\s\S]*?(?:\*\/|$)/y))) push('comment', m);
else if ((m = s.at(/`(?:\\[\s\S]|[^`\\])*`?/y)) || (m = s.at(/"(?:\\.|[^"\\\n])*"?/y)) || (m = s.at(/'(?:\\.|[^'\\\n])*'?/y))) push('string', m);
else if (jsxAllowed() && (m = s.at(/<[A-Za-z][\w.:-]*|<>/y))) {
if (m === '<>') stack.push({ k: 'jsx' });
else stack.push({ k: 'tag', closing: false });
push('tag', m);
} else if ((m = s.at(/0[xX][\da-fA-F]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?/y))) push('number', m);
else if ((m = s.at(/[A-Za-z_$][\w$]*/y))) {
const prev = lastSignificant();
const after = src.slice(s.i + m.length).match(/^\s*(\(|<[A-Za-z][\w,\s<>[\]|]*>\s*\()?/);
if (JS_KEYWORDS.has(m) && prev?.v !== '.') push('keyword', m);
else if (JS_LITERALS.has(m)) push('literal', m);
else if (TS_TYPES.has(m) && prev?.v !== '.') push('type', m);
else if (after?.[1]) push('function', m);
else if (prev?.v === '.') push('property', m);
else if (/^[A-Z]/.test(m)) push('type', m);
else if (/^\s*:/.test(src.slice(s.i + m.length)) && (prev?.v === '{' || prev?.v === ',' || prev?.v === ';')) push('property', m);
else push('plain', m);
} else if ((m = s.at(/[{}()[\];,.]/y))) {
if (m === '{') stack.push({ k: 'brace' });
if (m === '}' && top()?.k === 'brace') stack.pop();
push('punct', m);
} else if ((m = s.at(/=>|\.\.\.|[=+\-*/%!<>&|?:^~]+/y))) push('operator', m);
else push('plain', s.peek());
}
return out;
}
function tokenizeCss(src: string): Token[] {
const s = scanner(src);
const out: Token[] = [];
let depth = 0;
let inValue = false;
const push = (t: TokenType, v: string) => {
out.push({ t, v });
s.take(v.length);
};
while (!s.done()) {
let m: string | null;
if ((m = s.at(/\s+/y))) push('plain', m);
else if ((m = s.at(/\/\*[\s\S]*?(?:\*\/|$)/y))) push('comment', m);
else if ((m = s.at(/"[^"\n]*"?|'[^'\n]*'?/y))) push('string', m);
else if ((m = s.at(/@[\w-]+/y))) push('keyword', m);
else if (depth > 0 && !inValue && (m = s.at(/--?[A-Za-z_][\w-]*(?=\s*:(?!:|[a-z-]+\s*[{,(]))/y))) {
push('property', m);
if ((m = s.at(/\s*:/y))) {
push('punct', m);
inValue = true;
}
} else if (inValue && (m = s.at(/#[\da-fA-F]{3,8}\b/y))) push('number', m);
else if (inValue && (m = s.at(/-?(?:\d*\.)?\d+(?:%|[a-zA-Z]+)?/y))) push('number', m);
else if ((m = s.at(/[\w-]+(?=\()/y))) push('function', m);
else if (inValue && (m = s.at(/--[\w-]+/y))) push('variable', m);
else if (inValue && (m = s.at(/![a-z]+/y))) push('keyword', m);
else if (inValue && (m = s.at(/[A-Za-z-][\w-]*/y))) push('literal', m);
else if (!inValue && (m = s.at(/[.#][\w-]+/y))) push('type', m);
else if (!inValue && (m = s.at(/::?[\w-]+/y))) push('keyword', m);
else if (!inValue && (m = s.at(/[A-Za-z][\w-]*/y))) push('tag', m);
else if ((m = s.at(/[{};]/y))) {
if (m === '{') depth++;
if (m === '}') depth = Math.max(0, depth - 1);
inValue = false;
push('punct', m);
} else if ((m = s.at(/[(),:]/y))) push('punct', m);
else if ((m = s.at(/[&>+~*=/]+/y))) push('operator', m);
else push('plain', s.peek());
}
return out;
}
function tokenizeHtml(src: string): Token[] {
const s = scanner(src);
const out: Token[] = [];
let inTag = false;
const push = (t: TokenType, v: string) => {
out.push({ t, v });
s.take(v.length);
};
while (!s.done()) {
let m: string | null;
if (inTag) {
if ((m = s.at(/\s+/y))) push('plain', m);
else if ((m = s.at(/\/?>/y))) {
inTag = false;
push('tag', m);
} else if ((m = s.at(/"[^"]*"?|'[^']*'?/y))) push('string', m);
else if ((m = s.at(/=/y))) push('operator', m);
else if ((m = s.at(/[^\s=>/"']+/y))) push('attr', m);
else push('plain', s.peek());
continue;
}
if ((m = s.at(/<!--[\s\S]*?(?:-->|$)/y))) push('comment', m);
else if ((m = s.at(/<![^>]*>?/y))) push('keyword', m);
else if ((m = s.at(/<\/?[A-Za-z][\w-]*/y))) {
inTag = true;
push('tag', m);
} else if ((m = s.at(/&#?\w+;/y))) push('number', m);
else if ((m = s.at(/[^<&]+/y))) push('plain', m);
else push('plain', s.peek());
}
return out;
}
const BASH_KEYWORDS = new Set('if then else elif fi for in do done case esac while until function return export local sudo'.split(' '));
function tokenizeBash(src: string): Token[] {
const s = scanner(src);
const out: Token[] = [];
let command = true;
const push = (t: TokenType, v: string) => {
out.push({ t, v });
s.take(v.length);
};
while (!s.done()) {
let m: string | null;
const prev = out[out.length - 1];
const afterSpace = !prev || /\s$/.test(prev.v);
if ((m = s.at(/\n/y))) {
command = true;
push('plain', m);
} else if ((m = s.at(/[ \t]+/y))) push('plain', m);
else if (afterSpace && (m = s.at(/#[^\n]*/y))) push('comment', m);
else if ((m = s.at(/"(?:\\.|[^"\\])*"?|'[^']*'?/y))) {
command = false;
push('string', m);
} else if ((m = s.at(/\$\{[^}\n]*\}?|\$[\w@#?$!*-]+/y))) push('variable', m);
else if ((m = s.at(/\\\n/y))) push('operator', m);
else if ((m = s.at(/\|\||&&|[|;&]|\$\(/y))) {
command = true;
push('operator', m);
} else if ((m = s.at(/[<>]+|[()]/y))) push('operator', m);
else if (afterSpace && (m = s.at(/--?[\w-]+(?:=)?/y))) push('attr', m);
else if ((m = s.at(/[^\s"'$|;&<>()]+/y))) {
if (BASH_KEYWORDS.has(m)) push('keyword', m);
else if (command && /^[\w./@~-]+$/.test(m) && !/=/.test(m)) {
command = false;
push('function', m);
} else push(/^\d+$/.test(m) ? 'number' : 'plain', m);
} else push('plain', s.peek());
}
return out;
}
function tokenizeJson(src: string): Token[] {
const s = scanner(src);
const out: Token[] = [];
const push = (t: TokenType, v: string) => {
out.push({ t, v });
s.take(v.length);
};
while (!s.done()) {
let m: string | null;
if ((m = s.at(/\s+/y))) push('plain', m);
else if ((m = s.at(/\/\/[^\n]*/y))) push('comment', m);
else if ((m = s.at(/"(?:\\.|[^"\\\n])*"?/y))) push(/^\s*:/.test(s.rest().slice(m.length)) ? 'property' : 'string', m);
else if ((m = s.at(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y))) push('number', m);
else if ((m = s.at(/true|false|null/y))) push('literal', m);
else if ((m = s.at(/[{}[\],:]/y))) push('punct', m);
else push('plain', s.peek());
}
return out;
}
/** Tokens for one language. Small on purpose: good enough for snippets, no grammar files. */
export function tokenize(code: string, language: CodeLanguage = 'text'): Token[] {
switch (language) {
case 'tsx': case 'ts': case 'jsx': case 'js': return tokenizeJs(code);
case 'css': return tokenizeCss(code);
case 'html': return tokenizeHtml(code);
case 'bash': return tokenizeBash(code);
case 'json': return tokenizeJson(code);
default: return [{ t: 'plain', v: code }];
}
}
/** Tokens split into lines, multi line comments and strings break cleanly */
function toLines(tokens: Token[]): Token[][] {
const lines: Token[][] = [[]];
for (const tok of tokens) {
const parts = tok.v.split('\n');
parts.forEach((part, i) => {
if (i > 0) lines.push([]);
if (part) lines[lines.length - 1].push({ t: tok.t, v: part });
});
}
return lines;
}
const EXT: Record<string, CodeLanguage> = { tsx: 'tsx', ts: 'ts', jsx: 'jsx', js: 'js', mjs: 'js', cjs: 'js', css: 'css', html: 'html', htm: 'html', sh: 'bash', bash: 'bash', zsh: 'bash', json: 'json' };
const LABEL: Record<CodeLanguage, string> = { tsx: 'TSX', ts: 'TypeScript', jsx: 'JSX', js: 'JavaScript', css: 'CSS', html: 'HTML', bash: 'Bash', json: 'JSON', text: 'Text' };
const languageOf = (file?: string): CodeLanguage => EXT[file?.split('.').pop()?.toLowerCase() ?? ''] ?? 'text';
const inRanges = (n: number, ranges?: LineRange[]) => !!ranges?.some((r) => (Array.isArray(r) ? n >= r[0] && n <= r[1] : n === r));
/** Splits the leading whitespace off a line of tokens: the indent text, its width in columns and the rest */
function splitIndent(tokens: Token[]) {
let ws = '';
const rest: Token[] = [];
let leading = true;
for (const tok of tokens) {
if (!leading) {
rest.push(tok);
continue;
}
const m = tok.v.match(/^[ ]*/)![0];
ws += m;
if (m.length < tok.v.length) {
leading = false;
rest.push({ t: tok.t, v: tok.v.slice(m.length) });
}
}
const cols = Array.from(ws).reduce((a, c) => a + (c === ' ' ? 2 : 1), 0);
return { ws, cols, rest };
}
/** The first characters of a token line, for the typing terminal */
function sliceTokens(tokens: Token[], count: number): [Token[], Token[]] {
const head: Token[] = [];
const tail: Token[] = [];
let left = count;
for (const tok of tokens) {
if (left >= tok.v.length) {
head.push(tok);
left -= tok.v.length;
} else if (left > 0) {
head.push({ t: tok.t, v: tok.v.slice(0, left) });
tail.push({ t: tok.t, v: tok.v.slice(left) });
left = 0;
} else tail.push(tok);
}
return [head, tail];
}
const renderTokens = (tokens: Token[]) => tokens.map((tok, j) => (tok.t === 'plain' ? tok.v : <span key={j} className={`tk-${tok.t}`}>{tok.v}</span>));
/* ------------------------------------------------------------------ */
/* Clipboard */
/* ------------------------------------------------------------------ */
async function writeClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
}
/* ------------------------------------------------------------------ */
/* Styles: token colors from the theme, terminal turns dark in light */
/* ------------------------------------------------------------------ */
const STYLE = `
.kitcb{--cb-bg:var(--card);--cb-head:var(--surface);--cb-fg:var(--foreground);--cb-muted:var(--muted-foreground);--cb-subtle:var(--subtle-foreground);--cb-accent:var(--primary);--cb-line:var(--border)}
.kitcb-term{--cb-bg:color-mix(in oklch,var(--foreground) 95%,var(--background));--cb-head:color-mix(in oklch,var(--foreground) 90%,var(--background));--cb-fg:color-mix(in oklch,var(--background) 94%,var(--foreground));--cb-muted:color-mix(in oklch,var(--background) 64%,var(--foreground));--cb-subtle:color-mix(in oklch,var(--background) 46%,var(--foreground));--cb-accent:color-mix(in oklch,var(--primary) 55%,var(--background));--cb-line:color-mix(in oklch,var(--background) 11%,transparent)}
.dark .kitcb-term{--cb-bg:var(--background);--cb-head:var(--surface);--cb-fg:var(--foreground);--cb-muted:var(--muted-foreground);--cb-subtle:var(--subtle-foreground);--cb-accent:var(--primary);--cb-line:var(--border)}
.kitcb .tk-keyword{color:color-mix(in oklch,var(--cb-accent) 88%,var(--cb-fg));font-weight:560}
.kitcb .tk-tag{color:color-mix(in oklch,var(--cb-accent) 78%,var(--cb-fg));font-weight:560}
.kitcb .tk-function{color:var(--cb-fg);font-weight:560}
.kitcb .tk-type{color:color-mix(in oklch,var(--cb-accent) 58%,var(--cb-fg))}
.kitcb .tk-string{color:color-mix(in oklch,var(--cb-accent) 42%,var(--cb-muted))}
.kitcb .tk-number,.kitcb .tk-literal,.kitcb .tk-variable{color:color-mix(in oklch,var(--cb-accent) 70%,var(--cb-fg))}
.kitcb .tk-attr{color:color-mix(in oklch,var(--cb-accent) 34%,var(--cb-muted))}
.kitcb .tk-property{color:color-mix(in oklch,var(--cb-fg) 80%,var(--cb-accent))}
.kitcb .tk-comment{color:var(--cb-subtle);font-style:italic}
.kitcb .tk-punct,.kitcb .tk-operator{color:var(--cb-muted)}
.kitcb .tk-prompt{color:var(--cb-accent);user-select:none;font-weight:600}
.kitcb .tk-output{color:var(--cb-muted)}
@keyframes kitcb-ln{from{opacity:0;transform:translateY(8px)}}
@keyframes kitcb-caret{50%{opacity:0}}
.kitcb[data-anim=run] .kitcb-ln{animation:kitcb-ln .5s var(--ease-kit) both;animation-delay:calc(min(var(--i),24) * 28ms)}
.kitcb[data-anim=swap] .kitcb-ln{animation:kitcb-ln .35s var(--ease-kit) both;animation-delay:calc(min(var(--i),16) * 14ms)}
.kitcb[data-anim=wait] .kitcb-ln{opacity:0}
.kitcb-out{transition:opacity .35s var(--ease-kit),transform .35s var(--ease-kit)}
.kitcb-out[data-hidden]{opacity:0;transform:translateY(6px)}
.kitcb-caret{animation:kitcb-caret 1s steps(1) infinite}
@keyframes kitcb-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitcb[data-pending] code>span{animation:kitcb-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitcb .kitcb-ln,.kitcb .kitcb-caret{animation:none!important}.kitcb-out{transition:none}}
`;
/**
* The entrance of a code block: 'final' on the server, without JavaScript
* and with reduced motion. In the browser 'wait' until it scrolls in, then
* 'run'. 'swap' replays a short version after a tab change.
*/
type Entrance = 'final' | 'wait' | 'run' | 'swap';
function useEntrance<T extends Element>(enabled: boolean) {
const ref = useRef<T>(null);
const [state, setState] = useState<Entrance>('final');
useEffect(() => {
const el = ref.current;
if (!enabled || !el || prefersReducedMotion() || typeof IntersectionObserver === 'undefined') return;
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return;
setState('run');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, [enabled]);
return [ref, state, setState] as const;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export interface CodeFile {
/** File name, shown as the tab. The extension picks the language when none is given. */
name: string;
code: string;
language?: CodeLanguage;
highlight?: LineRange[];
/** Lines start with + (added), - (removed) or a space */
diff?: boolean;
}
export interface CodeBlockProps {
/** A single snippet. Use files for tabs. */
code?: string;
language?: CodeLanguage;
/** File name in the header of a single snippet */
filename?: string;
/** Lines to highlight, e.g. [3, [7, 9]] */
highlight?: LineRange[];
/** Lines start with + (added), - (removed) or a space */
diff?: boolean;
/** Several files as tabs */
files?: CodeFile[];
/** default: a code card. terminal: a shell window, lines starting with "$ " are commands, the rest is output. */
variant?: 'default' | 'terminal';
/** Title in the terminal bar */
title?: string;
/** Default: on for code, off for the terminal */
lineNumbers?: boolean;
/** Scroll vertically above this height in px */
maxHeight?: number;
/** Lines rise in when the block scrolls into view, the terminal types its commands. Default true. */
animate?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/**
* Code with a file header or tabs, copy button, line numbers, highlighted lines and diffs.
* Syntax colors come from the theme (foreground, muted and the accent), long lines scroll inside the block.
* <CodeBlock filename="button.tsx" code={source} highlight={[4, [8, 10]]} />
* <CodeBlock files={[{ name: 'app.tsx', code: a }, { name: 'app.css', code: b }]} />
* <CodeBlock variant="terminal" code={"$ npm run build\nDone in 4.2s"} />
*/
export function CodeBlock({
code = '',
language,
filename,
highlight,
diff,
files,
variant = 'default',
title,
lineNumbers,
maxHeight,
animate = true,
labels,
className,
}: CodeBlockProps) {
const t = useTexts(TEXTS, labels);
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '');
const terminal = variant === 'terminal';
const list: CodeFile[] = files?.length ? files : [{ name: filename ?? '', code, language, highlight, diff }];
const tabs = list.length > 1;
const [active, setActive] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const file = list[Math.min(active, list.length - 1)];
const lang: CodeLanguage = terminal ? 'bash' : (file.language ?? languageOf(file.name));
const numbers = lineNumbers ?? !terminal;
const [rootRef, anim, setAnim] = useEntrance<HTMLDivElement>(animate);
// Until the script runs the lines wait hidden (at most 2.5 s), so they do not flash before they rise or type
const [pending, setPending] = useState(animate);
useEffect(() => setPending(false), []);
const scrollRef = useRef<HTMLDivElement>(null);
const [more, setMore] = useState(false);
const measure = () => {
const el = scrollRef.current;
if (el) setMore(el.scrollWidth - el.clientWidth - el.scrollLeft > 4);
};
useEffect(() => {
measure();
const el = scrollRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [active]);
/* The tab marker glides to the active tab. Before it is measured every tab draws its own. */
const listRef = useRef<HTMLDivElement>(null);
const [marker, setMarker] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
if (!tabs) return;
const place = () => {
const tab = tabRefs.current[active];
if (tab && tab.offsetWidth) setMarker({ x: tab.offsetLeft + 10, w: Math.max(4, tab.offsetWidth - 20) });
};
place();
if (typeof ResizeObserver === 'undefined' || !listRef.current) return;
const ro = new ResizeObserver(place);
ro.observe(listRef.current);
return () => ro.disconnect();
}, [active, tabs]);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
/* Lines with their kind and tokens */
const lines = useMemo(() => {
const raw = file.code.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
const kinds = raw.map((l) => (file.diff ? (l.startsWith('+') ? 'add' : l.startsWith('-') ? 'del' : 'ctx') : terminal ? (l.startsWith('$ ') || l === '$' ? 'cmd' : 'out') : 'ctx'));
const body = raw.map((l, i) => (file.diff ? l.slice(1) : kinds[i] === 'cmd' ? l.slice(2) : l));
if (terminal) {
return body.map((l, i) =>
kinds[i] === 'cmd' ? { kind: 'cmd', tokens: [{ t: 'prompt', v: '$ ' } as Token, ...tokenize(l, 'bash')] } : { kind: 'out', tokens: [{ t: 'output', v: l } as Token] },
);
}
const tokenLines = toLines(tokenize(body.join('\n'), lang));
return tokenLines.map((tokens, i) => ({ kind: kinds[i] ?? 'ctx', tokens }));
}, [file.code, file.diff, terminal, lang]);
/* Terminal: the commands type themselves, the output follows line by line */
const [typed, setTyped] = useState<{ line: number; chars: number } | null>(null);
useEffect(() => {
if (!terminal) return;
if (anim === 'wait') return setTyped({ line: 0, chars: 0 });
if (anim !== 'run') return setTyped(null);
let line = 0;
let chars = 0;
let id = 0;
const lengths = lines.map((l) => (l.kind === 'cmd' ? l.tokens.slice(1).reduce((a, tok) => a + tok.v.length, 0) : 0));
const step = () => {
if (line >= lines.length) return setTyped(null);
if (lines[line].kind === 'cmd' && chars < lengths[line]) {
chars += 1;
setTyped({ line, chars });
// Short commands type at a calm pace, long ones a little faster
id = window.setTimeout(step, chars >= lengths[line] ? 240 : Math.max(12, Math.min(34, 640 / Math.max(1, lengths[line]))));
return;
}
line += 1;
chars = 0;
setTyped({ line, chars });
id = window.setTimeout(step, line < lines.length && lines[line].kind === 'out' ? 90 : 260);
};
id = window.setTimeout(step, 360);
return () => window.clearTimeout(id);
}, [terminal, anim, lines]);
/* After the entrance the lines stand still, so a tab change can replay a short version */
useEffect(() => {
if (anim !== 'run' || terminal) return;
const id = window.setTimeout(() => setAnim('final'), Math.min(lines.length, 24) * 28 + 600);
return () => window.clearTimeout(id);
}, [anim, terminal, lines.length, setAnim]);
const copyText = useMemo(() => {
const raw = file.code.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
if (terminal) return raw.filter((l) => l.startsWith('$ ')).map((l) => l.slice(2)).join('\n');
if (file.diff) return raw.filter((l) => !l.startsWith('-')).map((l) => l.slice(1)).join('\n');
return raw.join('\n');
}, [file.code, file.diff, terminal]);
const copyRef = useRef<HTMLButtonElement>(null);
const copy = async () => {
const ok = await writeClipboard(copyText);
setCopyState(ok ? 'copied' : 'failed');
if (!prefersReducedMotion())
copyRef.current?.animate(ok ? [{ transform: 'scale(1)' }, { transform: 'scale(0.93)' }, { transform: 'scale(1)' }] : [{ transform: 'translateX(0)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(0)' }], { duration: ok ? 260 : 300, easing: 'cubic-bezier(0.34,1.56,0.64,1)' });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopyState('idle'), 1800);
};
const select = (n: number) => {
if (n === active) return;
setActive(n);
if (!prefersReducedMotion() && animate) setAnim('swap');
};
const onTabKey = (e: React.KeyboardEvent, i: number) => {
const next = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? list.length - 1 : null;
if (next === null) return;
e.preventDefault();
const n = (next + list.length) % list.length;
select(n);
tabRefs.current[n]?.focus();
};
const done = copyState === 'copied';
const copyButton = (
<button
ref={copyRef}
type="button"
onClick={copy}
aria-label={t.copyCode}
className="relative inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-[var(--cb-muted)] transition-colors duration-150 after:absolute after:-inset-1.5 after:content-[''] hover:bg-[color-mix(in_oklch,var(--cb-fg)_7%,transparent)] hover:text-[var(--cb-fg)] sm:px-2.5"
>
<span className="relative grid size-4 place-items-center">
<Icon name="copy" size={15} className={cn('absolute transition-[opacity,scale] duration-200 motion-reduce:transition-none', done && 'scale-50 opacity-0')} />
{/* The check draws itself once the code is on the clipboard */}
<svg aria-hidden viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="absolute text-success">
<path
d="M5 12.5l4.5 4.5L19 7.5"
pathLength={1}
strokeDasharray="1"
strokeDashoffset={done ? 0 : 1}
className={done ? 'transition-[stroke-dashoffset] delay-75 duration-[420ms] ease-[var(--ease-kit)] motion-reduce:transition-none' : undefined}
/>
</svg>
</span>
<span className="max-sm:sr-only">{done ? t.copied : t.copy}</span>
</button>
);
const live = (
<span aria-live="polite" className="sr-only">
{copyState === 'copied' ? t.copied : copyState === 'failed' ? t.failed : ''}
</span>
);
const panelId = `${uid}-panel`;
const gutter = numbers || file.diff;
const digits = String(lines.length).length;
return (
<div
ref={rootRef}
data-anim={anim === 'final' ? undefined : anim}
data-pending={pending ? '' : undefined}
className={cn(
'kitcb w-full min-w-0 overflow-hidden rounded-xl border bg-[var(--cb-bg)] text-[var(--cb-fg)] shadow-[var(--highlight),var(--shadow-md)]',
terminal ? 'kitcb-term border-[var(--cb-line)] shadow-[var(--shadow-lg)]' : 'border-border',
className,
)}
>
<style>{STYLE}</style>
{/* Header */}
<div className="flex h-11 items-center gap-2 border-b border-[var(--cb-line)] bg-[var(--cb-head)] pl-1 pr-1.5">
{terminal ? (
<>
<div className="flex shrink-0 gap-1.5 pl-3" aria-hidden>
{[0, 1, 2].map((i) => (
<span key={i} className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--cb-fg)_22%,transparent)]" />
))}
</div>
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-[12px] font-medium text-[var(--cb-muted)]">
<Icon name="terminal" size={14} className="shrink-0" />
<span className="truncate">{title ?? t.terminal}</span>
</div>
</>
) : tabs ? (
<div ref={listRef} role="tablist" aria-label={t.files} className="kit-no-scrollbar relative flex min-w-0 flex-1 self-stretch overflow-x-auto">
{list.map((f, i) => (
<button
key={f.name + i}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${uid}-tab-${i}`}
aria-selected={i === active}
aria-controls={panelId}
tabIndex={i === active ? 0 : -1}
onClick={() => select(i)}
onKeyDown={(e) => onTabKey(e, i)}
className={cn(
'relative inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-[13px] font-medium outline-offset-[-2px] transition-colors duration-150',
i === active ? 'text-[var(--cb-fg)]' : 'text-[var(--cb-muted)] hover:text-[var(--cb-fg)]',
)}
>
<Icon name="file-code" size={14} className={cn('transition-colors duration-300', i === active && 'text-[color-mix(in_oklch,var(--cb-accent)_80%,var(--cb-fg))]')} />
{f.name}
{!marker && <span aria-hidden className={cn('absolute inset-x-2.5 bottom-0 h-0.5 rounded-full bg-[var(--cb-accent)]', i === active ? 'opacity-100' : 'opacity-0')} />}
</button>
))}
{marker && (
<span
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 h-0.5 w-[100px] origin-left rounded-full bg-[var(--cb-accent)] transition-transform duration-[400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `translateX(${marker.x}px) scaleX(${round(marker.w / 100, 3)})` }}
/>
)}
</div>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 pl-2.5 text-[13px] font-medium text-[var(--cb-fg)]">
{file.name ? (
<>
<Icon name="file-code" size={15} className="shrink-0 text-[var(--cb-muted)]" />
<span className="truncate">{file.name}</span>
</>
) : (
<span className="text-[12px] text-[var(--cb-muted)]">{LABEL[lang]}</span>
)}
</div>
)}
{!terminal && (tabs || file.name) && <span className="hidden shrink-0 text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--cb-subtle)] sm:inline">{LABEL[lang]}</span>}
{copyButton}
{live}
</div>
{/* Code */}
<div className="relative">
<div
ref={scrollRef}
onScroll={measure}
id={panelId}
role={tabs ? 'tabpanel' : 'region'}
aria-labelledby={tabs ? `${uid}-tab-${active}` : undefined}
aria-label={tabs ? undefined : `${terminal ? t.terminal : t.code}${file.name ? `, ${file.name}` : ''}`}
tabIndex={0}
className="kit-scrollbar overflow-auto outline-offset-[-2px]"
style={maxHeight ? { maxHeight } : undefined}
>
<pre className="m-0 py-3.5 text-[13px] leading-[1.7] tabular-nums sm:text-[13.5px]" style={{ fontFamily: 'var(--font-sans)', tabSize: 2 }}>
<code key={active} className="block w-max min-w-full" style={{ fontFamily: 'var(--font-sans)' }}>
{lines.map((line, i) => {
const n = i + 1;
const hl = inRanges(n, file.highlight);
const add = line.kind === 'add';
const del = line.kind === 'del';
const row = hl
? 'color-mix(in oklch, var(--cb-accent) 9%, var(--cb-bg))'
: add
? 'color-mix(in oklch, var(--success) 11%, var(--cb-bg))'
: del
? 'color-mix(in oklch, var(--destructive) 9%, var(--cb-bg))'
: 'var(--cb-bg)';
const { ws, cols, rest } = splitIndent(line.tokens);
// Terminal typing: this line is typed up to `chars`, later lines wait
let content: React.ReactNode = renderTokens(rest);
let hidden = false;
let caret = false;
if (terminal && typed) {
if (i > typed.line || (i === typed.line && line.kind === 'out')) hidden = true;
else if (i === typed.line && line.kind === 'cmd') {
const [head, tail] = sliceTokens(rest, 2 + typed.chars);
content = (
<>
{renderTokens(head)}
<span aria-hidden className="relative inline-block w-0 align-top">
<span className="kitcb-caret absolute left-px top-[0.25em] h-[1.2em] w-[2px] rounded-[1px] bg-[var(--cb-accent)]" />
</span>
<span className="opacity-0">{renderTokens(tail)}</span>
</>
);
caret = true;
}
}
return (
<span
key={i}
data-hidden={hidden ? '' : undefined}
className={cn('flex', terminal ? line.kind === 'out' && 'kitcb-out' : 'kitcb-ln', caret && 'relative')}
style={{ background: row, ...(terminal ? {} : ({ '--i': i } as React.CSSProperties)) }}
>
{gutter && (
<span
aria-hidden
className="sticky left-0 z-[1] flex shrink-0 select-none justify-end gap-2 pl-3 pr-4 text-right tabular-nums sm:pl-4"
style={{
background: row,
boxShadow: hl ? 'inset 2px 0 var(--cb-accent)' : add ? 'inset 2px 0 var(--success)' : del ? 'inset 2px 0 var(--destructive)' : undefined,
color: hl ? 'var(--cb-fg)' : 'var(--cb-subtle)',
}}
>
{numbers && <span style={{ minWidth: `${digits}ch` }}>{n}</span>}
{file.diff && (
<span className={cn('w-[1ch]', add && 'text-success-foreground', del && 'text-destructive')}>{add ? '+' : del ? '-' : ' '}</span>
)}
</span>
)}
{(add || del) && <span className="sr-only">{add ? t.added : t.removed} </span>}
<span className={cn('block whitespace-pre pr-6', gutter ? 'pl-0' : 'pl-4 sm:pl-5', del && 'opacity-70', terminal && line.kind === 'cmd' && hidden && 'opacity-0')}>
{ws && (
<span className="inline-block" style={{ width: `${round(cols * INDENT, 2)}em` }}>
{ws}
</span>
)}
{line.tokens.length ? content : ' '}
</span>
</span>
);
})}
</code>
</pre>
</div>
<span aria-hidden className={cn('pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[var(--cb-bg)] to-transparent transition-opacity duration-200', more ? 'opacity-100' : 'opacity-0')} />
</div>
</div>
);
}Frequent questions
How do I install Code block?
With aiw add @ai-website/code-block. The command puts components/ui/code-block.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Code block have?
4 variants, Single file, Tabs, Highlights and diff and Terminal.
Can I translate the texts of Code block?
Yes. Code block carries 9 texts of its own in English and German, such as “Copy”, “Copied” and “Copy failed”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Code block?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Code block with aiw_components and install it with aiw_add @ai-website/code-block. Name the variant in your prompt, for example variant="terminal", and the assistant writes your content straight into the props.