CodeBlock
Shiki-powered syntax-highlighted code block — copy button, collapse/expand, and a notation-driven tag/gutter mechanism.
CodeBlock renders a syntax-highlighted code block using Shiki, spec'd off Mantine's CodeHighlight: a copy button, collapse/expand for long snippets, custom-icon support, and a general comment-notation-driven line/gutter mechanism. It's "use client" — highlighting is async, so the component renders a plain, unhighlighted fallback (same chrome, no layout shift) until it resolves.
import { CodeBlock } from '@42-central/ui-react/code-block';
import '@42-central/ui-react/code-block/styles.css';
<CodeBlock
language="tsx"
code={`function greet(name) {
console.log(\`Hello, \${name}!\`)
}`}
/>@42-central/ui-react/code-block/styles.css is a separate, opt-in stylesheet — not part of the universal styles.css/theme.css/colors.css chain every consumer already imports. Only apps that render a CodeBlock need Shiki's token/gutter CSS, so it ships on its own.
CodeBlock.Static
For a fixed, known-at-render-time code (the diff example below is one — it's actually rendered via CodeBlock.Static, not CodeBlock), CodeBlock.Static is an async React Server Component that awaits the same highlightCode during the server render instead of CodeBlock's client-side effect. On a statically-generated page, that bakes the highlighted markup straight into the build output: no client JS spent on Shiki, no loading flash. It renders the exact same toolbar/copy/collapse-expand chrome as CodeBlock — the only difference is where the highlighting happens, and that it can't react to code changing later (there's no "later" from a Server Component's perspective). Requires a React Server Components-capable framework (e.g. Next.js App Router); reach for CodeBlock itself for anything editable/dynamic, like the Playground below.
import { CodeBlock } from '@42-central/ui-react/code-block';
// A Server Component — no "use client" needed here at all:
export default function Page() {
return <CodeBlock.Static language="js" code="console.log('hi')" />;
}Playground
Component
Dir
Presets
Props
Code
<Component language="js" code="function greet(name) {
console.log(`Hello, ${name}!`)
return true
}" />Full prop browser
Collapsing long snippets
Set collapsedLines to always show only the first N lines, with a fade + "Show more" control revealing the rest. Collapse/expand can be controlled (expanded/onExpandedChange) or left uncontrolled (defaultExpanded).
const line1 = 1
const line2 = 2
const line3 = 3
const line4 = 4
const line5 = 5
const line6 = 6const line7 = 7
const line8 = 8
const line9 = 9
const line10 = 10
const line11 = 11
const line12 = 12
const line13 = 13
const line14 = 14
const line15 = 15
const line16 = 16
const line17 = 17
const line18 = 18
const line19 = 19
const line20 = 20Code
<CodeBlock
code="const line1 = 1
const line2 = 2
const line3 = 3
const line4 = 4
const line5 = 5
const line6 = 6
const line7 = 7
const line8 = 8
const line9 = 9
const line10 = 10
const line11 = 11
const line12 = 12
const line13 = 13
const line14 = 14
const line15 = 15
const line16 = 16
const line17 = 17
const line18 = 18
const line19 = 19
const line20 = 20"
language="js"
collapsedLines={6}
/>Title
title adds a header row above the code — typically a filename. The toolbar moves into that header instead of floating over the code, so it never covers the first line.
export function greet(name: string) {
return "Hello, " + name
}Code
<CodeBlock.Static
title="greet.ts"
language="ts"
code={`export function greet(name: string) {
return "Hello, " + name
}`}
/>Line numbers
lineNumbers() (a tiny preset, not a dedicated prop) renders a real gutter node per line — not a ::before pseudo-element — so it composes cleanly alongside any other gutters column.
function greet(name) {
console.log(`Hello, ${name}!`)
return true
}Code
<CodeBlock
code="function greet(name) {
console.log(`Hello, ${name}!`)
return true
}"
language="js"
gutters={/* … */}
/>Notation — a general mechanism, not dedicated features
A line opts into styling or gutter content by carrying a comment marker — // [!code tagName], or // [!code tagName:payload] for a value like a name or a number. tags maps a marker's tag to styling (background/color, the same theme-aware Color tokens every colored component in the kit uses, plus className/style as the escape hatch); gutters maps tags to a stacked column rendered left of the code. There's no baked-in "highlight" or "diff" concept anywhere in CodeBlock — a diff view, a spotlighted zone, a redacted token, etc. are all just tags/gutters you author yourself (or generate).
A marker always applies to exactly the line it's written on — there's no "applies to the next N lines" shorthand. A multi-line diff removal is the same marker repeated on every affected line, which is trivial once it's machine-generated (see toDiffNotation below) and no real burden by hand either, since the marker sits right next to the line it describes.
Grammar reference
// [!code tagName] or // [!code tagName:payload]. tagName matches [A-Za-z][\w-]* — a letter, then any run of letters, digits, underscores, or hyphens (highlight, blame, tutorial-step are all valid tag names; 2fast isn't). payload is free-form text up to the closing ]; escape a literal colon or bracket inside it with \:/\] — // [!code note:ratio is 3\:4] parses to tag note, payload ratio is 3:4.
A marker is only honored inside a comment style the parser recognizes, checked per physical line (never across lines). Detection is purely textual, independent of the language grammar — so the // markers toDiffNotation emits work on a CSS or Python sample too.
| Style | Example |
|---|---|
// | const x = 1 // [!code highlight] |
# | x = 1 # [!code highlight] |
; | (setq x 1) ; [!code highlight] |
;; | (setq x 1) ;; [!code highlight] |
% | \section{Intro} % [!code highlight] |
%% | handle(X) -> X. %% [!code highlight] |
-- | SELECT * FROM users -- [!code highlight] |
/* ... */ | /* [!code highlight] */ |
<!-- ... --> | <!-- [!code highlight] --> |
{/* ... */} (JSX) | {/* [!code highlight] */} |
JSDoc * continuation | the 2nd+ line of a /** ... */ block, see below |
The line-style openers (//, #, ;/;;, %/%%, --) are found as the rightmost such token on the line, so // TODO: fix // [!code todo] finds the second // and leaves the first comment's own prose untouched. They also tolerate real prose before the marker in the same comment — sum += x // keep this [!code highlight] strips only the marker, leaving sum += x // keep this.
Block-style comments (/* */, <!-- -->, {/* */}) don't. The marker has to be the comment's entire content (surrounding whitespace aside), because removing an interior marker while preserving the closing *//-->/} isn't a plain end-of-line trim. A marker mixed with prose inside one of those isn't recognized as notation at all and renders as literal text — reach for a line-style trailing comment instead when prose needs to sit next to a marker.
The JSDoc continuation form reaches a marker on the 2nd+ line of a /** ... */ block, where the forms above can't:
/**
* Formats a friendly greeting.
* [!code highlight]
*/
function greet(name: string) {
return `Hello, ${name}!`
}A line whose only content is a marker is spliced out of the render, not left behind as a blank line. When a marker shares a line with real code, only the marker (and the now-empty comment opener) is trimmed — the code before it stays.
function total(items) {
let sum = 0
// [!code highlight]
for (const item of items) {
sum += item.price; // [!code highlight]
}
return sum
}renders as:
function total(items) {
let sum = 0
for (const item of items) {
sum += item.price;
}
return sum
}Markers stack. More than one distinct tag can sit in the same trailing comment, each matched independently against tags/gutters:
sum += item.price // [!code blame:alice] [!code coverage:miss]That line carries a blame marker (payload alice) and a coverage marker (payload miss) at once — a tags.blame style, a tags.coverage style, and a gutter column for each all apply to it together. Within a single content column that maps both tags, the first marker in written order wins.
A diff-style view
toDiffNotation(oldCode, newCode) (from @42-central/ui-react/code-block/diff, a separate subpath — see below) runs a real line-diff and returns ONE merged code string with // [!code removed]/// [!code added] already appended to every affected line, so nothing here is hand-typed. Tag removed/added each with their own background; a gutters content column renders a fixed −/+ marker for whichever tag matched. The marker's own color (text) is set per tag directly in tags, independent of the line's own (translucent, background-only) tint, so the marker itself reads unambiguously red/green.
function total(items) {
− let sum = 0
− for (const item of items) {
− sum += item.price
− }
− return sum
+ return items.reduce((sum, item) => sum + item.price, 0)
}Code
import { toDiffNotation } from '@42-central/ui-react/code-block/diff';
const oldCode = `function total(items) {
let sum = 0
for (const item of items) {
sum += item.price
}
return sum
}`;
const newCode = `function total(items) {
return items.reduce((sum, item) => sum + item.price, 0)
}`;
<CodeBlock.Static
language="js"
code={toDiffNotation(oldCode, newCode)}
tags={{
removed: { background: "red" },
added: { background: "green" },
}}
gutters={[
{
kind: "content",
tags: {
removed: { text: "−", color: "red" },
added: { text: "+", color: "green" },
},
align: "center",
},
]}
/>Diverging old/new line numbers
Stack two counter columns, each skip-ping the tag that shouldn't advance it — the old-number column skips added lines, the new-number column skips removed ones. The numbers repeat and diverge automatically; nobody types a number, and — since toDiffNotation produced the markers — nobody counted a line either.
function total(items) {
− let sum = 0
− for (const item of items) {
− sum += item.price
− }
− return sum
+ return items.reduce((sum, item) => sum + item.price, 0)
}Code
import { toDiffNotation } from '@42-central/ui-react/code-block/diff';
const oldCode = `function total(items) {
let sum = 0
for (const item of items) {
sum += item.price
}
return sum
}`;
const newCode = `function total(items) {
return items.reduce((sum, item) => sum + item.price, 0)
}`;
<CodeBlock.Static
language="js"
code={toDiffNotation(oldCode, newCode)}
tags={{
removed: { background: "red" },
added: { background: "green" },
}}
gutters={[
{ kind: "counter", skip: ["added"] },
{ kind: "counter", skip: ["removed"] },
{
kind: "content",
tags: {
removed: { text: "−", color: "red" },
added: { text: "+", color: "green" },
},
align: "center",
},
]}
/>A highlighted zone
Tag every line in a range with the same marker to tint a contiguous run — e.g. spotlighting a whole function body. highlight() is a ready-made preset for the common case of a single highlight tag.
function greet(name) {
const message = 'Hello, ' + name
console.log(message)
return message
}Code
<CodeBlock.Static
language="ts"
code={`function greet(name) {
const message = 'Hello, ' + name // [!code highlight]
console.log(message) // [!code highlight]
return message
}`}
tags={highlight()}
/>Showing markers verbatim
notation={false} skips the parse entirely, so [!code …] comments render as plain source — for a snippet that documents the notation itself, like the code panels on this page.
console.log(message) // [!code highlight]Code
<CodeBlock.Static
notation={false}
language="ts"
code="console.log(message) // [!code highlight]"
/>Extending the notation
CodeBlock ships no "blame" or "coverage" concept — tags/gutters are the entire mechanism, and a new tag works exactly the way removed/added/highlight already do above. Adding one is always the same recipe:
- Annotate source lines with
// [!code yourTag]or// [!code yourTag:payload](or whichever recognized comment style matches the language you're highlighting). - Optionally style every matching line via
tags.yourTag—background/colortint the line itself,className/styleare the escape hatch for anything else. - Optionally surface the tag in the gutter via a
gutterscontent column: mapyourTagto fixed text (a literal string, e.g."→"), totrueto render the marker's own payload verbatim, or to{ text, color?, background? }when the gutter marker needs its own color.
Three worked examples, rendered live, and one paste-ready recipe.
Blame — the marker's payload is a free-form author name, echoed verbatim in its own gutter column via the true shorthand:
function total(items) {
alice let sum = 0
bob for (const item of items) {
bob sum += item.price
}
alice return sum
}Code
<CodeBlock.Static
language="js"
code={`function total(items) {
let sum = 0 // [!code blame:alice]
for (const item of items) { // [!code blame:bob]
sum += item.price // [!code blame:bob]
}
return sum // [!code blame:alice]
}`}
tags={{ blame: { background: "neutral" } }}
gutters={[
{ kind: "content", tags: { blame: true }, align: "left", width: "5ch" },
]}
/>Tutorial steps — the payload is a step number; tags.step tints the line and the same number is echoed, styled to match, in a dedicated gutter column via the full { text: true, color } form:
function greet(name) {
1 const message = 'Hello, ' + name
2 console.log(message)
3 return message
}Code
<CodeBlock.Static
language="js"
code={`function greet(name) {
const message = 'Hello, ' + name // [!code step:1]
console.log(message) // [!code step:2]
return message // [!code step:3]
}`}
tags={{ step: { background: "blue" } }}
gutters={[
{ kind: "content", tags: { step: { text: true, color: "blue" } }, align: "center" },
]}
/>Coverage — two distinct tags (hit/miss) instead of one tag with two payload values, since a line's coverage status is a discrete choice rather than free text worth capturing in a payload:
function divide(a, b) {
✗ if (b === 0) {
✗ throw new Error('divide by zero')
}
✓ return a / b
}Code
<CodeBlock.Static
language="js"
code={`function divide(a, b) {
if (b === 0) { // [!code miss]
throw new Error('divide by zero') // [!code miss]
}
return a / b // [!code hit]
}`}
tags={{
hit: { background: "green" },
miss: { background: "red" },
}}
gutters={[
{ kind: "content", tags: { hit: "✓", miss: "✗" }, align: "center" },
]}
/>Severity — warn/error tags color the line and put a fixed icon in the gutter. The payload still carries the actual message: nothing here renders it, but a downstream tool (or a second content column mapping the tag to true) can read it straight off the marker:
<CodeBlock.Static
language="js"
code={`function riskyDivide(a, b) {
console.log('dividing') // [!code warn:unused variable above]
return a / b // [!code error:missing zero-check]
}`}
tags={{
warn: { background: "orange" },
error: { background: "red" },
}}
gutters={[
{ kind: "content", tags: { warn: "!", error: "×" }, align: "center" },
]}
/>Extending Shiki
CodeBlock hardcodes Shiki, but doesn't box in what Shiki itself can do:
-
transformers— pass anyShikiTransformer, including community ones from@shikijs/transformers. They run afterCodeBlock's own internal notation transformer, so they can build on top of it.import { transformerNotationDiff } from '@shikijs/transformers'; <CodeBlock code="..." transformers={[transformerNotationDiff()]} /> -
registerLanguage/registerTheme(exported from@42-central/ui-react/code-block) — register a custom TextMate grammar or VS Code-style theme once, solanguage/themescan reference it by name afterward, the same way a bundled one works. Await it before the first render that uses it (e.g. at app startup).import { registerLanguage } from '@42-central/ui-react/code-block'; await registerLanguage(myCustomGrammarJson); // later: <CodeBlock language="my-custom-lang" code="..." />
Custom icons
copyIcon/copiedIcon/expandIcon/collapseIcon follow the same convention as Alert's icon: undefined uses the default, null hides it, any ReactNode overrides it.
Custom toolbar controls
controls renders before the built-in buttons in the same floating toolbar. Build a custom one with CodeBlock.Button — the same compact icon-button primitive copy/expand use internally, so it matches their size/style exactly instead of you hand-rolling one. It composes the same way the built-ins do too: wrap it in a Tooltip for a hover/focus label.
console.log('hi')Code
<CodeBlock.Static
code={"console.log('hi')"}
controls={
<Tooltip label="Run" asChild>
<CodeBlock.Button aria-label="Run">
<Play />
</CodeBlock.Button>
</Tooltip>
}
/>Translations
Button/label copy is factored into one translations object — the same convention as Calendar's translations prop — rather than separate copyLabel/expandLabel/etc. props:
<CodeBlock
code="..."
translations={{ copy: "Duplicate", copied: "Duplicated!" }}
/>API
Prop
Type
Accessibility
The copy trigger's accessible name comes from translations.copy/copied (default "Copy"/"Copied"). The expand/collapse control is a real <button aria-expanded>, and its label text (translations.expand/collapse) doubles as its accessible name. A gutters counter column is aria-hidden by default (redundant, noisy if read aloud per line); a content column is left in the accessible tree by default, since its text is meaningful — both are overridable per column via ariaHidden.
Changelog
- #121
New CodeBlock component, highlighted by Shiki, with
CodeBlock.Buttonfor its toolbar - #130
CodeBlock.Static, an async Server Component variant - #130
Mark lines with
// [!code …]comments, shown in stacked gutter columns - #131
toDiffNotation(oldCode, newCode), from@42/ui-react/code-block/diff, writes that notation for a diff