Code Block

Free

Read-only source display with a zero-dependency highlighter for 9 languages. Four variants — block, multi-file tabs, diff (unified or split with per-hunk accept/reject) and terminal — plus streaming-safe rendering, line numbers, soft wrap, line highlighting, copy-to-clipboard, and a render cap for files too long to draw in full.

</>sv-code-block codesyntaxhighlightdiffterminaldeveloper

Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.

import { SvCodeBlockComponent } from '@styloviz/code-block';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Multi-file tabs

Pass an array of files to render a tabbed viewer with roving arrow-key navigation.

example.htmlAngular
<sv-code-block variant="tabs" [files]="files" (tabChange)="onTab($event)" />

Reviewable diff

Unified or split diff with per-hunk Accept / Reject — the AI-suggested-edit pattern.

example.htmlAngular
<sv-code-block
  variant="diff"
  diffMode="split"
  [hunks]="hunks"
  [reviewable]="true"
  (hunkAction)="onHunk($event)" />

Streaming buffer

The highlighter is pure, so re-running it on a growing buffer keeps a partial response correctly coloured.

example.htmlAngular
<sv-code-block [code]="buffer()" [streaming]="!done()" [wrap]="true" />

Terminal transcript

Command, stdout, stderr and exit lines are coloured semantically. ANSI escape sequences in the text are stripped, so output pasted straight from a real tool run does not render its colour codes as `[31m` debris.

example.htmlAngular
<sv-code-block variant="terminal" [terminalLines]="lines" [showHeader]="false" />

A file too long to render

Past `maxRenderedLines` (default 600) the block renders the first N lines and offers a "show all" control below the scroll region. Set `0` to render everything however long. The cap re-arms when a different tab or a replaced buffer arrives — but not while a stream appends to the buffer, so an expanded block stays expanded as an answer arrives — and never applies to the clipboard — copy always takes the whole file.

example.htmlAngular
<!-- render 200 lines, with the rest one button away -->
<sv-code-block [code]="hugeFile" [maxRenderedLines]="200" />

<!-- no cap at all -->
<sv-code-block [code]="hugeFile" [maxRenderedLines]="0" />

Computing the diff — the engine lives in core, not here

This component RENDERS a diff and computes nothing, which is deliberate: JSON Viewer wanted a JSON diff, Tree View wanted to compare trees and Descriptions wanted to diff a record, and four private half-engines is four sets of edge cases found separately. svDiffLines() in @styloviz/core is the one engine, and its output is the DiffHunk[] this component already takes — no adapter, no mapping. A contract test renders the engine's output through this component so the two cannot drift apart. It takes ignoreWhitespace, ignoreCase and a context width, and svDiffSummary() gives you the sentence that replaces the red and green for a screen reader.

example.htmlAngular
import { svDiffLines } from '@styloviz/core';

readonly diff = computed(() => svDiffLines(this.before(), this.after(), {
  context: 3,
  ignoreWhitespace: true,
}));

// <sv-code-block variant="diff" [hunks]="diff().hunks" diffMode="split" />

// diff().summary is "3 changes, 12 lines added, 4 lines removed."
// diff().tooDifferent is true when the two texts were too unrelated to compare
// line by line — Myers is quadratic on unrelated input, and a frozen tab is
// worse than a refusal that says so.

When to use

  • Rendering AI-generated code in a chat, artifact panel or completion.
  • Documenting an API response, config file or install command in an admin UI.
  • Showing a proposed edit for review, with per-hunk accept and reject.
  • Displaying CI logs or agent tool output as a terminal transcript.

Async states

None — this component has nothing to load, so it has no loading, empty or error state to drive.

Inputs

NameTypeDefaultDescription
variantCodeBlockVariant'block'Layout preset.
codestring''Source text — the quickest way to render a single snippet.
languageCodeLanguage | nullnullLanguage for `code`. Inferred from `filename` when left unset.
filenamestring''File name shown in the header and used for language inference.
filesreadonly CodeFile[][]Files for the `tabs` variant. Overrides `code` / `filename` when non-empty.
hunksreadonly DiffHunk[][]Hunks for the `diff` variant.
terminalLinesreadonly TerminalLine[][]Lines for the `terminal` variant.
diffModeCodeBlockDiffMode'unified'Diff layout. Ignored unless `variant` is `'diff'`.
lineNumbersbooleantrueShow the gutter with line numbers.
wrapbooleanfalseSoft-wrap long lines instead of scrolling horizontally.
showHeaderbooleantrueShow the header bar (file name, language badge, copy button).
copyablebooleantrueShow the copy-to-clipboard button.
highlightLinesreadonly number[][]1-based line numbers to emphasise.
maxHeightstring'24rem'Max height of the scroll region, as a CSS length.
maxRenderedLinesnumber600Rendering threshold, in lines. Beyond it the block truncates and offers a "show all" control. `0` renders everything, however long. Applies to `block` and `tabs`. A diff is bounded by its hunks and the terminal transcript by `terminalLines`, so neither needs a cap the caller has not already applied. "Show all" holds until the tab changes or the content is replaced; a buffer that only grows (a streamed answer) stays expanded.
streamingbooleanfalseRender a blinking caret after the last line, for in-flight responses.
ariaLabelstring''Accessible name for the scroll region. Falls back to the file name.
reviewablebooleanfalseShow per-hunk accept / reject buttons in diff mode.
activeTabnumber (two-way)0Index of the visible tab in the `tabs` variant.

Outputs

EventPayload typeDescription
copiedstringEmits the copied text after a successful clipboard write.
tabChangeCodeFileEmits the newly selected file when the user switches tab.
hunkActionDiffHunkEventEmits when a diff hunk is accepted or rejected.
activeTabChangenumberEmits the new `activeTab` whenever it changes — the output half of `[(activeTab)]`.

Customization & theming

  • Four variants: `block`, `tabs` (multi-file), `diff` (`unified` / `split`) and `terminal`.
  • Nine languages — typescript, javascript, html, css, json, bash, python, go, rust — inferred from the file extension or set via `language`. An extension with no rules degrades to plain monospace and says so rather than passing for a language that simply used no keywords.
  • Zero third-party dependencies: the highlighter is a small rule-based tokenizer that emits a token model, so the template renders it through Angular bindings — no `innerHTML`, no `bypassSecurityTrust*`.
  • Streaming-safe — `tokenize()` is pure, so re-running it on a growing buffer is cheap; `streaming` adds a caret after the last line. Half-received literals are handled explicitly: an unterminated string, template literal or block comment tokenises as what it is opening, so the colours do not rearrange themselves when the closing quote lands.
  • `maxRenderedLines` (default 600, `0` disables) caps how many lines are drawn in the `block` and `tabs` variants; the rest is one button away, below the scroll region. A cap rather than virtualisation — a windowed scroller has to own scroll position, row height and gutter alignment, and it breaks find-in-page for the rows it has not built. Copy is never truncated.
  • Terminal text is passed through an ANSI stripper, so a transcript pasted from real tool output renders as text instead of `[31m` fragments. Escapes are stripped rather than interpreted: `TerminalLine.kind` already drives the colour, and honouring an SGR code would mean two sources of truth for one pixel.
  • Toggle `lineNumbers`, `wrap`, `showHeader` and `copyable`; emphasise rows with `highlightLines`; cap the scroll region with `maxHeight` and its content with `maxRenderedLines`.
  • Emits `copied` (the copied text), `tabChange` (the selected file) and `hunkAction` (accept / reject with the hunk), so a host can wire it to real behaviour.
  • Icons come from `@styloviz/icons` (`sv-copy-icon`, `sv-check-icon`) — restyle or swap them by theming the icon library.
  • The exported `tokenize()`, `inferLanguage()`, `revealBidi()` and `stripAnsi()` helpers are public, so you can reuse the highlighter — and its two text-safety passes — outside the component.

Best practices

  • Do: Set `filename` — it drives language inference, the header label and the accessible name of the scroll region.
  • Do: Use `variant="diff"` with `reviewable` when the user is expected to act on a change, and handle `hunkAction` to apply it.
  • Don't: Do not use it for editable code — it is display-only. Reach for the Rich Text Editor or a dedicated code editor instead.
  • Don't: Do not paste untrusted HTML expecting it to render — the component renders a token model as text, never as markup.
  • Don't: Do not assume one copy button means one thing. What it takes depends on the variant — the updated side of a diff, the transcript without its `$` prompt, the open tab only — so the button says which in its `aria-label` and `title`. If you replace it, say the same thing.
  • Tip: Set `wrap` for streaming output so the panel never scrolls sideways while tokens arrive.
  • Tip: Cap the panel with `maxHeight` inside dense dashboards; the body scrolls and stays keyboard-reachable.
  • Tip: Lower `maxRenderedLines` when a panel is one of many on a page, and set `0` only when you know the source is small. A 12,000-line file otherwise pays for 12,000 rows and every token span in them on first paint.
  • Tip: Leave `language` unset unless you need to override inference. Setting it to `plaintext` is read as a request for plain text and suppresses the "not syntax highlighted" notice a genuine fallback shows.

Accessibility

  • The scrollable body is a `role="region"` with `tabindex="0"` and an accessible name from `ariaLabel` or the file name, so keyboard users can reach and scroll it.
  • The tab strip is a true `role="tablist"` with `aria-selected`, roving `tabindex`, and Arrow / Home / End navigation.
  • The copy button states what it will actually copy, in both `aria-label` and `title`: the updated side of a diff without the `+` / `−` markers, the transcript without the `$` prompt, the open tab only, or the code without the line numbers. One icon meaning four different things is not guessable, and every one of those answers is something a person pastes into a terminal. Success is announced through an `aria-live="polite"` status region.
  • When highlighting falls back, it says so. An extension the highlighter has no rules for renders a "plain text" badge and an `sr-only` "Not syntax highlighted: no rules for .rb. Shown as plain text." linked as the region's description — the failure mode of a highlighter is silence, so it has to announce itself. An explicit `language="plaintext"`, or a name with no extension, is a request rather than a fallback and stays quiet.
  • The body is pinned `dir="ltr"`. Source reads left-to-right whatever it contains, and inheriting a host `dir="rtl"` would reverse the visual order of every line while leaving the text alone. Each line is additionally `unicode-bidi: isolate`, so a right-to-left run inside one line cannot reorder its neighbours.
  • Invisible bidirectional control characters are rendered as a visible `<U+202E>` in a warning colour, everywhere the component draws text — tokenised code, diff lines and terminal output. They occupy no width and reorder the glyphs around them, so a line can display as one statement and copy as another (CVE-2021-42574, "Trojan Source"); a diff is the highest-risk place for that, because it is where code gets approved. The clipboard keeps the host's original bytes: rewriting source on the way out would corrupt a legitimate Arabic or Hebrew string literal to defend against a rare hostile one.
  • Line-number gutters, diff +/- markers and the streaming caret are `aria-hidden`, so screen readers read only the source text.
  • The truncation notice is a real `<button>` outside the scroll region, not decoration inside it. "There is more file" has to be reachable by keyboard, and placed after the last rendered line it would sit below a screenful of source — the one place a reader has no reason to scroll to.
  • A diff states its own scale: the region is `aria-describedby` a summary reading "Diff: 3 hunks, 12 added lines, 4 removed lines." The size of a change otherwise reaches the page only as geometry — the header's `+12 −4` chips and the per-line tint — leaving a screen-reader user to walk every line to find out how big it is. It is a DESCRIPTION, not part of the name, so a host `ariaLabel` still names the region and gets the summary too, and the chips are `aria-hidden` because this says it better.
  • Added and removed lines are prefixed with an `sr-only` "Added:" / "Removed:", in both `unified` and `split` modes. A red or green tint is not information, and in split mode the two columns read as one continuous run of text without it.
  • Highlighting renders a token model through Angular bindings — no `innerHTML` and no `bypassSecurityTrust*`, so untrusted source is never executed as markup.

Feedback on Code Block

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.