Skip to content
UI Kit
Accent color

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

4 variants

Variants

Single file

File name, language, copy and line numbers. Long lines scroll inside the block, never the page.

Tabs

Several files in one block. Arrow keys move between the tabs, the copy button takes the open file.

Highlights and diff

Lines that matter carry a light accent band. In a diff, added and removed lines get a marker and are announced to screen readers.

Terminal

A shell window. Commands carry a prompt, output stays quiet, copy takes only the commands.

Details

What Code block brings along, read from the registry and the source.

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

Props

The CodeBlockProps interface from components/ui/code-block.tsx, with the comments from the code.

  • codeoptional
    string

    A single snippet. Use files for tabs.

  • languageoptional
    CodeLanguage

    tsxtsjsxjscsshtmlbashjsontext

  • filenameoptional
    string

    File name in the header of a single snippet

  • highlightoptional
    LineRange[]

    Lines to highlight, e.g. [3, [7, 9]]

  • diffoptional
    boolean

    Lines start with + (added), - (removed) or a space

  • filesoptional
    CodeFile[]

    Several files as tabs

  • variantoptional
    'default' | 'terminal'

    default: a code card. terminal: a shell window, lines starting with "$ " are commands, the rest is output.

  • titleoptional
    string

    Title in the terminal bar

  • lineNumbersoptional
    boolean

    Default: on for code, off for the terminal

  • maxHeightoptional
    number

    Scroll vertically above this height in px

  • animateoptional
    boolean

    Lines 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
copyEnglish CopyGerman Kopieren
copiedEnglish CopiedGerman Kopiert
failedEnglish Copy failedGerman Kopieren fehlgeschlagen
copyCodeEnglish Copy codeGerman Code kopieren
codeEnglish CodeGerman Code
filesEnglish FilesGerman Dateien
terminalEnglish TerminalGerman Terminal
addedEnglish addedGerman hinzugefügt
removedEnglish removedGerman 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
components/ui/code-block.tsx
'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.

Often searched

Jump to

Copied to the clipboard