- Installation
- Usage
- Syntax Highlighting
- Examples
- Basic
- Header
- Actions
- Floating Actions
- Line Numbers
- Start Line
- Wrap
- Highlighted Lines
- Diff
- Height
- Show More
- Max Height
- Size
- Destructive
- Trace
- Tabs
- Message
- RTL
- Accessibility
- API Reference
-
CodeBlock -
CodeBlock:Actions -
CodeBlock:Content -
CodeBlock:CopyButton -
CodeBlock:Footer -
CodeBlock:Header -
CodeBlock:Line -
CodeBlock:Title -
code-block
Code Block
Displays code, terminal output and diffs with an optional header, line numbers and a copy button.
{% set lines = [
'import { streamText } from "ai"',
'',
'export async function POST(request: Request) {',
' const { messages } = await request.json()',
'',
' const result = streamText({',
' model: "anthropic/claude-sonnet-4.5",',
' system: "You are a helpful assistant. Answer in one short paragraph.",',
' messages,',
' })',
'',
' return result.toUIMessageStreamResponse()',
'}',
] %}
<div class="w-full max-w-lg">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>
<twig:ux:icon name="lucide:file-code" />
app/api/chat/route.ts
</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:Button variant="ghost" size="icon-xs" aria-label="Wrap lines" aria-pressed="false" class="aria-pressed:text-foreground" data-action="code-block#toggleWrap">
<twig:ux:icon name="lucide:wrap-text" />
</twig:Button>
<twig:Button variant="ghost" size="icon-xs" aria-label="Download" data-action="code-block#download" data-code-block-text="{{ lines|join('\n') }}" data-code-block-filename-param="route.ts">
<twig:ux:icon name="lucide:download" />
</twig:Button>
<twig:CodeBlock:CopyButton :value="lines|join('\n')" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content lineNumbers>
{%- for line in lines -%}
<twig:CodeBlock:Line variant="{{ loop.index0 >= 5 and loop.index0 <= 9 ? 'highlighted' : 'default' }}">{{ line }}</twig:CodeBlock:Line>
{%- endfor -%}
</twig:CodeBlock:Content>
</twig:CodeBlock>
</div>
Installation
Available since UX Toolkit 3.7.
php bin/console ux:install code-block --kit shadcn
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0 symfony/ux-icons
Install the following front-end dependencies:
npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css
Copy the following file(s) into your app:
import { Controller } from '@hotwired/stimulus';
/**
* @value copiedDuration How long, in milliseconds, a copy button shows its "Copied" state.
* @target content The `pre` element holding the code.
* @target status The live region that announces a successful copy.
* @action copy Copies the `data-code-block-text` of the clicked button to the clipboard.
* @action download Downloads the `data-code-block-text` of the clicked button as a file named by its `data-code-block-filename-param`.
* @action toggleWrap Toggles the wrapping of long lines, and the `aria-pressed` state of the clicked button.
* @action toggleExpanded Toggles `data-expanded` on the content the clicked button controls, and its `aria-expanded` state.
*/
export default class extends Controller {
static targets = ['content', 'status'];
static values = { copiedDuration: { type: Number, default: 2000 } };
#copiedTimeouts = new Map();
#labels = new WeakMap();
disconnect() {
// Reset the buttons too, or one copied right before a disconnect would keep its "Copied" state, e.g. in a Turbo snapshot.
for (const [button, timeout] of this.#copiedTimeouts) {
clearTimeout(timeout);
this.#setCopied(button, false);
}
this.#copiedTimeouts.clear();
}
async copy({ currentTarget: button }) {
try {
await navigator.clipboard.writeText(button.dataset.codeBlockText ?? '');
} catch {
return;
}
this.#setCopied(button, true);
clearTimeout(this.#copiedTimeouts.get(button));
this.#copiedTimeouts.set(
button,
setTimeout(() => {
this.#copiedTimeouts.delete(button);
this.#setCopied(button, false);
}, this.copiedDurationValue)
);
}
download({ currentTarget: button, params: { filename } }) {
const url = URL.createObjectURL(new Blob([button.dataset.codeBlockText ?? ''], { type: 'text/plain' }));
const link = document.createElement('a');
link.href = url;
link.download = String(filename ?? 'code.txt');
link.click();
// Revoked on the next task, so the browser has started the download first.
setTimeout(() => URL.revokeObjectURL(url));
}
toggleWrap({ currentTarget: button }) {
const wrap = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(wrap));
for (const content of this.contentTargets) {
content.toggleAttribute('data-wrap', wrap);
}
}
toggleExpanded({ currentTarget: button }) {
const expanded = button.getAttribute('aria-expanded') !== 'true';
const controls = button.getAttribute('aria-controls');
const content = this.contentTargets.find((target) => target.id === controls) ?? this.contentTarget;
button.setAttribute('aria-expanded', String(expanded));
content.toggleAttribute('data-expanded', expanded);
}
#setCopied(button, copied) {
if (!this.#labels.has(button)) {
this.#labels.set(button, button.getAttribute('aria-label') ?? 'Copy');
}
button.dataset.copied = String(copied);
button.setAttribute('aria-label', copied ? 'Copied' : this.#labels.get(button));
const status = button.nextElementSibling;
if (status && this.statusTargets.includes(status)) {
status.textContent = copied ? 'Copied' : '';
}
}
}
{%- props
## 'default'|'destructive' The tone of the block.
variant = 'default',
## 'default'|'sm' The size of the block; `sm` has tighter padding and `text-xs` code, for tool output.
size = 'default'
-%}
{%- set style = html_cva(
base: 'group/code-block relative flex w-full min-w-0 flex-col overflow-hidden 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',
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',
},
},
) -%}
<div
data-slot="code-block"
data-variant="{{ variant }}"
data-size="{{ size }}"
{{ attributes.defaults({
class: style.apply({variant: variant})|tailwind_classes,
'data-controller': 'code-block',
}) }}
>
{##- The block structure, typically includes `CodeBlock:Header`, `CodeBlock:Content` and optionally `CodeBlock:Footer`. -#}
{%- block content %}{% endblock -%}
</div>
<div
data-slot="code-block-actions"
{{ attributes.defaults({
class: 'ms-auto -my-1 -me-1.5 flex shrink-0 items-center gap-1 [[data-slot=code-block]>&]:absolute [[data-slot=code-block]>&]:end-2 [[data-slot=code-block]>&]:top-2 [[data-slot=code-block]>&]:z-10 [[data-slot=code-block]>&]:m-0 [[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 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:end-1.5 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:top-1.5 [[data-slot=code-block]>&]:*:[button]:bg-background [[data-slot=code-block]>&]:*:[button]:ring-1 [[data-slot=code-block]>&]:*:[button]:ring-foreground/10'|tailwind_classes,
}) }}
>
{##- The action buttons, such as a `CodeBlock:CopyButton`. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## string|null The code to render as plain text. Without it, the content block is rendered.
code = null,
## boolean Whether long lines wrap instead of scrolling.
wrap = false,
## boolean Whether each line is numbered. A `code` string is split into lines.
lineNumbers = false,
## number The number of the first line, when `lineNumbers` is set.
startLine = 1
-%}
<pre
data-slot="code-block-content"
{% if wrap %}data-wrap{% endif %}
{% if lineNumbers %}data-numbered{% endif %}
data-code-block-target="content"
dir="ltr"
{{ attributes.defaults({
class: 'min-h-0 min-w-0 scroll-fade-x overflow-auto px-3 py-3 text-start font-mono leading-relaxed outline-none [scrollbar-width:thin] data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap 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'|tailwind_classes,
tabindex: '0',
}) }}
><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 lineNumbers %} style="counter-reset: line {{ startLine - 1 }}"{% endif %}>
{%- if code is not null -%}
{#- Concatenated to a plain string, so the code is escaped even when it comes from a captured `set`. -#}
{%- set _code = ('' ~ code)|replace({'\r\n': '\n'}) -%}
{%- if lineNumbers -%}
{%- for _line in (_code|trim('\n', 'right'))|split('\n') -%}
<twig:CodeBlock:Line>{{ _line }}</twig:CodeBlock:Line>
{%- endfor -%}
{%- else -%}
{{- _code -}}
{%- endif -%}
{%- else -%}
{##- The code lines, as a list of `CodeBlock:Line`, when no `code` is given. -#}
{%- block content %}{% endblock -%}
{%- endif -%}
</code></pre>
{%- props
## string The text to copy.
value,
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
variant = 'ghost',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'icon-xs'
-%}
<twig:Button
variant="{{ variant }}"
size="{{ size }}"
data-slot="code-block-copy-button"
data-copied="false"
data-code-block-text="{{ value }}"
{{ ...attributes.defaults({
'aria-label': 'Copy',
'data-action': 'click->code-block#copy'|html_attr_type('sst'),
}) }}
>
<twig:ux:icon name="lucide:copy" class="group-data-[copied=true]/button:hidden" />
<twig:ux:icon name="lucide:check" class="hidden group-data-[copied=true]/button:inline" />
</twig:Button>
<span aria-live="polite" class="sr-only" data-code-block-target="status"></span>
<div
data-slot="code-block-footer"
{{ attributes.defaults({
class: 'flex min-w-0 shrink-0 items-center 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'|tailwind_classes,
}) }}
>
{##- The footer content, such as a Show More button. -#}
{%- block content %}{% endblock -%}
</div>
<div
data-slot="code-block-header"
{{ attributes.defaults({
class: 'flex min-w-0 shrink-0 items-center gap-2 px-3 pt-3 text-muted-foreground *:has-[>[data-slot=code-block-title]]:min-w-0 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'|tailwind_classes,
}) }}
>
{##- The header content, typically includes `CodeBlock:Title` and `CodeBlock:Actions`. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'default'|'added'|'removed'|'highlighted' The state of the line.
variant = 'default'
-%}
{%- set style = html_cva(
base: "relative -mx-3 block min-h-lh px-3 group-data-[size=sm]/code-block:-mx-2.5 group-data-[size=sm]/code-block:px-2.5 before:min-w-6 before:text-end before:text-muted-foreground after:absolute after:start-1 after:top-0 after:text-muted-foreground after:select-none in-data-numbered:col-span-2 in-data-numbered:grid in-data-numbered:grid-cols-subgrid in-data-numbered:gap-x-3 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: '',
highlighted: 'bg-muted',
added: "bg-muted after:content-['+']",
removed: "bg-destructive/10 after:text-destructive after:content-['−']",
},
},
) -%}
<span
data-slot="code-block-line"
data-variant="{{ variant }}"
{{ attributes.defaults({
class: style.apply({variant: variant})|tailwind_classes,
}) }}
><span data-slot="code-block-line-content" class="min-w-0">
{%- set _content -%}
{##- The line content: text or highlighted tokens. An empty line holds a newline, so a copied selection keeps blank lines. -#}
{{- block('content') -}}
{%- endset -%}
{{- _content ?: '\n' -}}
</span></span>
<div
data-slot="code-block-title"
{{ attributes.defaults({
class: "min-w-0 truncate font-sans text-sm font-medium text-foreground group-data-[variant=destructive]/code-block:text-destructive group-data-[size=sm]/code-block:text-xs [&_svg]:pointer-events-none [&_svg]:inline-block [&_svg]:align-middle *:[svg]:me-1.5 [&_svg:not([class*='size-'])]:size-3.5"|tailwind_classes,
}) }}
>
{##- A filename or a label, with an optional icon. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
variant = 'default',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'default',
## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
as = 'button'
-%}
{%- set style = html_cva(
base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
) -%}
<{{ as }}
data-slot="{{ attributes.render('data-slot')|default('button') }}"
data-variant="{{ variant }}"
data-size="{{ size }}"
{{ attributes.defaults({
class: style.apply({variant: variant, size: size})|tailwind_classes,
type: as == 'button' ? 'button' : false,
}) }}
>
{##- The button label and/or icon. -#}
{%- block content %}{% endblock -%}
</{{ as }}>
Usage
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>Terminal</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="output" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content :code="output" />
</twig:CodeBlock>
CodeBlock:Content takes a code string, or a list of CodeBlock:Line. Use lines when you need line variants or highlighted tokens.
Syntax Highlighting
This recipe does not highlight code. To color it, highlight the code in your application, then pass each highlighted line to a CodeBlock:Line.
<twig:CodeBlock>
<twig:CodeBlock:Content>
{% for line in highlighted_lines %}
<twig:CodeBlock:Line>{{ line|raw }}</twig:CodeBlock:Line>
{% endfor %}
</twig:CodeBlock:Content>
</twig:CodeBlock>
Here, highlighted_lines is a list of HTML strings, one per line, built by your application. Only print a line with raw when it comes from a highlighter that escapes the code.
- Phiki: a PHP port of Shiki, the highlighter Shadcn uses. It uses the same TextMate grammars and VS Code themes. It returns tokens line by line, and accepts a light and a dark theme.
- tempest/highlight: a fast PHP highlighter. It returns the whole block as one HTML string. A token that spans several lines (a block comment, a multi-line string) has to be split by hand.
- Shiki in a Stimulus controller: the same engine as Shadcn, running in the browser. Its grammars and themes add a lot of JavaScript to load.
Examples
Basic
Pass a string to the code prop of CodeBlock:Content. It renders as one text node.
{% set output = [
'$ pnpm build',
'> next build',
'',
' ✓ Compiled successfully in 4.2s',
' ✓ Generating static pages (12/12)',
'',
'Route (app) Size First Load JS',
'┌ ○ / 1.9 kB 112 kB',
'├ ○ /_not-found 993 B 103 kB',
'└ ƒ /api/chat 141 B 102 kB',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Content :code="output" />
</twig:CodeBlock>
</div>
Header
CodeBlock:Header holds a CodeBlock:Title and CodeBlock:Actions. CodeBlock:CopyButton copies its value. A long title truncates.
{% set command = 'pnpm add ai @ai-sdk/react zod' %}
{% set output = [
'$ ' ~ command,
'Packages: +14',
'Progress: resolved 412, reused 398, downloaded 14, added 14, done',
'Done in 2.8s using pnpm v10.18.0',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>
<twig:ux:icon name="lucide:terminal" />
Terminal
</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="command" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content :code="output" />
</twig:CodeBlock>
</div>
Actions
CodeBlock:Actions holds any number of buttons. The code-block controller provides a toggleWrap action for a wrap toggle, and a download action that saves the data-code-block-text of the button.
{% set output = [
'$ pnpm lint',
'apps/web/components/message-list.tsx',
' 18:22 error \'messages\' is possibly \'undefined\'. Add a guard before calling map @typescript-eslint/no-unsafe-call',
' 42:7 warning React Hook React.useEffect has a missing dependency: \'scrollToBottom\' react-hooks/exhaustive-deps',
'',
'✖ 2 problems (1 error, 1 warning)',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>
<twig:ux:icon name="lucide:terminal" />
Terminal
</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:Button variant="ghost" size="icon-xs" aria-label="Wrap lines" aria-pressed="true" class="aria-pressed:text-foreground" data-action="code-block#toggleWrap">
<twig:ux:icon name="lucide:wrap-text" />
</twig:Button>
<twig:Button variant="ghost" size="icon-xs" aria-label="Download" data-action="code-block#download" data-code-block-text="{{ output }}" data-code-block-filename-param="lint.log">
<twig:ux:icon name="lucide:download" />
</twig:Button>
<twig:CodeBlock:CopyButton :value="output" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content wrap :code="output" />
</twig:CodeBlock>
</div>
Floating Actions
Without a header, put CodeBlock:Actions 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.
{% set output = [
'$ pnpm add ai @ai-sdk/react zod',
'Progress: resolved 412, reused 398, downloaded 14, added 14, done',
'dependencies:',
'+ @ai-sdk/react 3.0.12',
'+ ai 6.0.12',
'+ zod 4.1.12',
'Done in 2.8s using pnpm v10.18.0',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="output" />
</twig:CodeBlock:Actions>
<twig:CodeBlock:Content :code="output" />
</twig:CodeBlock>
</div>
Line Numbers
Add lineNumbers to CodeBlock:Content. A code string is split into lines, and the numbers are not copied with a selection.
{% set code = [
'{',
' "name": "chat",',
' "private": true,',
' "scripts": {',
' "dev": "next dev",',
' "build": "next build"',
' }',
'}',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Content lineNumbers :code="code" />
</twig:CodeBlock>
</div>
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.
{% set code = [
' if (!session) {',
' redirect("/login")',
' }',
'',
' const projects = await db.project.findMany({',
' where: { ownerId: session.user.id },',
' orderBy: { updatedAt: "desc" },',
' })',
'',
' return <ProjectList projects={projects} />',
'}',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>app/dashboard/page.tsx</twig:CodeBlock:Title>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content lineNumbers :startLine="995" :code="code" />
</twig:CodeBlock>
</div>
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.
{% set output = 'Error: The model "anthropic/claude-sonnet-4.5" returned an error: Request too large. The prompt is 212,431 tokens and the context window is 200,000 tokens. Remove some messages or summarize the conversation and try again.' %}
<div class="flex w-full max-w-md flex-col gap-4">
<twig:CodeBlock>
<twig:CodeBlock:Content :code="output" />
</twig:CodeBlock>
<twig:CodeBlock>
<twig:CodeBlock:Content wrap :code="output" />
</twig:CodeBlock>
</div>
Highlighted Lines
Render each line as a CodeBlock:Line and set variant="highlighted" on the lines to call out.
{% set lines = [
'export default {',
' experimental: {',
' reactCompiler: true,',
' viewTransition: true,',
' },',
' images: {',
' remotePatterns: [{ hostname: "avatars.githubusercontent.com" }],',
' },',
'}',
] %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Content lineNumbers>
{%- for line in lines -%}
<twig:CodeBlock:Line variant="{{ loop.index0 in [2, 3] ? 'highlighted' : 'default' }}">{{ line }}</twig:CodeBlock:Line>
{%- endfor -%}
</twig:CodeBlock:Content>
</twig:CodeBlock>
</div>
Diff
Set variant="added" or variant="removed" on a CodeBlock:Line. Each one adds a + or − marker.
{% set patch = [
' export function Greeting({ name }: { name: string }) {',
'- return <p>Hello, {name}</p>',
'+ return <p className="text-sm text-muted-foreground">',
'+ Hello, {name}!',
'+ </p>',
' }',
] %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>components/greeting.tsx</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="patch|join('\n')" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content>
{%- for line in patch -%}
<twig:CodeBlock:Line variant="{{ line starts with '+' ? 'added' : (line starts with '-' ? 'removed' : 'default') }}">{{ line|slice(1) }}</twig:CodeBlock:Line>
{%- endfor -%}
</twig:CodeBlock:Content>
</twig:CodeBlock>
</div>
Height
Cap the height with a max-h-* class on CodeBlock:Content. The content scrolls inside the block. It 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.
{% set logs = (1..40)|map(index => '[' ~ ('%02d'|format(index)) ~ '] GET /api/chat 200 in ' ~ (12 + ((index - 1) * 7) % 40) ~ 'ms')|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>Logs</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="logs" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content class="max-h-48 scroll-fade-y" :code="logs" />
</twig:CodeBlock>
</div>
Show More
CodeBlock:Footer is a slot below the content. For Show More, cap the content while it is collapsed with not-data-expanded:* classes, and put a ghost xs Button in the footer. Its toggleExpanded action toggles data-expanded on the content its aria-controls points at.
{% set code = [
'"use client"',
'',
'import * as React from "react"',
'import { useChat } from "@ai-sdk/react"',
'',
'import { Button } from "@/components/ui/button"',
'import { Input } from "@/components/ui/input"',
'',
'export function Chat() {',
' const [input, setInput] = React.useState("")',
' const { messages, sendMessage, status } = useChat()',
'',
' return (',
' <div className="flex flex-col gap-4">',
' {messages.map((message) => (',
' <div key={message.id}>',
' {message.parts.map((part, index) =>',
' part.type === "text" ? <p key={index}>{part.text}</p> : null',
' )}',
' </div>',
' ))}',
' <form',
' onSubmit={(event) => {',
' event.preventDefault()',
' sendMessage({ text: input })',
' setInput("")',
' }}',
' >',
' <Input value={input} onChange={(event) => setInput(event.target.value)} />',
' <Button disabled={status !== "ready"}>Send</Button>',
' </form>',
' </div>',
' )',
'}',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>components/chat.tsx</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="code" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content id="code-block-show-more" lineNumbers class="not-data-expanded:max-h-64 not-data-expanded:overflow-y-hidden not-data-expanded:scroll-fade-y" :code="code" />
<twig:CodeBlock:Footer class="justify-center">
<twig:Button variant="ghost" size="xs" aria-expanded="false" aria-controls="code-block-show-more" data-action="code-block#toggleExpanded">
<twig:ux:icon name="lucide:chevron-down" class="transition-transform in-aria-expanded:rotate-180" />
<span class="group-aria-expanded/button:hidden">Show more</span>
<span class="hidden group-aria-expanded/button:inline">Show less</span>
</twig:Button>
</twig:CodeBlock:Footer>
</twig:CodeBlock>
</div>
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.
{% set code = [
'"use client"',
'',
'import * as React from "react"',
'import { useChat } from "@ai-sdk/react"',
'',
'import { Button } from "@/components/ui/button"',
'import { Input } from "@/components/ui/input"',
'',
'export function Chat() {',
' const [input, setInput] = React.useState("")',
' const { messages, sendMessage, status } = useChat()',
'',
' return (',
' <div className="flex flex-col gap-4">',
' {messages.map((message) => (',
' <div key={message.id}>',
' {message.parts.map((part, index) =>',
' part.type === "text" ? <p key={index}>{part.text}</p> : null',
' )}',
' </div>',
' ))}',
' <form',
' onSubmit={(event) => {',
' event.preventDefault()',
' sendMessage({ text: input })',
' setInput("")',
' }}',
' >',
' <Input value={input} onChange={(event) => setInput(event.target.value)} />',
' <Button disabled={status !== "ready"}>Send</Button>',
' </form>',
' </div>',
' )',
'}',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>components/chat.tsx</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="code" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content id="code-block-max-height" lineNumbers class="max-h-96 scroll-fade-y not-data-expanded:max-h-64 not-data-expanded:overflow-y-hidden" :code="code" />
<twig:CodeBlock:Footer class="justify-center">
<twig:Button variant="ghost" size="xs" aria-expanded="false" aria-controls="code-block-max-height" data-action="code-block#toggleExpanded">
<twig:ux:icon name="lucide:chevron-down" class="transition-transform in-aria-expanded:rotate-180" />
<span class="group-aria-expanded/button:hidden">Show more</span>
<span class="hidden group-aria-expanded/button:inline">Show less</span>
</twig:Button>
</twig:CodeBlock:Footer>
</twig:CodeBlock>
</div>
Size
Use size="sm" for tool and Trace output. It has tighter padding and text-xs text.
{% set output = [
'$ pnpm add ai @ai-sdk/react zod',
'Progress: resolved 412, reused 398, downloaded 14, added 14, done',
'Done in 2.8s using pnpm v10.18.0',
]|join('\n') %}
<div class="flex w-full max-w-md flex-col gap-4">
{% for size in ['default', 'sm'] %}
<twig:CodeBlock size="{{ size }}">
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>
<twig:ux:icon name="lucide:terminal" />
Terminal
</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="output" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content wrap :code="output" />
</twig:CodeBlock>
{% endfor %}
</div>
Destructive
Use variant="destructive" for errors and failed output.
{% set error = [
'TypeError: Cannot read properties of undefined (reading \'map\')',
' at MessageList (components/message-list.tsx:18:22)',
' at renderWithHooks (react-dom.development.js:16305:18)',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:CodeBlock variant="destructive">
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>Error</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="error" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content wrap :code="error" />
</twig:CodeBlock>
</div>
Trace
Put a CodeBlock with size="sm" in Trace:Collapsible:Content for tool and terminal output. Add wrap and a max-h-64 class to CodeBlock:Content so long output wraps and scrolls inside the trace. The block sets its own text color, so Trace:Collapsible:Content does not mute it.
{% set output = [
'$ node --version',
'v24.18.0',
]|join('\n') %}
{% set error = [
'$ pnpm dev',
'Error: listen EADDRINUSE: address already in use :::3000',
' at Server.setupListenHandle [as _listen2] (node:net:1908:16)',
]|join('\n') %}
<div class="w-full max-w-md">
<twig:Trace>
<twig:Trace:Collapsible open>
<twig:Trace:Collapsible:Trigger>
<twig:Trace:Line>
<twig:Trace:Icon>
<twig:ux:icon name="lucide:terminal" />
</twig:Trace:Icon>
<twig:Trace:Title>Ran node --version</twig:Trace:Title>
</twig:Trace:Line>
</twig:Trace:Collapsible:Trigger>
<twig:Trace:Collapsible:Content>
<twig:CodeBlock size="sm">
<twig:CodeBlock:Content wrap class="max-h-64" :code="output" />
</twig:CodeBlock>
</twig:Trace:Collapsible:Content>
</twig:Trace:Collapsible>
<twig:Trace:Collapsible open>
<twig:Trace:Collapsible:Trigger>
<twig:Trace:Line status="error">
<twig:Trace:Icon>
<twig:ux:icon name="lucide:terminal" />
</twig:Trace:Icon>
<twig:Trace:Title>Failed to run pnpm dev</twig:Trace:Title>
</twig:Trace:Line>
</twig:Trace:Collapsible:Trigger>
<twig:Trace:Collapsible:Content>
<twig:CodeBlock size="sm" variant="destructive">
<twig:CodeBlock:Content wrap class="max-h-64" :code="error" />
</twig:CodeBlock>
</twig:Trace:Collapsible:Content>
</twig:Trace:Collapsible>
</twig:Trace>
</div>
Tabs
Show several files with Tabs, one CodeBlock in each Tabs:Content.
{% set files = [
{
id: 'route',
title: 'app/api/chat/route.ts',
code: [
'import { streamText } from "ai"',
'',
'export async function POST(request: Request) {',
' const { messages } = await request.json()',
' const result = streamText({ model: "anthropic/claude-sonnet-4.5", messages })',
'',
' return result.toUIMessageStreamResponse()',
'}',
]|join('\n'),
},
{
id: 'chat',
title: 'components/chat.tsx',
code: [
'"use client"',
'',
'import { useChat } from "@ai-sdk/react"',
'',
'export function Chat() {',
' const { messages } = useChat()',
'',
' return <MessageList messages={messages} />',
'}',
]|join('\n'),
},
{
id: 'env',
title: '.env.local',
code: 'AI_GATEWAY_API_KEY=your-api-key',
},
] %}
<twig:Tabs defaultValue="route" class="w-full max-w-md">
<twig:Tabs:List>
{% for file in files %}
<twig:Tabs:Trigger value="{{ file.id }}">{{ file.title|split('/')|last }}</twig:Tabs:Trigger>
{% endfor %}
</twig:Tabs:List>
{% for file in files %}
<twig:Tabs:Content value="{{ file.id }}">
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>{{ file.title }}</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="file.code" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content lineNumbers :code="file.code" />
</twig:CodeBlock>
</twig:Tabs:Content>
{% endfor %}
</twig:Tabs>
Message
Put a CodeBlock between paragraphs inside a Message.
{% set code = [
'import { streamText } from "ai"',
'',
'export async function POST(request: Request) {',
' const { messages } = await request.json()',
'',
' const result = streamText({',
' model: "anthropic/claude-sonnet-4.5",',
' messages,',
' })',
'',
' return result.toUIMessageStreamResponse()',
'}',
]|join('\n') %}
<div class="flex w-full max-w-md flex-col gap-4 text-sm">
<twig:Message align="end">
<twig:Message:Content>
<twig:Bubble variant="muted">
<twig:Bubble:Content>How do I stream a response?</twig:Bubble:Content>
</twig:Bubble>
</twig:Message:Content>
</twig:Message>
<twig:Message>
<twig:Message:Content class="gap-3">
<p>Create a route handler that returns the stream:</p>
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>app/api/chat/route.ts</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="code" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content :code="code" />
</twig:CodeBlock>
<p>The client reads it with useChat.</p>
</twig:Message:Content>
</twig:Message>
</div>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element.
Code always reads left to right. The header follows the page direction.
{% set code = [
'pnpm add ai @ai-sdk/react zod',
'pnpm dev',
]|join('\n') %}
<div class="flex w-full max-w-md flex-col gap-8">
{% for translation in [
{description: 'ثبّت الحزم، ثم شغّل خادم التطوير.', title: 'الطرفية'},
{description: 'התקן את החבילות, ואז הפעל את שרת הפיתוח.', title: 'מסוף'},
] %}
<div dir="rtl" class="flex flex-col gap-3 text-sm">
<p>{{ translation.description }}</p>
<twig:CodeBlock>
<twig:CodeBlock:Header>
<twig:CodeBlock:Title>
<twig:ux:icon name="lucide:terminal" />
{{ translation.title }}
</twig:CodeBlock:Title>
<twig:CodeBlock:Actions>
<twig:CodeBlock:CopyButton :value="code" />
</twig:CodeBlock:Actions>
</twig:CodeBlock:Header>
<twig:CodeBlock:Content lineNumbers :code="code" />
</twig:CodeBlock>
</div>
{% endfor %}
</div>
Accessibility
CodeBlock:Contentis aprewith acodeelement and always setsdir="ltr", so code reads left to right on an RTL page.CodeBlock:Contenthastabindex="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 and diff markers are generated content, left out of a copied selection, and blank lines survive the copy.
- Diff lines carry a
+or−marker as well as a tint, so the change does not rely on color. CodeBlock:CopyButtonis labelled "Copy", and a polite live region announces "Copied" after a successful copy.- Give a wrap toggle
aria-pressedso its state is announced; thetoggleWrapaction keeps it in sync. - Give a Show More button
aria-expandedand pointaria-controlsat theidofCodeBlock:Content; thetoggleExpandedaction keeps it in sync.
API Reference
<twig:CodeBlock>
| Prop | Type | Default |
|---|---|---|
variant The tone of the block.
|
'default'|'destructive' |
'default' |
size The size of the block;
sm has tighter padding and text-xs code, for tool output. |
'default'|'sm' |
'default' |
| Block | Description |
|---|---|
content |
The block structure, typically includes CodeBlock:Header, CodeBlock:Content and optionally CodeBlock:Footer. |
<twig:CodeBlock:Actions>
| Block | Description |
|---|---|
content |
The action buttons, such as a CodeBlock:CopyButton. |
<twig:CodeBlock:Content>
| Prop | Type | Default |
|---|---|---|
code The code to render as plain text. Without it, the content block is rendered.
|
string|null |
null |
wrap Whether long lines wrap instead of scrolling.
|
boolean |
false |
lineNumbers Whether each line is numbered. A
code string is split into lines. |
boolean |
false |
startLine The number of the first line, when
lineNumbers is set. |
number |
1 |
| Block | Description |
|---|---|
content |
The code lines, as a list of CodeBlock:Line, when no code is given. |
<twig:CodeBlock:CopyButton>
| Prop | Type | Default |
|---|---|---|
value The text to copy.
|
string |
- |
variant The visual style variant.
|
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' |
'ghost' |
size The button size.
|
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' |
'icon-xs' |
<twig:CodeBlock:Footer>
| Block | Description |
|---|---|
content |
The footer content, such as a Show More button. |
<twig:CodeBlock:Header>
| Block | Description |
|---|---|
content |
The header content, typically includes CodeBlock:Title and CodeBlock:Actions. |
<twig:CodeBlock:Line>
| Prop | Type | Default |
|---|---|---|
variant The state of the line.
|
'default'|'added'|'removed'|'highlighted' |
'default' |
| Block | Description |
|---|---|
content |
The line content: text or highlighted tokens. An empty line holds a newline, so a copied selection keeps blank lines. |
<twig:CodeBlock:Title>
| Block | Description |
|---|---|
content |
A filename or a label, with an optional icon. |
data-controller="code-block"
| Value | Type | Default |
|---|---|---|
data-code-block-copied-duration-value How long, in milliseconds, a copy button shows its "Copied" state.
|
Number |
2000 |
| Target | Description |
|---|---|
content |
The pre element holding the code. |
status |
The live region that announces a successful copy. |
| Action | Description |
|---|---|
copy |
Copies the data-code-block-text of the clicked button to the clipboard. |
download |
Downloads the data-code-block-text of the clicked button as a file named by its data-code-block-filename-param. |
toggleWrap |
Toggles the wrapping of long lines, and the aria-pressed state of the clicked button. |
toggleExpanded |
Toggles data-expanded on the content the clicked button controls, and its aria-expanded state. |