Code Block
FreeRead-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.
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 demoMore examples
Multi-file tabs
Pass an array of files to render a tabbed viewer with roving arrow-key navigation.
<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.
<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.
<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.
<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.
<!-- 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.
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
| Name | Type | Default | Description |
|---|---|---|---|
| variant | CodeBlockVariant | 'block' | Layout preset. |
| code | string | '' | Source text — the quickest way to render a single snippet. |
| language | CodeLanguage | null | null | Language for `code`. Inferred from `filename` when left unset. |
| filename | string | '' | File name shown in the header and used for language inference. |
| files | readonly CodeFile[] | [] | Files for the `tabs` variant. Overrides `code` / `filename` when non-empty. |
| hunks | readonly DiffHunk[] | [] | Hunks for the `diff` variant. |
| terminalLines | readonly TerminalLine[] | [] | Lines for the `terminal` variant. |
| diffMode | CodeBlockDiffMode | 'unified' | Diff layout. Ignored unless `variant` is `'diff'`. |
| lineNumbers | boolean | true | Show the gutter with line numbers. |
| wrap | boolean | false | Soft-wrap long lines instead of scrolling horizontally. |
| showHeader | boolean | true | Show the header bar (file name, language badge, copy button). |
| copyable | boolean | true | Show the copy-to-clipboard button. |
| highlightLines | readonly number[] | [] | 1-based line numbers to emphasise. |
| maxHeight | string | '24rem' | Max height of the scroll region, as a CSS length. |
| maxRenderedLines | number | 600 | Rendering 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. |
| streaming | boolean | false | Render a blinking caret after the last line, for in-flight responses. |
| ariaLabel | string | '' | Accessible name for the scroll region. Falls back to the file name. |
| reviewable | boolean | false | Show per-hunk accept / reject buttons in diff mode. |
| activeTab | number (two-way) | 0 | Index of the visible tab in the `tabs` variant. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| copied | string | Emits the copied text after a successful clipboard write. |
| tabChange | CodeFile | Emits the newly selected file when the user switches tab. |
| hunkAction | DiffHunkEvent | Emits when a diff hunk is accepted or rejected. |
| activeTabChange | number | Emits 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.