Installation
npx shadcn@latest add @pixeldosa/code-blockThe example above also uses Message. To copy it as-is, install everything it needs:
npx shadcn@latest add @pixeldosa/code-block @pixeldosa/messageRequires `cn` at `@/lib/utils`. Pass `streaming` while the fence is open. `splitCodeFences` tells you when that is. Highlighting is up to you: pass `highlight`, or leave it off for plain text.
Usage
"use client";
import { CodeBlock, splitCodeFences } from "@/components/ui/code-block";
{splitCodeFences(message.text).map((segment, index) =>
segment.type === "code" ? (
<CodeBlock
key={index}
code={segment.content}
language={segment.language}
streaming={!segment.complete}
onApply={() => insertIntoEditor(segment.content)}
/>
) : (
<Markdown key={index}>{segment.content}</Markdown>
)
)}
Why it exists
People paste generated code into real projects. The failures that matter aren't visual: copying half a function while it's still streaming, a copy that silently fails and pastes the old clipboard, or a markdown renderer that falls apart on an unclosed fence.
Features
- Waits for the stream. While
streaming, the header says "Writing" and Copy and Apply are disabled. - Copy you can trust. It shows "Copied" or "Couldn't copy", and both are announced.
- Apply with a real result. Return a promise and the button shows Applying, Applied, or Retry apply.
- Unclosed fences handled.
splitCodeFencesturns a still-open fence into a streaming block. - Long code stays out of the way. Blocks over 24 lines collapse behind "Show all N lines".
- Scroll or wrap. Long lines scroll by default, and Wrap is one tap away.
- Bring your own highlighter. Pass
highlight. The component has zero dependencies.
Examples
With Shiki (or any highlighter that returns HTML):
const highlight = (code, language) => (
<span dangerouslySetInnerHTML={{ __html: highlighter.codeToHtml(code, { lang: language, theme }) }} />
);
<CodeBlock code={code} language="ts" highlight={highlight} />
A file-scoped block with line numbers that never collapses:
<CodeBlock code={source} filename="src/http/client.ts" showLineNumbers collapseAfter={Infinity} />
Props
| Prop | Type | Description |
|---|---|---|
code | string | Required. |
language | string | Shown in the header if there's no filename. |
filename | string | |
streaming | boolean | Disables Copy and Apply and shows "Writing". |
highlight | (code, language) => ReactNode | Optional highlighter. |
onApply | () => void | Promise<void> | Adds an Apply action. |
applyLabel | string | Defaults to "Apply". |
showLineNumbers | boolean | Hidden while wrapping. |
collapseAfter | number | Defaults to 24. |
splitCodeFences(markdown)
Returns { type: "text", content } and { type: "code", content, language, complete }
segments. A trailing fence that hasn't closed yet has complete: false.
Accessibility
- The block is a group named after its file or language, and
aria-busywhile streaming. - Copy and apply results are announced in a polite status region.
- Wrap is a toggle button with
aria-pressed, and "Show all" controls the code region. - Line numbers are decorative and hidden from screen readers.
When not to use
Not for editing code. It shows code for reading, copying and applying. For reviewing changes
to existing code, use Diff Accept.
why it's built this way
Engineering Notes
Built around the next action people take with generated code, which is copying it somewhere that matters. Streaming state is explicit: the group is `aria-busy`, the header reads 'Writing', and both Copy and Apply are disabled until the stream completes. Copy handles clipboard failure (permissions, insecure contexts) with a visible and announced 'Couldn't copy', because the silent alternative pastes stale clipboard contents. Apply is promise-aware, so the caller's real result drives 'Applying', 'Applied' or 'Retry apply'. `splitCodeFences` exists because markdown renderers handle an unclosed fence badly mid-stream. It detects fences with matching marker type and length (``` or ~~~, three or more), treats a trailing unclosed fence as an incomplete code segment, and leaves text segments for the caller's renderer. Highlighting is a render prop, so the registry item has zero dependencies and teams keep whichever highlighter their app already loads. Line numbers are a separate aria-hidden gutter computed from the line count, which works with any highlighter's output, and are hidden when wrapping, since wrapped lines would misalign them. A trailing newline is trimmed, so streams don't show a phantom empty line.
how it moves, and when it doesn't
Motion Notes
Motion only for machine work. 'Writing' in the header and 'Applying' on the button take the shared `pd-shimmer`, because both are the machine's turn. The code itself never shimmers or animates in, since it is content. Button feedback ('Copied', 'Applied') is a text swap, not an animation, so it can be read immediately, and 'Copied' reverts after 2s. Hover colour runs at `--pd-duration-instant`. The collapse fade is static. Everything is static under prefers-reduced-motion.