Displays code, terminal output and diffs with an optional header, line numbers and a copy button.
CodeBlock displays code, terminal output, logs and diffs. It has a header with a title and actions, line numbers, wrapping, diff and highlighted lines, and a small size for tool output in a Trace.
Syntax highlighting is optional and lives in a separate function, so a block that only shows plain output stays small.
Installation
Install the dependencies
Copy the component
Copy the following files into src/lib/components/ui/code-block.
These files use the Nova style and Lucide icons. npx @sveltery/ui@latest add writes the style and icon library from your components.json.
It imports the button component, so copy its folder too.
<script lang="ts">
import type { CodeBlockProps } from './types.js';
import { cn } from 'cn';
import { codeBlockVariants } from './variants.js';
let { children, class: classProp, variant = 'default', size = 'default', ...props }: CodeBlockProps = $props();
</script>
<div data-slot="code-block" data-variant={variant} data-size={size} {...props} class={cn(codeBlockVariants({ variant }), classProp)}>
{@render children?.()}
</div> <script lang="ts">
import type { CodeBlockActionsProps } from './types.js';
import { cn } from 'cn';
let { children, class: classProp, ...props }: CodeBlockActionsProps = $props();
</script>
<div
data-slot="code-block-actions"
{...props}
class={cn(
'-my-1 -me-1.5 gap-1 [[data-slot=code-block]>&]:m-0 [[data-slot=code-block]>&]:top-2 [[data-slot=code-block]>&]:end-2 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:top-1.5 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:end-1.5 [[data-slot=code-block]>&]:*:[button]:bg-background [[data-slot=code-block]>&]:*:[button]:ring-1 [[data-slot=code-block]>&]:*:[button]:ring-foreground/10 ms-auto flex shrink-0 items-center [[data-slot=code-block]>&]:absolute [[data-slot=code-block]>&]:z-10 [[data-slot=code-block]>&]:opacity-0 [[data-slot=code-block]>&]:transition-opacity [[data-slot=code-block]>&]:group-focus-within/code-block:opacity-100 [[data-slot=code-block]>&]:group-hover/code-block:opacity-100 [[data-slot=code-block]>&]:pointer-coarse:opacity-100',
classProp,
)}
>
{@render children?.()}
</div> <script lang="ts">
import type { CodeBlockContentProps } from './types.js';
import { cn } from 'cn';
import CodeBlockLine from './CodeBlockLine.svelte';
let { children, class: classProp, code, wrap = false, lineNumbers = false, startLine = 1, style, ...props }: CodeBlockContentProps = $props();
</script>
<!-- The content scrolls, so it takes focus for keyboard scrolling. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<pre
data-slot="code-block-content"
data-wrap={wrap || undefined}
data-numbered={lineNumbers || undefined}
dir="ltr"
tabindex={0}
{...props}
style="counter-reset: line {startLine - 1};{style ?? ''}"
class={cn(
'px-3 py-3 leading-relaxed group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2 group-data-[size=sm]/code-block:px-2.5 group-data-[size=sm]/code-block:py-2.5 group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5 min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto text-start font-mono outline-none data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap',
classProp,
)}><code data-slot="code-block-code" class="grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0"
>{#if code !== undefined && lineNumbers}{#each code.replace(/\n$/, '').split('\n') as line, index (index)}<CodeBlockLine>{line}</CodeBlockLine>{/each}{:else if code !== undefined}{code}{:else}{@render children?.()}{/if}</code
></pre> <script lang="ts">
import type { CodeBlockCopyButtonProps } from './types.js';
import { cn } from 'cn';
import { Button } from '../button/index.js';
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
let { value, variant = 'ghost', size = 'icon-xs', class: classProp, onclick, ...props }: CodeBlockCopyButtonProps = $props();
let copied = $state(false);
$effect(() => {
if (!copied) return;
const timeout = setTimeout(() => (copied = false), 2000);
return () => clearTimeout(timeout);
});
</script>
<Button
data-slot="code-block-copy-button"
data-copied={copied || undefined}
{variant}
{size}
aria-label={copied ? 'Copied' : 'Copy'}
class={cn('', classProp)}
onclick={(event) => {
onclick?.(event);
// The clipboard is undefined outside a secure context.
navigator.clipboard
?.writeText(value)
.then(() => (copied = true))
.catch(() => {});
}}
{...props}
>
{#if copied}
<CheckIcon />
{:else}
<CopyIcon />
{/if}
</Button>
<span aria-live="polite" class="sr-only">{copied ? 'Copied' : ''}</span> <script lang="ts">
import type { CodeBlockFooterProps } from './types.js';
import { cn } from 'cn';
let { children, class: classProp, ...props }: CodeBlockFooterProps = $props();
</script>
<div data-slot="code-block-footer" {...props} class={cn('gap-2 px-3 pb-3 text-muted-foreground group-data-[size=sm]/code-block:px-2.5 group-data-[size=sm]/code-block:pb-2.5 flex min-w-0 shrink-0 items-center', classProp)}>
{@render children?.()}
</div> <script lang="ts">
import type { CodeBlockHeaderProps } from './types.js';
import { cn } from 'cn';
let { children, class: classProp, ...props }: CodeBlockHeaderProps = $props();
</script>
<div data-slot="code-block-header" {...props} class={cn('gap-2 px-3 pt-3 text-muted-foreground group-data-[variant=destructive]/code-block:text-destructive group-data-[size=sm]/code-block:px-2.5 group-data-[size=sm]/code-block:pt-2.5 flex min-w-0 shrink-0 items-center *:has-[>[data-slot=code-block-title]]:min-w-0', classProp)}>
{@render children?.()}
</div> <script lang="ts">
import type { CodeBlockLineProps } from './types.js';
import { untrack } from 'svelte';
import { cn } from 'cn';
import { codeBlockLineVariants } from './variants.js';
let { children, class: classProp, variant = 'default', ...props }: CodeBlockLineProps = $props();
// An empty line holds a newline so copying a selection keeps blank lines. A snippet cannot be inspected for
// emptiness, so the rendered content is watched instead.
const newline = '\n';
let empty = $state(true);
function watchContent(node: HTMLElement) {
// Only the newline this line added itself is discounted, so content that is a newline does not toggle forever.
const sync = () => (empty = node.childElementCount === 0 && node.textContent === (untrack(() => empty) ? newline : ''));
sync();
const observer = new MutationObserver(sync);
observer.observe(node, { childList: true, characterData: true, subtree: true });
return () => observer.disconnect();
}
</script>
<span data-slot="code-block-line" data-variant={variant} {...props} class={cn(codeBlockLineVariants({ variant }), classProp)}>
<span data-slot="code-block-line-content" class="min-w-0" {@attach watchContent}>{@render children?.()}{#if empty}{newline}{/if}</span>
</span> <script lang="ts">
import type { CodeBlockTitleProps } from './types.js';
import { cn } from 'cn';
let { children, class: classProp, ...props }: CodeBlockTitleProps = $props();
</script>
<div data-slot="code-block-title" {...props} class={cn("font-sans text-sm font-medium text-foreground group-data-[variant=destructive]/code-block:text-destructive *:[svg]:me-1.5 [&_svg:not([class*='size-'])]:size-3.5 group-data-[size=sm]/code-block:text-xs min-w-0 truncate [&_svg]:pointer-events-none [&_svg]:inline-block [&_svg]:align-middle", classProp)}>
{@render children?.()}
</div> // Adapted from shadcn-ui/ui d2fba50e; MIT: apps/docs/registry/THIRD_PARTY_NOTICES.md.
import { createHighlighterCore, type GrammarState, type HighlighterCore } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
// Add the languages you need. Each one loads the first time it is used.
const languages = {
bash: () => import('shiki/langs/shellscript.mjs'),
css: () => import('shiki/langs/css.mjs'),
diff: () => import('shiki/langs/diff.mjs'),
html: () => import('shiki/langs/html.mjs'),
javascript: () => import('shiki/langs/javascript.mjs'),
json: () => import('shiki/langs/json.mjs'),
jsx: () => import('shiki/langs/jsx.mjs'),
markdown: () => import('shiki/langs/markdown.mjs'),
python: () => import('shiki/langs/python.mjs'),
sql: () => import('shiki/langs/sql.mjs'),
tsx: () => import('shiki/langs/tsx.mjs'),
typescript: () => import('shiki/langs/typescript.mjs'),
yaml: () => import('shiki/langs/yaml.mjs'),
};
const aliases: Record<string, keyof typeof languages> = {
js: 'javascript',
md: 'markdown',
py: 'python',
sh: 'bash',
shell: 'bash',
shellscript: 'bash',
ts: 'typescript',
yml: 'yaml',
zsh: 'bash',
};
let highlighter: Promise<HighlighterCore> | undefined;
function getHighlighter() {
highlighter ??= createHighlighterCore({
themes: [import('shiki/themes/github-light.mjs'), import('shiki/themes/github-dark.mjs')],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
return highlighter;
}
function getLanguage(language: string) {
const name = language.toLowerCase();
if (name === 'ansi') return name;
if (name in languages) return name as keyof typeof languages;
return aliases[name] ?? null;
}
export function escapeHtml(text: string) {
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
}
const styleOf = (style: Record<string, string> | undefined) =>
style ? Object.entries(style).map(([property, value]) => `${property}:${value}`).join(';') : '';
/** Loads a language and returns a highlighter for it, or null for a language that is not listed. */
export async function loadHighlighter(language: string) {
const lang = getLanguage(language);
if (!lang) return null;
const shiki = await getHighlighter();
if (lang !== 'ansi' && !shiki.getLoadedLanguages().includes(lang)) await shiki.loadLanguage(languages[lang]);
return {
/**
* Returns one HTML string per line, for `{@html}` in a `CodeBlockLine`, and the grammar state after the last
* line. Pass that state back to continue where the code left off.
*/
highlight(code: string, state?: GrammarState) {
const tokenize = (text: string) =>
shiki.codeToTokens(text, { lang, themes: { light: 'github-light', dark: 'github-dark' }, defaultColor: false, grammarState: state }).tokens;
// ANSI has no grammar state, so each line is tokenized on its own and gets the same colors whether it arrives
// in one piece or streams.
const tokens = lang === 'ansi' ? code.split('\n').flatMap((line) => tokenize(line)) : tokenize(code);
return {
lines: tokens.map((line) =>
line
.map((token) => `<span style="${escapeHtml(styleOf(token.htmlStyle)).replaceAll('"', '"')}" class="text-(--shiki-light) dark:text-(--shiki-dark)">${escapeHtml(token.content)}</span>`)
.join(''),
),
state: shiki.getLastGrammarState(tokens),
};
},
};
}
export type Highlighter = NonNullable<Awaited<ReturnType<typeof loadHighlighter>>>;
/** Returns one HTML string per line. A language that is not listed comes back as escaped plain text. */
export async function highlightCode(code: string, language: string) {
const highlighter = await loadHighlighter(language);
return highlighter ? highlighter.highlight(code).lines : code.split('\n').map(escapeHtml);
} import type { HTMLAttributes } from 'svelte/elements';
import type { ButtonProps } from '../button/index.js';
import type { CodeBlockLineVariants, CodeBlockVariants } from './variants.js';
type NativeDivProps = HTMLAttributes<HTMLDivElement>;
export type CodeBlockProps = NativeDivProps & CodeBlockVariants & { size?: 'default' | 'sm' };
export type CodeBlockHeaderProps = NativeDivProps;
export type CodeBlockTitleProps = NativeDivProps;
export type CodeBlockActionsProps = NativeDivProps;
export type CodeBlockFooterProps = NativeDivProps;
export type CodeBlockCopyButtonProps = ButtonProps & { value: string };
export type CodeBlockContentProps = HTMLAttributes<HTMLPreElement> & {
/** Plain text to show. It renders as one text node, or as one line per row with `lineNumbers`. */
code?: string;
wrap?: boolean;
lineNumbers?: boolean;
startLine?: number;
};
export type CodeBlockLineProps = HTMLAttributes<HTMLSpanElement> & CodeBlockLineVariants; // Adapted from shadcn-ui/ui d2fba50e; MIT: apps/docs/registry/THIRD_PARTY_NOTICES.md.
import type { GrammarState } from 'shiki/core';
import { escapeHtml, loadHighlighter, type Highlighter } from './highlighter.js';
type Done = { highlighter: Highlighter | null; text: string; lines: string[]; state?: GrammarState };
/**
* Highlights `code` and returns one HTML string per line in `lines`, for `{@html}` in a `CodeBlockLine`. Pass
* getters so the result follows the current values.
*/
export function useHighlightedCode(code: () => string, language: () => string) {
let loaded = $state.raw<{ language: string; highlighter: Highlighter | null } | null>(null);
$effect(() => {
const current = language();
let cancelled = false;
loadHighlighter(current)
.then((highlighter) => {
if (!cancelled) loaded = { language: current, highlighter };
})
.catch(() => {});
return () => {
cancelled = true;
};
});
// The complete lines, highlighted. While the code streams, only the new lines are tokenized, starting from the
// grammar state of the last one. It is a cache, so it is not reactive.
let done: Done = { highlighter: null, text: '', lines: [] };
const lines = $derived.by(() => {
const value = code();
// A trailing newline would render as an extra empty line.
const text = value.endsWith('\n') ? value.slice(0, -1) : value;
const highlighter = loaded?.language === language() ? loaded.highlighter : null;
if (!highlighter) return text.split('\n').map(escapeHtml);
if (done.highlighter !== highlighter || !text.startsWith(done.text)) done = { highlighter, text: '', lines: [] };
const end = text.lastIndexOf('\n') + 1;
if (end > done.text.length) {
const result = highlighter.highlight(text.slice(done.text.length, end - 1), done.state);
done = { highlighter, text: text.slice(0, end), lines: [...done.lines, ...result.lines], state: result.state };
}
// The last line is still being written, so it is tokenized on every change.
return [...done.lines, highlighter.highlight(text.slice(end), done.state).lines[0] ?? ''];
});
return {
get lines() {
return lines;
},
};
} // Adapted from shadcn-ui/ui d2fba50e; MIT: apps/docs/registry/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const codeBlockVariants = cva('rounded-lg text-sm data-[size=sm]:text-xs has-[>[data-slot=code-block-content]:focus-visible]:ring-3 has-[>[data-slot=code-block-content]:focus-visible]:ring-ring/50 group/code-block relative flex w-full min-w-0 flex-col overflow-hidden', {
variants: {
variant: { default: 'border border-input text-foreground dark:bg-input/30 has-[>[data-slot=code-block-content]:focus-visible]:border-ring', destructive: 'border border-destructive/30 text-destructive dark:border-destructive/40' },
},
defaultVariants: { variant: 'default' },
});
export type CodeBlockVariants = VariantProps<typeof codeBlockVariants>;
export const codeBlockLineVariants = cva(
"-mx-3 px-3 in-data-numbered:gap-x-3 before:min-w-6 before:text-end before:text-muted-foreground after:start-1 after:text-muted-foreground group-data-[size=sm]/code-block:-mx-2.5 group-data-[size=sm]/code-block:px-2.5 relative block min-h-lh after:absolute after:top-0 after:select-none in-data-numbered:col-span-2 in-data-numbered:grid in-data-numbered:grid-cols-subgrid in-data-numbered:[counter-increment:line] in-data-numbered:before:content-[counter(line)] in-data-numbered:before:select-none in-data-numbered:supports-[content:counter(line)_/_'']:before:content-[counter(line)_/_'']",
{
variants: {
variant: {
default: '', added: "bg-muted after:content-['+']",
removed: "bg-destructive/10 after:text-destructive after:content-['−']", highlighted: 'bg-muted',
},
},
defaultVariants: { variant: 'default' },
},
);
export type CodeBlockLineVariants = VariantProps<typeof codeBlockLineVariants>; export { default as CodeBlock } from './CodeBlock.svelte';
export { default as CodeBlockHeader } from './CodeBlockHeader.svelte';
export { default as CodeBlockTitle } from './CodeBlockTitle.svelte';
export { default as CodeBlockActions } from './CodeBlockActions.svelte';
export { default as CodeBlockCopyButton } from './CodeBlockCopyButton.svelte';
export { default as CodeBlockContent } from './CodeBlockContent.svelte';
export { default as CodeBlockLine } from './CodeBlockLine.svelte';
export { default as CodeBlockFooter } from './CodeBlockFooter.svelte';
export { codeBlockVariants, codeBlockLineVariants } from './variants.js';
export type { CodeBlockVariants, CodeBlockLineVariants } from './variants.js';
export { highlightCode, loadHighlighter } from './highlighter.js';
export { useHighlightedCode } from './use-highlighted-code.svelte.js';
export type {
CodeBlockProps,
CodeBlockHeaderProps,
CodeBlockTitleProps,
CodeBlockActionsProps,
CodeBlockCopyButtonProps,
CodeBlockContentProps,
CodeBlockLineProps,
CodeBlockFooterProps,
} from './types.js'; Update the import paths
The component folders import each other with relative paths, so keep them side by side. If you place them elsewhere, change the imports to match your project setup.
code-block also installs useHighlightedCode for syntax highlighting.
Usage
<script lang="ts">
import {
CodeBlock,
CodeBlockActions,
CodeBlockContent,
CodeBlockCopyButton,
CodeBlockFooter,
CodeBlockHeader,
CodeBlockLine,
CodeBlockTitle,
} from '#lib/components/ui/code-block';
</script> <CodeBlock>
<CodeBlockHeader>
<CodeBlockTitle>Terminal</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton value={output} />
</CodeBlockActions>
</CodeBlockHeader>
<CodeBlockContent code={output} />
</CodeBlock> Composition
CodeBlock
├── CodeBlockHeader
│ ├── CodeBlockTitle
│ └── CodeBlockActions
│ └── CodeBlockCopyButton
├── CodeBlockActions (floating, when there is no header)
├── CodeBlockContent
│ └── CodeBlockLine
└── CodeBlockFooter CodeBlockContent takes a string in code, or a list of CodeBlockLine as children. Use lines when you need line variants or highlighted tokens.
Features
- It streams cheaply. A
codestring renders as one text node, and the highlighter tokenizes only the new lines as code arrives. - It copies cleanly. Line numbers and diff markers are left out of a selection, and blank lines survive the copy.
- It reads left to right. The content is always
dir="ltr". The header follows the page direction. - It highlights without WASM.
useHighlightedCodeuses Shiki's JavaScript engine, loads each language on first use and renders ANSI colors in terminal output. On the server,highlightCodekeeps Shiki out of the client bundle. - It fits where code shows up. Use it in a message, a Trace line, Tabs or a Dialog.
- It scrolls by keyboard. The content is focusable, and floating actions show on focus as well as on hover.
Basic
Pass a string to code on CodeBlockContent. It renders as one text node, so it stays cheap while output streams.
Header
CodeBlockHeader holds a CodeBlockTitle and CodeBlockActions. CodeBlockCopyButton copies its value.
A long title truncates. To link the title to a file, wrap CodeBlockTitle in an anchor.
<CodeBlockHeader>
<a href="/files/route.ts">
<CodeBlockTitle>app/api/chat/route.ts</CodeBlockTitle>
</a>
</CodeBlockHeader> Actions
CodeBlockActions holds any number of buttons. For a wrap toggle, keep the state yourself and pass it to wrap.
<script lang="ts">
let wrap = $state(false);
</script>
<CodeBlockActions>
<Button variant="ghost" size="icon-xs" aria-label="Wrap lines" aria-pressed={wrap} onclick={() => (wrap = !wrap)}>
<WrapTextIcon />
</Button>
<CodeBlockCopyButton value={output} />
</CodeBlockActions>
<CodeBlockContent {wrap} code={output} /> Floating Actions
Without a header, put CodeBlockActions directly inside CodeBlock. The actions float at the top end and stay hidden until the block is hovered or focused. They always show on touch screens.
Line Numbers
Add lineNumbers to CodeBlockContent. A code string is split into lines, and the numbers are not copied with a selection.
Start Line
Set startLine to number lines from a given line, for a snippet from the middle of a file. The gutter grows to fit the widest number.
Wrap
Long lines scroll by default. Add wrap to wrap them, which suits tool output and error messages. To let the reader toggle it, see Actions.
Highlighted Lines
Render each line as a CodeBlockLine and set variant="highlighted" on the lines to call out.
Diff
Set variant="added" or variant="removed" on a CodeBlockLine. Each one adds a + or − marker.
Height
Cap the height with a max-h-* class on CodeBlockContent. The content scrolls inside the block.
CodeBlockContent fades its edges with scroll-fade as it scrolls: left and right by default, top and bottom when it wraps. Add scroll-fade-y to a capped block that does not wrap.
Show More
CodeBlockFooter is a slot below the content. For Show More, cap the content while it is collapsed and put a ghost xs Button in the footer.
<script lang="ts">
let expanded = $state(false);
const id = $props.id();
</script>
<CodeBlockContent {id} class={expanded ? undefined : 'max-h-64 overflow-y-hidden scroll-fade-y'} {code} />
<CodeBlockFooter class="justify-center">
<Button variant="ghost" size="xs" aria-expanded={expanded} aria-controls={id} onclick={() => (expanded = !expanded)}>
{expanded ? 'Show less' : 'Show more'}
</Button>
</CodeBlockFooter> Max Height
To keep a long block from taking over the page, raise the cap instead of removing it. The expanded content scrolls, and the edges fade as you scroll.
Size
Use size="sm" for tool and Trace output. It has tighter padding and text-xs text.
Destructive
Use variant="destructive" for errors and failed output.
Trace
Put a CodeBlock with size="sm" in TraceCollapsibleContent for tool and terminal output. Add wrap and a max-h-64 class to CodeBlockContent so long output wraps and scrolls inside the trace. The block sets its own text color, so TraceCollapsibleContent does not mute it.
Tabs
Show several files with Tabs, one CodeBlock in each TabsContent.
Message
Put a CodeBlock between paragraphs inside a Message.
RTL
Wrap the content in a DirectionProvider to lay it out right to left. Code always reads left to right. The header follows the page direction. Use the buttons to switch the language.
Syntax Highlighting
useHighlightedCode highlights with Shiki and returns one HTML string per line for CodeBlockLine.
<script lang="ts">
const highlighted = useHighlightedCode(() => code, () => 'json');
</script>
<CodeBlock>
<CodeBlockContent>
{#each highlighted.lines as line, index (index)}
<CodeBlockLine>{@html line}</CodeBlockLine>
{/each}
</CodeBlockContent>
</CodeBlock> Shiki uses its JavaScript regex engine, so there is no WASM to load. The highlighter escapes the code, so {@html} renders the tokens and never markup from the code.
Languages
Each language loads the first time it is used. Edit the list in highlighter.ts. A language that is not in the list renders as plain text.
The list ships with bash, css, diff, html, javascript, json, jsx, markdown, python, sql, tsx, typescript and yaml, plus aliases such as ts, js, sh and yml.
Terminal Output
Pass "ansi" as the language to render the colors in terminal output. Without it, escape codes show as text.
ANSI colors do not carry from one line to the next.
Server Rendering
highlightCode is async, so it can run in a load function on the server and Shiki stays out of the client bundle.
import { highlightCode } from '#lib/components/ui/code-block';
export async function load() {
const code = 'export const answer = 42;';
return { code, lines: await highlightCode(code, 'tsx') };
} <script lang="ts">
import { CodeBlock, CodeBlockContent, CodeBlockLine } from '#lib/components/ui/code-block';
let { data } = $props();
</script>
<CodeBlock>
<CodeBlockContent lineNumbers>
{#each data.lines as line, index (index)}
<CodeBlockLine>{@html line}</CodeBlockLine>
{/each}
</CodeBlockContent>
</CodeBlock> Streaming
While the code streams, useHighlightedCode tokenizes only the new lines, starting where the last line left off. Lines that did not change keep the same strings, so Svelte leaves their DOM alone.
Accessibility
CodeBlockContentis aprewith acodeelement and always setsdir="ltr", so code reads left to right on an RTL page.CodeBlockContenthastabindex="0", so keyboard users can focus it and scroll it. Passtabindex={-1}to remove the tab stop.- Floating actions show when focus is inside the block, not only on hover, so keyboard users can reach them.
- Line numbers are hidden from screen readers.
- Diff lines carry a
+or−marker as well as a tint, so the change does not rely on color. CodeBlockCopyButtonis labelled "Copy", and a polite live region announces "Copied" after a successful copy.- Give a wrap toggle
aria-pressedso its state is announced. - Give a Show More button
aria-expandedand pointaria-controlsat theidofCodeBlockContent.
API reference
CodeBlock
The surface that holds the header, the content and the footer.
CodeBlockHeader
A row above the content for a title and actions.
CodeBlockTitle
A filename or a label, with an optional icon. It truncates.
CodeBlockActions
Holds buttons at the end of the header. As a direct child of CodeBlock, it floats.
CodeBlockCopyButton
A ghost icon button that copies value to the clipboard. It accepts the props of Button.
CodeBlockContent
The pre that scrolls or wraps.
CodeBlockLine
One line of code.
CodeBlockFooter
A row below the content, such as for a Show More button.
useHighlightedCode
const highlighted = useHighlightedCode(() => code, () => language); Returns an object whose lines holds one HTML string per line. It holds escaped plain lines until the first pass finishes, and for a language that is not in the list. Call it while a component initializes, because it loads the language in an effect.