10.7k

Code Block

PreviousNext

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

server/api/chat.post.ts
import { streamText } from "ai"
export default defineEventHandler(async (event) => {  const { messages } = await readBody(event)
  const result = streamText({    model: "anthropic/claude-sonnet-4.5",    system: "You are a helpful assistant. Answer in one short paragraph.",    messages,  })
  return result.toUIMessageStreamResponse()})
<script setup lang="ts">
import { DownloadIcon, FileCodeIcon, WrapTextIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `import { streamText } from "ai"

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)

  const result = streamText({
    model: "anthropic/claude-sonnet-4.5",
    system: "You are a helpful assistant. Answer in one short paragraph.",
    messages,
  })

  return result.toUIMessageStreamResponse()
})`

const [wrap, toggleWrap] = useToggle(false)
const lines = useHighlightedCode(code, 'ts')

function download(value: string, filename: string) {
  const url = URL.createObjectURL(new Blob([value], { type: 'text/plain' }))
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  link.click()
  URL.revokeObjectURL(url)
}
</script>

<template>
  <div class="w-full max-w-lg">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <FileCodeIcon />
          server/api/chat.post.ts
        </CodeBlockTitle>
        <CodeBlockActions>
          <Button
            variant="ghost"
            size="icon-xs"
            aria-label="Wrap lines"
            :aria-pressed="wrap"
            class="aria-pressed:text-foreground"
            @click="toggleWrap()"
          >
            <WrapTextIcon />
          </Button>
          <Button
            variant="ghost"
            size="icon-xs"
            aria-label="Download"
            @click="download(code, 'chat.post.ts')"
          >
            <DownloadIcon />
          </Button>
          <CodeBlockCopyButton :value="code" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent :wrap="wrap" line-numbers>
        <CodeBlockLine
          v-for="(line, index) in lines"
          :key="index"
          :variant="index >= 5 && index <= 9 ? 'highlighted' : 'default'"
        >
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

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 composable, so a block that only shows plain output stays small.

Installation

pnpm dlx shadcn-vue@latest add code-block

code-block also includes useHighlightedCode and CodeBlockToken for syntax highlighting. They live in useHighlightedCode.ts and highlighter.ts in the same folder.

Usage

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockFooter,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const output = `$ pnpm add ai @ai-sdk/vue zod
Done in 2.8s using pnpm v10.18.0`
</script>

<template>
  <CodeBlock>
    <CodeBlockHeader>
      <CodeBlockTitle>Terminal</CodeBlockTitle>
      <CodeBlockActions>
        <CodeBlockCopyButton :value="output" />
      </CodeBlockActions>
    </CodeBlockHeader>
    <CodeBlockContent>{{ output }}</CodeBlockContent>
  </CodeBlock>
</template>

Composition

CodeBlock
├── CodeBlockHeader
│   ├── CodeBlockTitle
│   └── CodeBlockActions
│       └── CodeBlockCopyButton
├── CodeBlockActions (floating, when there is no header)
├── CodeBlockContent
│   └── CodeBlockLine
│       └── CodeBlockToken
└── CodeBlockFooter

CodeBlockContent takes plain text or a list of CodeBlockLine. Use lines when you need line variants or highlighted tokens.

Features

  • It streams cheaply. Plain text in the slot 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. useHighlightedCode uses Shiki's JavaScript engine, loads each language on first use and renders ANSI colors in terminal output. On the server, highlightCode returns the tokens without a component.
  • It fits where code shows up. Use it in a message, a Markdown fence, a Trace line, Tabs, a Dialog or a typeset container.
  • It scrolls by keyboard. The content is focusable, and floating actions show on focus as well as on hover.

Basic

Put plain text in CodeBlockContent. It renders as one text node, so it stays cheap while output streams.

$ pnpm build
> nuxt build

 ✔ Client built in 4215ms
 ✔ Server built in 1803ms

Route                      Size  Gzip
┌ ○ /                    1.9 kB  0.9 kB
├ ○ /about               993 B   0.5 kB
└ ƒ /api/chat            141 B   0.1 kB
<script setup lang="ts">
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'

const output = `$ pnpm build
> nuxt build

 ✔ Client built in 4215ms
 ✔ Server built in 1803ms

Route                      Size  Gzip
┌ ○ /                    1.9 kB  0.9 kB
├ ○ /about               993 B   0.5 kB
└ ƒ /api/chat            141 B   0.1 kB`
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockContent>{{ output }}</CodeBlockContent>
    </CodeBlock>
  </div>
</template>

CodeBlockHeader holds a CodeBlockTitle and CodeBlockActions. CodeBlockCopyButton copies its value.

Terminal
$ pnpm add ai @ai-sdk/vue zod
Packages: +14
Progress: resolved 412, reused 398, downloaded 14, added 14, done
Done in 2.8s using pnpm v10.18.0
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const command = 'pnpm add ai @ai-sdk/vue zod'

const output = `$ ${command}
Packages: +14
Progress: resolved 412, reused 398, downloaded 14, added 14, done
Done in 2.8s using pnpm v10.18.0`
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          Terminal
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="command" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent>{{ output }}</CodeBlockContent>
    </CodeBlock>
  </div>
</template>

A long title truncates. To link the title to a file, wrap CodeBlockTitle in an anchor.

<template>
  <CodeBlockHeader>
    <a href="/files/chat.post.ts">
      <CodeBlockTitle>server/api/chat.post.ts</CodeBlockTitle>
    </a>
  </CodeBlockHeader>
</template>

Actions

CodeBlockActions holds any number of buttons. For a wrap toggle, keep the state yourself and pass it to wrap.

Terminal
$ pnpm lint
app/components/MessageList.vue
  18:22  error    'messages' is possibly 'undefined'. Add a guard before calling map  ts/no-unsafe-call
  42:7   warning  'scrollToBottom' is assigned a value but never used  unused-imports/no-unused-vars

✖ 2 problems (1 error, 1 warning)
<script setup lang="ts">
import { DownloadIcon, TerminalIcon, WrapTextIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const output = `$ pnpm lint
app/components/MessageList.vue
  18:22  error    'messages' is possibly 'undefined'. Add a guard before calling map  ts/no-unsafe-call
  42:7   warning  'scrollToBottom' is assigned a value but never used  unused-imports/no-unused-vars

✖ 2 problems (1 error, 1 warning)`

const [wrap, toggleWrap] = useToggle(true)

function download(value: string, filename: string) {
  const url = URL.createObjectURL(new Blob([value], { type: 'text/plain' }))
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  link.click()
  URL.revokeObjectURL(url)
}
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          Terminal
        </CodeBlockTitle>
        <CodeBlockActions>
          <Button
            variant="ghost"
            size="icon-xs"
            aria-label="Wrap lines"
            :aria-pressed="wrap"
            class="aria-pressed:text-foreground"
            @click="toggleWrap()"
          >
            <WrapTextIcon />
          </Button>
          <Button
            variant="ghost"
            size="icon-xs"
            aria-label="Download"
            @click="download(output, 'lint.log')"
          >
            <DownloadIcon />
          </Button>
          <CodeBlockCopyButton :value="output" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent :wrap="wrap">
        {{ output }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>
<script setup lang="ts">
import { WrapTextIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'

const [wrap, toggleWrap] = useToggle(false)
</script>

<template>
  <CodeBlockActions>
    <Button
      variant="ghost"
      size="icon-xs"
      aria-label="Wrap lines"
      :aria-pressed="wrap"
      @click="toggleWrap()"
    >
      <WrapTextIcon />
    </Button>
    <CodeBlockCopyButton :value="output" />
  </CodeBlockActions>
  <CodeBlockContent :wrap="wrap">
    {{ output }}
  </CodeBlockContent>
</template>

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.

$ pnpm add ai @ai-sdk/vue zod
Progress: resolved 412, reused 398, downloaded 14, added 14, done
dependencies:
+ @ai-sdk/vue 3.0.12
+ ai 6.0.12
+ zod 4.1.12
Done in 2.8s using pnpm v10.18.0
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
} from '@/components/ui/code-block'

const output = `$ pnpm add ai @ai-sdk/vue zod
Progress: resolved 412, reused 398, downloaded 14, added 14, done
dependencies:
+ @ai-sdk/vue 3.0.12
+ ai 6.0.12
+ zod 4.1.12
Done in 2.8s using pnpm v10.18.0`
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockActions>
        <CodeBlockCopyButton :value="output" />
      </CodeBlockActions>
      <CodeBlockContent>{{ output }}</CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Line Numbers

Add line-numbers to CodeBlockContent. Plain text in the slot is split into lines, and the numbers are not copied with a selection.

{  "name": "chat",  "private": true,  "scripts": {    "dev": "nuxt dev",    "build": "nuxt build"  }}
<script setup lang="ts">
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'

const code = `{
  "name": "chat",
  "private": true,
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build"
  }
}`
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockContent line-numbers>
        {{ code }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Start Line

Set start-line to number lines from a given line, for a snippet from the middle of a file. The gutter grows to fit the widest number.

server/api/projects.get.ts
  if (!session) {    throw createError({ statusCode: 401 })  }
  const projects = await db.project.findMany({    where: { ownerId: session.user.id },    orderBy: { updatedAt: "desc" },  })
  return projects})
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `  if (!session) {
    throw createError({ statusCode: 401 })
  }

  const projects = await db.project.findMany({
    where: { ownerId: session.user.id },
    orderBy: { updatedAt: "desc" },
  })

  return projects
})`

const lines = useHighlightedCode(code, 'ts')
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>server/api/projects.get.ts</CodeBlockTitle>
      </CodeBlockHeader>
      <CodeBlockContent line-numbers :start-line="995">
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

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.

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.
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.
<script setup lang="ts">
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'

const 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.`
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <CodeBlock>
      <CodeBlockContent>{{ output }}</CodeBlockContent>
    </CodeBlock>
    <CodeBlock>
      <CodeBlockContent wrap>
        {{ output }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Highlighted Lines

Render each line as a CodeBlockLine and set variant="highlighted" on the lines to call out.

export default defineNuxtConfig({  experimental: {    viewTransition: true,    typedPages: true,  },  image: {    domains: ["avatars.githubusercontent.com"],  },})
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
} from '@/components/ui/code-block'

const code = `export default defineNuxtConfig({
  experimental: {
    viewTransition: true,
    typedPages: true,
  },
  image: {
    domains: ["avatars.githubusercontent.com"],
  },
})`

const highlighted = [2, 3]
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockContent line-numbers>
        <CodeBlockLine
          v-for="(line, index) in code.split('\n')"
          :key="index"
          :variant="highlighted.includes(index) ? 'highlighted' : 'default'"
        >
          {{ line }}
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Diff

Set variant="added" or variant="removed" on a CodeBlockLine. Each one adds a + or − marker.

components/Greeting.vue
<template>  <p>Hello, {{ name }}</p>  <p class="text-sm text-muted-foreground">    Hello, {{ name }}!  </p></template>
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const patch = ` <template>
-  <p>Hello, {{ name }}</p>
+  <p class="text-sm text-muted-foreground">
+    Hello, {{ name }}!
+  </p>
 </template>`

function getVariant(line: string) {
  if (line.startsWith('+'))
    return 'added'

  if (line.startsWith('-'))
    return 'removed'

  return 'default'
}
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>components/Greeting.vue</CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="patch" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent>
        <CodeBlockLine
          v-for="(line, index) in patch.split('\n')"
          :key="index"
          :variant="getVariant(line)"
        >
          {{ line.slice(1) }}
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Height

Cap the height with a max-h-* class on CodeBlockContent. The content scrolls inside the block.

Logs
[01] GET /api/chat 200 in 12ms
[02] GET /api/chat 200 in 19ms
[03] GET /api/chat 200 in 26ms
[04] GET /api/chat 200 in 33ms
[05] GET /api/chat 200 in 40ms
[06] GET /api/chat 200 in 47ms
[07] GET /api/chat 200 in 14ms
[08] GET /api/chat 200 in 21ms
[09] GET /api/chat 200 in 28ms
[10] GET /api/chat 200 in 35ms
[11] GET /api/chat 200 in 42ms
[12] GET /api/chat 200 in 49ms
[13] GET /api/chat 200 in 16ms
[14] GET /api/chat 200 in 23ms
[15] GET /api/chat 200 in 30ms
[16] GET /api/chat 200 in 37ms
[17] GET /api/chat 200 in 44ms
[18] GET /api/chat 200 in 51ms
[19] GET /api/chat 200 in 18ms
[20] GET /api/chat 200 in 25ms
[21] GET /api/chat 200 in 32ms
[22] GET /api/chat 200 in 39ms
[23] GET /api/chat 200 in 46ms
[24] GET /api/chat 200 in 13ms
[25] GET /api/chat 200 in 20ms
[26] GET /api/chat 200 in 27ms
[27] GET /api/chat 200 in 34ms
[28] GET /api/chat 200 in 41ms
[29] GET /api/chat 200 in 48ms
[30] GET /api/chat 200 in 15ms
[31] GET /api/chat 200 in 22ms
[32] GET /api/chat 200 in 29ms
[33] GET /api/chat 200 in 36ms
[34] GET /api/chat 200 in 43ms
[35] GET /api/chat 200 in 50ms
[36] GET /api/chat 200 in 17ms
[37] GET /api/chat 200 in 24ms
[38] GET /api/chat 200 in 31ms
[39] GET /api/chat 200 in 38ms
[40] GET /api/chat 200 in 45ms
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const logs = Array.from(
  { length: 40 },
  (_, index) =>
    `[${String(index + 1).padStart(2, '0')}] GET /api/chat 200 in ${12 + ((index * 7) % 40)}ms`,
).join('\n')
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>Logs</CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="logs" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent class="max-h-48 scroll-fade-y">
        {{ logs }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

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.

components/Chat.vue
<script setup lang="ts">import { Chat } from "@ai-sdk/vue"import { ref } from "vue"
import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"
const input = ref("")const chat = new Chat({})
function onSubmit() {  chat.sendMessage({ text: input.value })  input.value = ""}</script>
<template>  <div class="flex flex-col gap-4">    <div v-for="message in chat.messages" :key="message.id">      <template v-for="(part, index) in message.parts" :key="index">        <p v-if="part.type === 'text'">          {{ part.text }}        </p>      </template>    </div>    <form @submit.prevent="onSubmit">      <Input v-model="input" />      <Button :disabled="chat.status !== 'ready'">        Send      </Button>    </form>  </div></template>
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'
import { useId } from 'vue'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockFooter,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `<script setup lang="ts">
import { Chat } from "@ai-sdk/vue"
import { ref } from "vue"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

const input = ref("")
const chat = new Chat({})

function onSubmit() {
  chat.sendMessage({ text: input.value })
  input.value = ""
}
<\/script>

<template>
  <div class="flex flex-col gap-4">
    <div v-for="message in chat.messages" :key="message.id">
      <template v-for="(part, index) in message.parts" :key="index">
        <p v-if="part.type === 'text'">
          {{ part.text }}
        </p>
      </template>
    </div>
    <form @submit.prevent="onSubmit">
      <Input v-model="input" />
      <Button :disabled="chat.status !== 'ready'">
        Send
      </Button>
    </form>
  </div>
</template>`

const [expanded, toggleExpanded] = useToggle(false)
const lines = useHighlightedCode(code, 'vue')
const id = useId()
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>components/Chat.vue</CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="code" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent
        :id="id"
        line-numbers
        :class="expanded ? undefined : 'max-h-64 scroll-fade-y overflow-y-hidden'"
      >
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
      <CodeBlockFooter class="justify-center">
        <Button
          variant="ghost"
          size="xs"
          :aria-expanded="expanded"
          :aria-controls="id"
          @click="toggleExpanded()"
        >
          <ChevronDownIcon class="transition-transform in-aria-expanded:rotate-180" />
          {{ expanded ? 'Show less' : 'Show more' }}
        </Button>
      </CodeBlockFooter>
    </CodeBlock>
  </div>
</template>
<script setup lang="ts">
import { useToggle } from '@vueuse/core'
import { useId } from 'vue'

const [expanded, toggleExpanded] = useToggle(false)
const id = useId()
</script>

<template>
  <CodeBlockContent
    :id="id"
    :class="expanded ? undefined : 'max-h-64 overflow-y-hidden scroll-fade-y'"
  >
    {{ code }}
  </CodeBlockContent>
  <CodeBlockFooter class="justify-center">
    <Button
      variant="ghost"
      size="xs"
      :aria-expanded="expanded"
      :aria-controls="id"
      @click="toggleExpanded()"
    >
      {{ expanded ? 'Show less' : 'Show more' }}
    </Button>
  </CodeBlockFooter>
</template>

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.

components/Chat.vue
<script setup lang="ts">import { Chat } from "@ai-sdk/vue"import { ref } from "vue"
import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"
const input = ref("")const chat = new Chat({})
function onSubmit() {  chat.sendMessage({ text: input.value })  input.value = ""}</script>
<template>  <div class="flex flex-col gap-4">    <div v-for="message in chat.messages" :key="message.id">      <template v-for="(part, index) in message.parts" :key="index">        <p v-if="part.type === 'text'">          {{ part.text }}        </p>      </template>    </div>    <form @submit.prevent="onSubmit">      <Input v-model="input" />      <Button :disabled="chat.status !== 'ready'">        Send      </Button>    </form>  </div></template>
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'
import { useId } from 'vue'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockFooter,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `<script setup lang="ts">
import { Chat } from "@ai-sdk/vue"
import { ref } from "vue"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

const input = ref("")
const chat = new Chat({})

function onSubmit() {
  chat.sendMessage({ text: input.value })
  input.value = ""
}
<\/script>

<template>
  <div class="flex flex-col gap-4">
    <div v-for="message in chat.messages" :key="message.id">
      <template v-for="(part, index) in message.parts" :key="index">
        <p v-if="part.type === 'text'">
          {{ part.text }}
        </p>
      </template>
    </div>
    <form @submit.prevent="onSubmit">
      <Input v-model="input" />
      <Button :disabled="chat.status !== 'ready'">
        Send
      </Button>
    </form>
  </div>
</template>`

const [expanded, toggleExpanded] = useToggle(false)
const lines = useHighlightedCode(code, 'vue')
const id = useId()
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>components/Chat.vue</CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="code" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent
        :id="id"
        line-numbers
        :class="expanded ? 'max-h-96 scroll-fade-y' : 'max-h-64 scroll-fade-y overflow-y-hidden'"
      >
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
      <CodeBlockFooter class="justify-center">
        <Button
          variant="ghost"
          size="xs"
          :aria-expanded="expanded"
          :aria-controls="id"
          @click="toggleExpanded()"
        >
          <ChevronDownIcon class="transition-transform in-aria-expanded:rotate-180" />
          {{ expanded ? 'Show less' : 'Show more' }}
        </Button>
      </CodeBlockFooter>
    </CodeBlock>
  </div>
</template>

Size

Use size="sm" for tool and Trace output. It has tighter padding and text-xs text.

Terminal
$ pnpm add ai @ai-sdk/vue zod
Progress: resolved 412, reused 398, downloaded 14, added 14, done
Done in 2.8s using pnpm v10.18.0
Terminal
$ pnpm add ai @ai-sdk/vue zod
Progress: resolved 412, reused 398, downloaded 14, added 14, done
Done in 2.8s using pnpm v10.18.0
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const output = `$ pnpm add ai @ai-sdk/vue zod
Progress: resolved 412, reused 398, downloaded 14, added 14, done
Done in 2.8s using pnpm v10.18.0`
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          Terminal
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="output" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent wrap>
        {{ output }}
      </CodeBlockContent>
    </CodeBlock>
    <CodeBlock size="sm">
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          Terminal
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="output" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent wrap>
        {{ output }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Destructive

Use variant="destructive" for errors and failed output.

Error
TypeError: Cannot read properties of undefined (reading 'map')
    at setup (components/MessageList.vue:18:22)
    at callWithErrorHandling (runtime-core.esm-bundler.js:199:19)
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from '@/components/ui/code-block'

const error = `TypeError: Cannot read properties of undefined (reading 'map')
    at setup (components/MessageList.vue:18:22)
    at callWithErrorHandling (runtime-core.esm-bundler.js:199:19)`
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock variant="destructive">
      <CodeBlockHeader>
        <CodeBlockTitle>Error</CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="error" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent wrap>
        {{ error }}
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

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.

$ node --version
v24.18.0
$ pnpm dev
Error: listen EADDRINUSE: address already in use :::3000
    at Server.setupListenHandle [as _listen2] (node:net:1908:16)
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'

const output = `$ node --version
v24.18.0`

const error = `$ pnpm dev
Error: listen EADDRINUSE: address already in use :::3000
    at Server.setupListenHandle [as _listen2] (node:net:1908:16)`
</script>

<template>
  <div class="w-full max-w-md">
    <Trace>
      <TraceCollapsible default-open>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <TerminalIcon />
            </TraceIcon>
            <TraceTitle>Ran node --version</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock size="sm">
            <CodeBlockContent wrap class="max-h-64">
              {{ output }}
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceCollapsible default-open>
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon>
              <TerminalIcon />
            </TraceIcon>
            <TraceTitle>Failed to run pnpm dev</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock size="sm" variant="destructive">
            <CodeBlockContent wrap class="max-h-64">
              {{ error }}
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  </div>
</template>

Tabs

Show several files with Tabs, one CodeBlock in each TabsContent.

server/api/chat.post.ts
import { streamText } from "ai"
export default defineEventHandler(async (event) => {  const { messages } = await readBody(event)  const result = streamText({ model: "anthropic/claude-sonnet-4.5", messages })
  return result.toUIMessageStreamResponse()})
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from '@/components/ui/tabs'

const files = [
  {
    id: 'route',
    title: 'server/api/chat.post.ts',
    language: 'ts',
    code: `import { streamText } from "ai"

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)
  const result = streamText({ model: "anthropic/claude-sonnet-4.5", messages })

  return result.toUIMessageStreamResponse()
})`,
  },
  {
    id: 'chat',
    title: 'components/Chat.vue',
    language: 'vue',
    code: `<script setup lang="ts">
import { Chat } from "@ai-sdk/vue"

const chat = new Chat({})
<\/script>

<template>
  <MessageList :messages="chat.messages" />
</template>`,
  },
  {
    id: 'env',
    title: '.env',
    language: 'bash',
    code: 'AI_GATEWAY_API_KEY=your-api-key',
  },
]

const highlighted = files.map(file => useHighlightedCode(file.code, file.language))
</script>

<template>
  <Tabs default-value="route" class="w-full max-w-md">
    <TabsList>
      <TabsTrigger v-for="file in files" :key="file.id" :value="file.id">
        {{ file.title.split('/').at(-1) }}
      </TabsTrigger>
    </TabsList>
    <TabsContent v-for="(file, index) in files" :key="file.id" :value="file.id">
      <CodeBlock>
        <CodeBlockHeader>
          <CodeBlockTitle>{{ file.title }}</CodeBlockTitle>
          <CodeBlockActions>
            <CodeBlockCopyButton :value="file.code" />
          </CodeBlockActions>
        </CodeBlockHeader>
        <CodeBlockContent line-numbers>
          <CodeBlockLine v-for="(line, i) in highlighted[index]?.value" :key="i">
            <CodeBlockToken v-for="(token, j) in line" :key="j" :token="token" />
          </CodeBlockLine>
        </CodeBlockContent>
      </CodeBlock>
    </TabsContent>
  </Tabs>
</template>

Message

Put a CodeBlock between paragraphs inside a Message.

How do I stream a response?

Create a server route that returns the stream:

server/api/chat.post.ts
import { streamText } from "ai"
export default defineEventHandler(async (event) => {  const { messages } = await readBody(event)
  const result = streamText({    model: "anthropic/claude-sonnet-4.5",    messages,  })
  return result.toUIMessageStreamResponse()})

The client reads it with Chat from @ai-sdk/vue.

<script setup lang="ts">
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'
import { Message, MessageContent } from '@/components/ui/message'

const code = `import { streamText } from "ai"

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)

  const result = streamText({
    model: "anthropic/claude-sonnet-4.5",
    messages,
  })

  return result.toUIMessageStreamResponse()
})`

const lines = useHighlightedCode(code, 'ts')
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4 text-sm">
    <Message align="end">
      <MessageContent>
        <Bubble variant="muted">
          <BubbleContent>How do I stream a response?</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageContent class="gap-3">
        <p>Create a server route that returns the stream:</p>
        <CodeBlock>
          <CodeBlockHeader>
            <CodeBlockTitle>server/api/chat.post.ts</CodeBlockTitle>
            <CodeBlockActions>
              <CodeBlockCopyButton :value="code" />
            </CodeBlockActions>
          </CodeBlockHeader>
          <CodeBlockContent>
            <CodeBlockLine v-for="(line, index) in lines" :key="index">
              <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
            </CodeBlockLine>
          </CodeBlockContent>
        </CodeBlock>
        <p>The client reads it with Chat from @ai-sdk/vue.</p>
      </MessageContent>
    </Message>
  </div>
</template>

Typeset

A CodeBlock inside a typeset container keeps its own styles and gets the same space above as a paragraph.

Stream a response

Create a server route that calls streamText and returns the stream to the client.

server/api/chat.post.ts
import { streamText } from "ai"
export default defineEventHandler(async (event) => {  const { messages } = await readBody(event)  const result = streamText({ model: "anthropic/claude-sonnet-4.5", messages })
  return result.toUIMessageStreamResponse()})

The Chat class from @ai-sdk/vue reads the stream on the client and renders the answer while it is written.

<script setup lang="ts">
import { FileCodeIcon } from '@lucide/vue'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `import { streamText } from "ai"

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)
  const result = streamText({ model: "anthropic/claude-sonnet-4.5", messages })

  return result.toUIMessageStreamResponse()
})`

const lines = useHighlightedCode(code, 'ts')
</script>

<template>
  <div class="typeset w-full max-w-md">
    <h3>Stream a response</h3>
    <p>
      Create a server route that calls <code>streamText</code> and returns
      the stream to the client.
    </p>
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <FileCodeIcon />
          server/api/chat.post.ts
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="code" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent line-numbers>
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
    <p>
      The <code>Chat</code> class from <code>@ai-sdk/vue</code> reads the
      stream on the client and renders the answer while it is written.
    </p>
  </div>
</template>

RTL

To enable RTL support in shadcn-vue, see the RTL configuration guide. Code always reads left to right. The header follows the page direction.

ثبّت الحزم، ثم شغّل خادم التطوير.

الطرفية
pnpm add ai @ai-sdk/vue zodpnpm dev
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `pnpm add ai @ai-sdk/vue zod
pnpm dev`

const lines = useHighlightedCode(code, 'bash')
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-3 text-sm" dir="rtl">
    <p>ثبّت الحزم، ثم شغّل خادم التطوير.</p>
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          الطرفية
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="code" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent line-numbers>
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

Syntax Highlighting

useHighlightedCode highlights with Shiki and returns a computed list of lines. Each line is a list of tokens, which you render with CodeBlockToken inside a CodeBlockLine.

getWeather output
{  "location": "San Francisco",  "temperature": 18,  "unit": "celsius",  "conditions": ["fog", "wind"]}
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const code = `{
  "location": "San Francisco",
  "temperature": 18,
  "unit": "celsius",
  "conditions": ["fog", "wind"]
}`

const lines = useHighlightedCode(code, 'json')
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>getWeather output</CodeBlockTitle>
      </CodeBlockHeader>
      <CodeBlockContent>
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const lines = useHighlightedCode(code, 'json')
</script>

<template>
  <CodeBlock>
    <CodeBlockContent>
      <CodeBlockLine v-for="(line, index) in lines" :key="index">
        <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
      </CodeBlockLine>
    </CodeBlockContent>
  </CodeBlock>
</template>

Both arguments accept a plain value, a ref or a getter, so the lines update when the code or the language changes:

const props = defineProps<{ code: string, language: string }>()

const lines = useHighlightedCode(() => props.code, () => props.language)

Shiki uses its JavaScript regex engine, so there is no WASM to load.

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.

assets/css/main.css
@import "tailwindcss";
:root {  --radius: 0.625rem;}
main.py
def greet(name: str) -> str:    return f"Hello, {name}!"
notes.txt
A language that is not in the list renders as plain text.
<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const samples = [
  {
    language: 'css',
    title: 'assets/css/main.css',
    code: `@import "tailwindcss";

:root {
  --radius: 0.625rem;
}`,
  },
  {
    language: 'python',
    title: 'main.py',
    code: `def greet(name: str) -> str:
    return f"Hello, {name}!"`,
  },
  {
    language: 'text',
    title: 'notes.txt',
    code: 'A language that is not in the list renders as plain text.',
  },
]

const highlighted = samples.map(sample => useHighlightedCode(sample.code, sample.language))
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <CodeBlock v-for="(sample, index) in samples" :key="sample.language">
      <CodeBlockHeader>
        <CodeBlockTitle>{{ sample.title }}</CodeBlockTitle>
      </CodeBlockHeader>
      <CodeBlockContent>
        <CodeBlockLine v-for="(line, i) in highlighted[index]?.value" :key="i">
          <CodeBlockToken v-for="(token, j) in line" :key="j" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

The list ships with bash, css, diff, html, javascript, json, jsx, markdown, python, sql, tsx, typescript, vue 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.

Terminal
$ pnpm test✓ src/format.test.ts (4 tests)✓ src/parse.test.ts (7 tests)✗ src/stream.test.ts (3 tests | 1 failed)
AssertionError: expected 'hello' to be 'hello world'
Tests  1 failed | 13 passed (14)
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const output = `$ pnpm test
\x1B[32m✓\x1B[39m src/format.test.ts \x1B[2m(4 tests)\x1B[22m
\x1B[32m✓\x1B[39m src/parse.test.ts \x1B[2m(7 tests)\x1B[22m
\x1B[31m✗\x1B[39m src/stream.test.ts \x1B[2m(3 tests | 1 failed)\x1B[22m

\x1B[31mAssertionError: expected 'hello' to be 'hello world'\x1B[39m

Tests  \x1B[31m1 failed\x1B[39m | \x1B[32m13 passed\x1B[39m (14)`

// eslint-disable-next-line no-control-regex
const plain = output.replace(/\x1B\[[\d;]*m/g, '')

const lines = useHighlightedCode(output, 'ansi')
</script>

<template>
  <div class="w-full max-w-md">
    <CodeBlock>
      <CodeBlockHeader>
        <CodeBlockTitle>
          <TerminalIcon />
          Terminal
        </CodeBlockTitle>
        <CodeBlockActions>
          <CodeBlockCopyButton :value="plain" />
        </CodeBlockActions>
      </CodeBlockHeader>
      <CodeBlockContent wrap>
        <CodeBlockLine v-for="(line, index) in lines" :key="index">
          <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
        </CodeBlockLine>
      </CodeBlockContent>
    </CodeBlock>
  </div>
</template>

ANSI colors do not carry from one line to the next.

Server Rendering

highlightCode is async and returns the tokens of each line, or null for a language that is not in the list. Call it from a server route or with useAsyncData so the tokens are part of the server-rendered HTML.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
  CodeBlockToken,
  highlightCode,
} from '@/components/ui/code-block'

const props = defineProps<{ code: string }>()

const { data: lines } = await useAsyncData(
  `code-${props.code}`,
  async () =>
    (await highlightCode(props.code, 'ts'))
    ?? props.code.split('\n').map(line => (line ? [{ content: line }] : [])),
)
</script>

<template>
  <CodeBlock>
    <CodeBlockContent line-numbers>
      <CodeBlockLine v-for="(line, index) in lines" :key="index">
        <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
      </CodeBlockLine>
    </CodeBlockContent>
  </CodeBlock>
</template>

To highlight many snippets in a row, loadHighlighter loads a language once and returns a highlighter with a synchronous highlight(code) method. It returns null for a language that is not in the list.

import { loadHighlighter } from '@/components/ui/code-block'

const highlighter = await loadHighlighter('ts')
const { lines } = highlighter?.highlight(code) ?? { lines: [] }

Streaming

While the code streams, the composable tokenizes only the new lines, starting where the last line left off. Lines that did not change keep the same tokens, so Vue skips them.

composables/useDebounce.ts
export function useDebounce<T>(value: Ref<T>, delay = 300) {  const debounced = shallowRef(value.value)
  watch(value, (next, _, onCleanup) => {    const timeout = setTimeout(() => (debounced.value = next), delay)    onCleanup(() => clearTimeout(timeout))  })
  return debounced}
pnpm dlx shadcn-vue@latest add code-block
components/Search.vue
<script setup lang="ts">const query = ref("")const results = useSearch(query)const debounced = useDebounce(query, 300)const results = useSearch(debounced)</script>
<template>  <SearchResults :results="results" /></template>
server/api/chat.post.ts
import { streamText } from "ai"
export default defineEventHandler(async (event) => {  const { messages } = await readBody(event)
  const result = streamText({    model: "anthropic/claude-sonnet-4.5",    messages,  })
  return result.toUIMessageStreamResponse()})
.env
AI_GATEWAY_API_KEY=your-api-key
<script setup lang="ts">
import { RotateCcwIcon } from '@lucide/vue'
import { useIntervalFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'

const blocks: {
  title?: string
  language: string
  code: string
  lineNumbers?: boolean
  highlight?: number[]
  diff?: boolean
}[] = [
  {
    title: 'composables/useDebounce.ts',
    language: 'ts',
    lineNumbers: true,
    code: `export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debounced = shallowRef(value.value)

  watch(value, (next, _, onCleanup) => {
    const timeout = setTimeout(() => (debounced.value = next), delay)
    onCleanup(() => clearTimeout(timeout))
  })

  return debounced
}`,
  },
  {
    language: 'bash',
    code: 'pnpm dlx shadcn-vue@latest add code-block',
  },
  {
    title: 'components/Search.vue',
    language: 'vue',
    lineNumbers: true,
    diff: true,
    code: ` <script setup lang="ts">
 const query = ref("")
-const results = useSearch(query)
+const debounced = useDebounce(query, 300)
+const results = useSearch(debounced)
 <\/script>

 <template>
   <SearchResults :results="results" />
 </template>`,
  },
  {
    title: 'server/api/chat.post.ts',
    language: 'ts',
    lineNumbers: true,
    highlight: [5, 6, 7, 8],
    code: `import { streamText } from "ai"

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)

  const result = streamText({
    model: "anthropic/claude-sonnet-4.5",
    messages,
  })

  return result.toUIMessageStreamResponse()
})`,
  },
  {
    title: '.env',
    language: 'bash',
    code: 'AI_GATEWAY_API_KEY=your-api-key',
  },
]

// Where each block starts in the stream.
const offsets = blocks.map((_, index) =>
  blocks.slice(0, index).reduce((total, block) => total + block.code.length, 0),
)
const total = blocks.reduce((sum, block) => sum + block.code.length, 0)

const length = ref(total)

const streamed = blocks.map((block, index) => {
  const code = computed(() => block.code.slice(0, Math.max(0, length.value - offsets[index]!)))
  const rows = computed(() => code.value.split('\n'))
  // A diff row starts with +, - or a space. The marker is not part of the code.
  const source = computed(() =>
    block.diff ? rows.value.map(row => row.slice(1)).join('\n') : code.value,
  )
  const lines = useHighlightedCode(source, block.language)

  return { block, code, rows, source, lines }
})

function getVariant(item: (typeof streamed)[number], index: number) {
  if (item.block.diff) {
    const row = item.rows.value[index]
    if (row?.startsWith('+'))
      return 'added'
    if (row?.startsWith('-'))
      return 'removed'
    return 'default'
  }

  return item.block.highlight?.includes(index) ? 'highlighted' : 'default'
}

const { pause, resume } = useIntervalFn(() => {
  length.value += 3
  if (length.value >= total)
    pause()
}, 16, { immediate: false })

function replay() {
  length.value = 0
  resume()
}
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <template v-for="(item, index) in streamed" :key="item.block.code">
      <CodeBlock v-if="length > offsets[index]!">
        <CodeBlockHeader v-if="item.block.title">
          <CodeBlockTitle>{{ item.block.title }}</CodeBlockTitle>
          <CodeBlockActions>
            <CodeBlockCopyButton
              :value="item.source.value"
              :disabled="item.code.value.length < item.block.code.length"
            />
          </CodeBlockActions>
        </CodeBlockHeader>
        <CodeBlockContent :line-numbers="item.block.lineNumbers">
          <CodeBlockLine
            v-for="(line, i) in item.lines.value"
            :key="i"
            :variant="getVariant(item, i)"
          >
            <CodeBlockToken v-for="(token, j) in line" :key="j" :token="token" />
          </CodeBlockLine>
        </CodeBlockContent>
      </CodeBlock>
    </template>
    <Button
      variant="outline"
      size="sm"
      class="self-start"
      :disabled="length < total"
      @click="replay"
    >
      <RotateCcwIcon />
      Replay
    </Button>
  </div>
</template>

Markdown

Render the message with Comark (@comark/vue) and route fenced code to a CodeBlock with a custom pre component. Comark does not run components inside pre, so a code component only matches inline code, and inline code stays inline.

How do I debounce a search input in Vue?

Wrap the value in a useDebounce composable. It returns the latest value once it stops changing for delay milliseconds.

ts
export function useDebounce<T>(value: Ref<T>, delay = 300) {  const debounced = shallowRef(value.value)
  watch(value, (next, _, onCleanup) => {    const timeout = setTimeout(() => (debounced.value = next), delay)    onCleanup(() => clearTimeout(timeout))  })
  return debounced}

Pass the debounced value to your fetch instead of the raw input.

<script setup lang="ts">
import type { ElementNode } from '@comark/vue'
import type { FunctionalComponent, PropType } from 'vue'
import { Markdown } from '@comark/vue'
import { useIntervalFn } from '@vueuse/core'
import { computed, defineComponent, h, ref } from 'vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import {
  CodeBlock,
  CodeBlockActions,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLine,
  CodeBlockTitle,
  CodeBlockToken,
  useHighlightedCode,
} from '@/components/ui/code-block'
import { Message, MessageContent } from '@/components/ui/message'

const question = 'How do I debounce a search input in Vue?'

const response = `Wrap the value in a \`useDebounce\` composable. It returns the latest value once it stops changing for \`delay\` milliseconds.

\`\`\`ts
export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debounced = shallowRef(value.value)

  watch(value, (next, _, onCleanup) => {
    const timeout = setTimeout(() => (debounced.value = next), delay)
    onCleanup(() => clearTimeout(timeout))
  })

  return debounced
}
\`\`\`

Pass the debounced value to your fetch instead of the raw input.`

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  text: string
}

const transcript: ChatMessage[] = [
  { id: 'question', role: 'user', text: question },
  { id: 'answer', role: 'assistant', text: response },
]

const messages = ref<ChatMessage[]>(transcript)

// Stream the answer a word at a time.
const chunks = response.split(/(?<=\s)/)
let index = 0

const { pause, resume, isActive: busy } = useIntervalFn(() => {
  if (index >= chunks.length) {
    pause()
    return
  }

  messages.value = [
    messages.value[0]!,
    { id: 'answer', role: 'assistant', text: chunks.slice(0, ++index).join('') },
  ]
}, 30, { immediate: false })

function replay() {
  index = 0
  messages.value = [{ id: 'question', role: 'user', text: question }]
  resume()
}

// Comark closes an unfinished fence while streaming. The trailing fence is
// still open when an odd number of lines start with ```.
const openFence = computed(() => {
  const message = messages.value.at(-1)
  if (!busy.value || message?.role !== 'assistant')
    return null

  const lines = message.text.trim().split('\n')
  const fences = lines.flatMap((line, i) => (line.startsWith('```') ? [i] : []))

  return fences.length % 2 ? lines.slice(fences.at(-1)! + 1).join('\n') : null
})

const MarkdownCodeBlock = defineComponent({
  props: {
    language: { type: String, default: '' },
    // The raw `pre` node: ['pre', props, ['code', props, text]].
    // eslint-disable-next-line vue/prop-name-casing
    __node: { type: Array as unknown as PropType<ElementNode>, default: undefined },
  },
  setup(props) {
    const code = computed(() => {
      const child = props.__node?.[2]
      return Array.isArray(child) && typeof child[2] === 'string' ? child[2] : ''
    })
    // True while the closing fence has not arrived yet.
    const incomplete = computed(() => openFence.value?.trimEnd() === code.value.trimEnd())
    const lines = useHighlightedCode(code, () => props.language)

    return () =>
      h(CodeBlock, null, () => [
        h(CodeBlockHeader, null, () => [
          h(CodeBlockTitle, null, () => props.language || 'text'),
          h(CodeBlockActions, null, () =>
            h(CodeBlockCopyButton, { value: code.value, disabled: incomplete.value })),
        ]),
        h(CodeBlockContent, null, () =>
          lines.value.map((line, index) =>
            h(CodeBlockLine, { key: index }, () =>
              line.map((token, i) => h(CodeBlockToken, { key: i, token }))))),
      ])
  },
})

// Comark does not run components inside `pre`, so this is inline code only.
const MarkdownCode: FunctionalComponent = (_, { slots }) =>
  h('code', { class: 'rounded-sm bg-muted px-1 py-0.5 font-mono text-[0.9em]' }, slots.default?.())

const components = { pre: MarkdownCodeBlock, code: MarkdownCode }
</script>

<template>
  <div class="flex min-h-112 w-full max-w-md flex-col gap-4 py-6 text-sm">
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ message.text }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent>
          <Markdown
            class="typeset"
            :value="message.text"
            :components="components"
            :streaming="busy && message === messages.at(-1)"
          />
        </MessageContent>
      </Message>
    </template>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

The pre component gets the fence's language as a prop. Declare a __node prop to receive the raw node, and read the code from it. Pass streaming while the message streams, so Comark closes unfinished syntax.

Comark closes an unfinished fence while streaming, but does not say so. The trailing fence is still open when the text has an odd number of lines that start with ```. Provide its code, and disable the copy button in the block that matches it.

<script setup lang="ts">
import { Markdown } from '@comark/vue'
import { computed, provide } from 'vue'
import MarkdownCodeBlock from './MarkdownCodeBlock.vue'

const props = defineProps<{ text: string, streaming?: boolean }>()

// The code of the trailing fence while it is still open.
const openFence = computed(() => {
  const lines = props.text.trim().split('\n')
  const fences = lines.flatMap((line, i) => (line.startsWith('```') ? [i] : []))
  return props.streaming && fences.length % 2
    ? lines.slice(fences.at(-1)! + 1).join('\n')
    : null
})

provide('markdown-open-fence', openFence)
</script>

<template>
  <Markdown
    :value="text"
    :components="{ pre: MarkdownCodeBlock }"
    :streaming="streaming"
  />
</template>
MarkdownCodeBlock.vue
<script setup lang="ts">
import type { ElementNode } from '@comark/vue'
import type { Ref } from 'vue'
import { computed, inject } from 'vue'

const props = defineProps<{
  language?: string
  __node?: ElementNode // ['pre', attrs, ['code', attrs, text]]
}>()

const code = computed(() => {
  const child = props.__node?.[2]
  return Array.isArray(child) && typeof child[2] === 'string' ? child[2] : ''
})

// True while the closing fence has not arrived yet.
const openFence = inject<Ref<string | null>>('markdown-open-fence')
const incomplete = computed(() => openFence?.value?.trimEnd() === code.value.trimEnd())

const lines = useHighlightedCode(code, () => props.language ?? '')
</script>

<template>
  <CodeBlock>
    <CodeBlockHeader>
      <CodeBlockTitle>{{ language || 'text' }}</CodeBlockTitle>
      <CodeBlockActions>
        <CodeBlockCopyButton :value="code" :disabled="incomplete" />
      </CodeBlockActions>
    </CodeBlockHeader>
    <CodeBlockContent>
      <CodeBlockLine v-for="(line, index) in lines" :key="index">
        <CodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
      </CodeBlockLine>
    </CodeBlockContent>
  </CodeBlock>
</template>

Accessibility

  • CodeBlockContent is a pre with a code element and always sets dir="ltr", so code reads left to right on an RTL page.
  • CodeBlockContent 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 are hidden from screen readers.
  • Diff lines carry a + or − marker as well as a tint, so the change does not rely on color.
  • CodeBlockCopyButton 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.
  • Give a Show More button aria-expanded and point aria-controls at the id of CodeBlockContent.

API Reference

CodeBlock

The surface that holds the header, the content and the footer.

PropTypeDefaultDescription
variant"default" | "destructive""default"The tone of the block.
size"default" | "sm""default""sm" has tighter padding and text-xs code, for tool output.
classstring-Additional classes to apply to the block.

CodeBlockHeader

A row above the content for a title and actions.

PropTypeDefaultDescription
classstring-Additional classes to apply to the header.

CodeBlockTitle

A filename or a label, with an optional icon. It truncates.

PropTypeDefaultDescription
classstring-Additional classes to apply to the title.

CodeBlockActions

Holds buttons at the end of the header. As a direct child of CodeBlock, it floats.

PropTypeDefaultDescription
classstring-Additional classes to apply to the actions.

CodeBlockCopyButton

A ghost icon button that copies value to the clipboard. It accepts the props of Button.

PropTypeDefaultDescription
valuestring-Required. The text to copy.
variantstring"ghost"The variant of the button.
sizestring"icon-xs"The size of the button.
asstring"button"The element or component to render as.
as-childbooleanfalseRender as the child element.
classstring-Additional classes to apply to the button.

CodeBlockContent

The pre that scrolls or wraps.

PropTypeDefaultDescription
default slot--Plain text, or a list of CodeBlockLine.
wrapbooleanfalseWrap long lines instead of scrolling.
line-numbersbooleanfalseNumber each line. Plain text in the slot is split into lines.
start-linenumber1The number of the first line, when line-numbers is set.
tabindexnumber0Makes the content focusable so it can be scrolled by keyboard.
classstring-Additional classes to apply to the content, such as max-h-72.

CodeBlockLine

One line of code.

PropTypeDefaultDescription
variant"default" | "added" | "removed" | "highlighted""default"The state of the line.
classstring-Additional classes to apply to the line.

CodeBlockToken

One highlighted token from useHighlightedCode or highlightCode. It renders a span with the light and dark theme colors.

PropTypeDefaultDescription
tokenHighlightedToken-Required. The token.

CodeBlockFooter

A row below the content, such as for a Show More button.

PropTypeDefaultDescription
classstring-Additional classes to apply to the footer.

useHighlightedCode

const lines = useHighlightedCode(code, language)

Returns a ComputedRef<HighlightedToken[][]>, one list of tokens per line. It returns plain lines until the first pass finishes, and for a language that is not in the list.

ArgumentTypeDescription
codeMaybeRefOrGetter<string>The code to highlight. A trailing newline is ignored.
languageMaybeRefOrGetter<string>A language or alias from highlighter.ts, or "ansi".

highlightCode

const lines = await highlightCode(code, language)

Returns Promise<HighlightedToken[][] | null>, one list of tokens per line, or null for a language that is not in the list.

loadHighlighter

const highlighter = await loadHighlighter(language)

Loads a language and returns a highlighter for it, or null for a language that is not in the list. highlighter.highlight(code, state?) returns { lines, state }. Pass state back to continue tokenizing where the code left off.

HighlightedToken

interface HighlightedToken {
  content: string
  htmlStyle?: Record<string, string>
}