View as Markdown

Code Block

Displays code, terminal output and diffs with an optional header, line numbers and a copy button.

100%
Loading...
{% 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

Note

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:

assets/controllers/code_block_controller.js
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' : '';
        }
    }
}
templates/components/CodeBlock.html.twig
{%- 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>
templates/components/CodeBlock/Actions.html.twig
<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>
templates/components/CodeBlock/Content.html.twig
{%- 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>
templates/components/CodeBlock/CopyButton.html.twig
{%- 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>
templates/components/CodeBlock/Footer.html.twig
<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>
templates/components/CodeBlock/Header.html.twig
<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>
templates/components/CodeBlock/Line.html.twig
{%- 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>
templates/components/CodeBlock/Title.html.twig
<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>
templates/components/Button.html.twig
{%- 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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.

100%
Loading...
{% 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:Content is a pre with a code element and always sets dir="ltr", so code reads left to right on an RTL page.
  • CodeBlock:Content has tabindex="0", so keyboard users can focus it and scroll it. Pass tabindex="-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:CopyButton is labelled "Copy", and a polite live region announces "Copied" after a successful copy.
  • Give a wrap toggle aria-pressed so its state is announced; the toggleWrap action keeps it in sync.
  • Give a Show More button aria-expanded and point aria-controls at the id of CodeBlock:Content; the toggleExpanded action keeps it in sync.

API Reference

<twig:CodeBlock>

Prop Type Default
variant 
'default'|'destructive' 'default'
size 
'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 
string|null null
wrap 
boolean false
lineNumbers 
boolean false
startLine 
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 
string -
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'ghost'
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 
'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 
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.