Codeblock
Code mit Dateikopf, Reitern, Kopierknopf, Zeilennummern, Hervorhebungen und Diffs. Die Syntaxfarben kommen aus dem Theme und dem Akzent.
Eine von 2 Components in Code und JSON
Variants
Details
Was Codeblock mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Code und JSON
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/code-block.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 9 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CodeBlockProps aus components/ui/code-block.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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.
Eingebaute Texte
Diese Texte schreibt Codeblock selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 9 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| failed | Englisch Copy failed | Deutsch Kopieren fehlgeschlagen |
| copyCode | Englisch Copy code | Deutsch Code kopieren |
| code | Englisch Code | Deutsch Code |
| files | Englisch Files | Deutsch Dateien |
| terminal | Englisch Terminal | Deutsch Terminal |
| added | Englisch added | Deutsch hinzugefügt |
| removed | Englisch removed | Deutsch entfernt |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Codeblock reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-controls, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen tab, tablist und tabs.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 847 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Codeblock?
Mit aiw add @ai-website/code-block. Der Befehl legt die Datei components/ui/code-block.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Codeblock?
4 Varianten, Eine Datei, Reiter, Hervorhebung und Diff und Terminal.
Kann ich die Texte von Codeblock übersetzen?
Ja. Codeblock bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Kopieren“, „Kopiert“ und „Kopieren fehlgeschlagen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Codeblock einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Codeblock mit aiw_components und installieren sie mit aiw_add @ai-website/code-block. Nenn im Prompt die Variante, etwa variant="terminal", dann schreibt der Assistent deine Inhalte direkt in die Props.