Code-block
Multi-line code surface with optional syntax highlighting (via adapter), language label, line numbers, and built-in copy button (via the copyable behavior, default-on).
Per the answered design question:
- Adapter API:
highlight(code, lang) => string(same shape as Icon). - Copy button: provided by
copyablebehavior, opted-in by default per v1-master-diff §4.8. - Line numbers: opt-in via
data-line-numbers="true". Runtime wraps each line in<span class="code-block__line">so a CSS counter renders the number.
When to use
- Documentation blocks longer than a single line.
- Tutorial snippets, examples, configuration samples.
For inline code inside prose, use Code (elements/display/code/).
Anatomy
<!-- Plain block, no highlighter registered: renders as plain text + copy -->
<pre class="code-block" data-lang="ts">
<code>const x = 1;
const y = 2;
const z = x + y;</code>
</pre>
<!-- With language label, line numbers, copy on hover -->
<pre class="code-block" data-lang="bash" data-line-numbers="true">
<code>pnpm install
pnpm dev</code>
</pre>
<!-- Variants -->
<pre class="code-block -flat" data-lang="css">…</pre>
<pre class="code-block -on-inverse" data-lang="md">…</pre>
Registering a highlighter
import { registerHighlighter, refreshCodeBlocks } from "@malevich/components";
import { codeToHtml } from "shiki";
registerHighlighter((code, lang) => {
// Shiki returns full <pre><code>…; strip the outer wrapper to
// return inner HTML only.
const html = codeToHtml(code, { lang, theme: "github-light" });
const match = html.match(/<code[^>]*>([\s\S]*?)<\/code>/);
return match ? match[1] : code;
});
refreshCodeBlocks();
The adapter contract is intentionally narrow:
type Highlighter = (code: string, lang: string) => string;
Return the inner HTML for the <code> element. The component
owns the surrounding chrome (border, padding, copy button, language
label). Authors may use Shiki, Prism, Highlight.js, or a custom
highlighter — any function matching the signature works.
Variants
| Variant | Class | Effect |
|---|---|---|
| Default | .code-block |
Bordered card on canvas |
| Flat | .code-block.-flat |
No border, raised background |
| Inverse | .code-block.-on-inverse |
Dark surface |
Tokens used
--color-surface-canvas/--color-surface-raised/--color-ink-strong--color-ink-subtle— language label + line-number color--color-ink-inverse— inverse text--color-border-muted— border--space-inset-block-m,--space-inset-element-{s,m,l},--space-inset-section-s--size-control-s— reserved space for copy button--radius-card--border-width-hairline--font-mono-m-*,--font-overline-*
Copy button
The copy button comes from the copyable behavior modifier. By
default data-copyable="true" is implicit per v1-master-diff §4.8.
Opt out per instance:
<pre class="code-block" data-copyable="false">…</pre>
The runtime injects the copy button automatically once init() runs.
Clipboard text excludes the language label and copy button itself.
Accessibility
- Use
<pre>as the host and a child<code>for the content. Both carry implicit semantics. - The language label is a decorative
::beforepseudo-element — excluded from the accessibility tree. - Line numbers are decorative
::beforecounters — excluded. - The copy button has
aria-label="Copy to clipboard"(from the copyable behavior).
Edge cases
- No highlighter registered: the block renders plain text. Copy + language label + line numbers still work.
- Highlighter throws: the runtime silently falls back to plain text. Errors logged to console only if the highlighter writes there.
- Late registration: call
refreshCodeBlocks()after registering a highlighter to re-render existing blocks.
Do
- Use
<pre><code>markup. - Set
data-langso the language label renders even without a highlighter. - Reach for
refreshCodeBlocks()after registering or swapping a highlighter at runtime.
Don't
- Don't put inline tokens inside the host
<pre>— keep code inside the inner<code>. - Don't write highlighting HTML manually for static blocks. Register a highlighter or omit highlighting.