|Kit

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}!\`)
}`}
/>
Ship the stylesheet

@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

Loading preview

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 = 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

Code

<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.

greet.ts
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).

No range shorthand

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.

StyleExample
//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 * continuationthe 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 marker-only line disappears entirely

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:

  1. Annotate source lines with // [!code yourTag] or // [!code yourTag:payload] (or whichever recognized comment style matches the language you're highlighting).
  2. Optionally style every matching line via tags.yourTag — background/color tint the line itself, className/style are the escape hatch for anything else.
  3. Optionally surface the tag in the gutter via a gutters content column: map yourTag to fixed text (a literal string, e.g. "→"), to true to 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 any ShikiTransformer, including community ones from @shikijs/transformers. They run after CodeBlock'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, so language/themes can 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

v0.18.0
  • title adds a header row above the code (a filename, say) that hosts the toolbar

    #290
  • notation={false} shows [!code …] markers verbatim

    #290
  • Code stays left-to-right on RTL pages

    #290
v0.8.0
  • New CodeBlock component, highlighted by Shiki, with CodeBlock.Button for its toolbar

    #121
  • CodeBlock.Static, an async Server Component variant

    #130
  • Mark lines with // [!code …] comments, shown in stacked gutter columns

    #130
  • toDiffNotation(oldCode, newCode), from @42/ui-react/code-block/diff, writes that notation for a diff

    #131

On this page