Components

Code Block

Displays code, terminal output and diffs with a header, line numbers, syntax highlighting and a copy button.

Credits

Shout out to Shadcn Vue for the original CodeBlock. This version adds a code + language shortcut, themes, line highlight props, automatic diffs, a built-in show more, download and wrap buttons, more languages, and success / warning variants.

Source code

Click to see the source code for this component on GitHub. Feel free to copy it and adjust it for your own use.

Installation

This installs Shiki and two helpers next to the component: useHighlightedCode (a composable) and code-highlighter.ts (a util). CodeBlock uses the scroll-fade utility, so add it if you have not yet: Scroll Fade.

About

UiCodeBlock 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 lazy. Shiki and each language load the first time they are used, so a block that only shows plain output stays small.

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()})

Usage

Set code, language and filename on the root. The title, content, copy and download parts read them, so you do not repeat them on each part.

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();});

Or put plain text in UiCodeBlockContent and pass value to the copy button yourself:

<template>
  <UiCodeBlock>
    <UiCodeBlockHeader>
      <UiCodeBlockTitle>Terminal</UiCodeBlockTitle>
      <UiCodeBlockActions>
        <UiCodeBlockCopyButton :value="output" />
      </UiCodeBlockActions>
    </UiCodeBlockHeader>
    <UiCodeBlockContent>{{ output }}</UiCodeBlockContent>
  </UiCodeBlock>
</template>

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

Composition

UiCodeBlock
├── UiCodeBlockHeader
│   ├── UiCodeBlockTitle
│   └── UiCodeBlockActions
│       ├── UiCodeBlockWrapButton
│       ├── UiCodeBlockDownloadButton
│       └── UiCodeBlockCopyButton
├── UiCodeBlockActions (floating, when there is no header)
├── UiCodeBlockContent
│   └── UiCodeBlockLine
│       └── UiCodeBlockToken
└── UiCodeBlockFooter
    └── UiCodeBlockExpandButton

UiCodeBlockContent highlights code for you, or takes plain text, or a list of UiCodeBlockLine. Use lines when you want full control over each line and token.

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. Shiki's JavaScript engine, 30+ languages loaded on first use, ANSI colors for terminal output, and 11 themes. Add more with registerCodeLanguage and registerCodeTheme.
  • It does the common things for you.highlight-lines, automatic diff, max-lines with an expand button, a wrap toggle, a download button.
  • 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.
  • Four tones.default, destructive, success and warning.

Basic

Put plain text in UiCodeBlockContent. 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

UiCodeBlockHeader holds a UiCodeBlockTitle and UiCodeBlockActions. UiCodeBlockCopyButton 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

A long title truncates. Give the title an icon and text, or put your own markup in its slot. To link the title to a file, wrap UiCodeBlockTitle in an anchor.

<template>
  <UiCodeBlockHeader>
    <a href="/files/chat.post.ts">
      <UiCodeBlockTitle icon="lucide:file-code" text="server/api/chat.post.ts" />
    </a>
  </UiCodeBlockHeader>
</template>

Actions

UiCodeBlockActions holds any number of buttons. Drop in UiCodeBlockCopyButton, UiCodeBlockDownloadButton, UiCodeBlockWrapButton, or your own.

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)

Wrap, download and copy buttons

The buttons read what they need from the surrounding UiCodeBlock. Bind v-model:wrap on the root and the wrap button toggles it. The download button saves code as filename, and the copy button copies code.

Terminal
$ pnpm add ai @ai-sdk/vue zod @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/googlePackages: +42Progress: resolved 128, reused 120, downloaded 8, added 42, doneDone in 2.8s using pnpm v10.18.0
Wrapping is off

Floating Actions

Without a header, put UiCodeBlockActions directly inside UiCodeBlock. 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

Line Numbers

Add line-numbers to UiCodeBlockContent. 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"  }}

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})

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 the wrap button.

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.

Highlighted Lines

Pass highlight-lines to UiCodeBlockContent with numbers or ranges such as "17-18". The numbers are the ones shown in the gutter, so they follow start-line.

const result = streamText({  model: "anthropic/claude-sonnet-4.5",  messages: convertToModelMessages(messages),  tools: {    weather: tool({      description: "Get the weather in a location",      inputSchema: z.object({ location: z.string() }),      execute: async ({ location }) => getWeather(location),    }),  },});

To control each line yourself, render every line as a UiCodeBlockLine and set variant="highlighted" on the lines to call out.

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

Diff

With language="diff" (or :diff="true" for any language) lines that start with + are marked as added and lines that start with - as removed. The + and - stay in the text, so a copied selection is still a valid diff.

package.json
  "dependencies": {-   "ai": "^4.3.0",+   "ai": "^5.0.0",+   "@ai-sdk/vue": "^2.0.0",    "zod": "^4.0.0"  }

To build the diff yourself, set variant="added" or variant="removed" on a UiCodeBlockLine. 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>

Height

Cap the height with a max-h-* class on UiCodeBlockContent. 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

UiCodeBlockContent 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

Set max-lines on the root to show only that many lines until the block is expanded. UiCodeBlockExpandButton toggles v-model:expanded and only renders when there is something to expand.

components/Chat.vue
<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">      <UiInput v-model="input" />      <UiButton :disabled="chat.status !== 'ready'">Send</UiButton>    </form>  </div></template>
<script lang="ts" setup>import { Chat } from "@ai-sdk/vue";
const input = ref("");const chat = new Chat({});
function onSubmit() {  chat.sendMessage({ text: input.value });  input.value = "";}</script>

Build it yourself

UiCodeBlockFooter is a slot below the content. To control it yourself, 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"
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">      <UiInput v-model="input" />      <UiButton :disabled="chat.status !== 'ready'">        Send      </UiButton>    </form>  </div></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"
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">      <UiInput v-model="input" />      <UiButton :disabled="chat.status !== 'ready'">        Send      </UiButton>    </form>  </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

Variants

variant sets the tone of the block: default, destructive, success and warning. Use destructive for errors and failed output.

Output
Done in 2.8s
Passed
Tests  42 passed (42)
Warnings
2 warnings: unused variable `props`
Failed
ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with frozen-lockfile
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)

Trace

Put a UiCodeBlock with size="sm" in UiTraceCollapsibleContent for tool and terminal output. Add wrap and a max-h-64 class to UiCodeBlockContent so long output wraps and scrolls inside the trace. The block sets its own text color, so UiTraceCollapsibleContent 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)

Tabs

Show several files with Tabs, one UiCodeBlock in each UiTabsContent.

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()})

Message

Put a UiCodeBlock 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.

Typeset

A UiCodeBlock 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.

RTL

Code always reads left to right. The header follows the page direction.

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

الطرفية
pnpm add ai @ai-sdk/vue zodpnpm dev

Syntax Highlighting

Set language and code and the content highlights itself. For full control, useHighlightedCode highlights with Shiki and returns a computed list of lines. Each line is a list of tokens, which you render with UiCodeBlockToken inside a UiCodeBlockLine.

getWeather output
{  "location": "San Francisco",  "temperature": 18,  "unit": "celsius",  "conditions": ["fog", "wind"]}
<template>
  <UiCodeBlock>
    <UiCodeBlockContent>
      <UiCodeBlockLine v-for="(line, index) in lines" :key="index">
        <UiCodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
      </UiCodeBlockLine>
    </UiCodeBlockContent>
  </UiCodeBlock>
</template>

<script lang="ts" setup>
  const lines = useHighlightedCode(code, "json");
</script>

Both arguments accept a plain value, a ref or a getter, so the lines update when the code or the language changes. The optional third argument takes the themes:

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. A language that is not registered 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.

The list ships with astro, bash, c, cpp, csharp, css, dart, diff, docker, go, graphql, html, ini, java, javascript, json, jsonc, jsx, kotlin, lua, markdown, php, powershell, prisma, python, ruby, rust, scss, sql, svelte, swift, toml, tsx, typescript, vue, xml and yaml, plus aliases such as ts, js, sh, yml, py and rs.

To add another language, register a Shiki language module once, for example in a plugin:

registerCodeLanguage("zig", () => import("shiki/langs/zig.mjs"));

Themes

Pass themes on the root (or on the content) with a light and a dark Shiki theme. The default is github-light and github-dark. Bundled: catppuccin-latte, catppuccin-mocha, dracula, github-dark, github-light, min-dark, min-light, one-dark-pro, tokyo-night, vitesse-dark and vitesse-light.

vitesse
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;}
catppuccin
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;}

Register another theme with registerCodeTheme("nord", () => import("shiki/themes/nord.mjs")) and use its name in themes.

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)

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 registered. Call it from a server route or with useAsyncData so the tokens are part of the server-rendered HTML.

<template>
  <UiCodeBlock>
    <UiCodeBlockContent line-numbers>
      <UiCodeBlockLine v-for="(line, index) in lines" :key="index">
        <UiCodeBlockToken v-for="(token, i) in line" :key="i" :token="token" />
      </UiCodeBlockLine>
    </UiCodeBlockContent>
  </UiCodeBlock>
</template>

<script lang="ts" setup>
  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>

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 registered.

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}
bunx ui-thing@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

Markdown

Fenced code in a Markdown message renders well as a UiCodeBlock. Split the text into blocks, render the fences with a UiCodeBlock and the rest with your Markdown renderer. While the message streams, the last fence may not be closed yet, so disable the copy button on it.

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.

Accessibility

  • UiCodeBlockContent is a pre with a code element and always sets dir="ltr", so code reads left to right on an RTL page.
  • UiCodeBlockContent 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.
  • UiCodeBlockCopyButton is labelled "Copy", and a polite live region announces "Copied" after a successful copy.
  • UiCodeBlockWrapButton sets aria-pressed and UiCodeBlockExpandButton sets aria-expanded, so their state is announced.
  • Every button takes a label (or expand-text / collapse-text) so you can translate the accessible names.

API Reference

The layout parts (block, header, title, actions, footer) are built on a Reka Primitive, so as and as-child work on them. The buttons accept as. Every part accepts class.

UiCodeBlock

The surface that holds the header, the content and the footer. It also shares its code, language, filename, themes, wrap and expanded state with the parts inside it.

Prop / ModelTypeDefaultDescription
variant"default" | "destructive" | "success" | "warning""default"The tone of the block.
size"default" | "sm""default""sm" has tighter padding and text-xs code, for tool output.
codestring-The code. The content, copy and download parts fall back to it.
languagestring-The language of code.
filenamestring-The name used by the download button.
themes{ light: string; dark: string }GitHubThe Shiki themes for light and dark mode.
max-linesnumber-Show only this many lines until expanded.
v-model:wrapbooleanfalseWrap long lines. Shared with the wrap button and the content.
v-model:expandedbooleanfalseWhether the block is expanded. Shared with the expand button.
asstring | Component"div"The element to render.
classstring-Additional classes to apply to the block.

UiCodeBlockHeader

A row above the content for a title and actions.

PropTypeDefaultDescription
classstring-Additional classes to apply to the header.

UiCodeBlockTitle

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

PropTypeDefaultDescription
textstringfilenameThe title. Used when the default slot is empty. Falls back to the filename of UiCodeBlock.
iconstring-An Iconify icon shown before the text.
classstring-Additional classes to apply to the title.

UiCodeBlockActions

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

PropTypeDefaultDescription
classstring-Additional classes to apply to the actions.

UiCodeBlockCopyButton

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

Prop / EventTypeDefaultDescription
valuestringcodeThe text to copy. Falls back to the code of UiCodeBlock.
variantstring"ghost"The variant of the button.
sizestring"icon-xs"The size of the button.
timeoutnumber2000How long, in milliseconds, the copied state lasts.
labelstring"Copy"The accessible name.
copied-labelstring"Copied"The accessible name and announcement after copying.
copy(text: string) => void-Emitted after the text was copied.
classstring-Additional classes to apply to the button.

The default slot receives { copied } to replace the icon.

UiCodeBlockDownloadButton

Saves text as a file. It accepts the props of UiButton.

Prop / EventTypeDefaultDescription
valuestringcodeThe text to save. Falls back to the code of UiCodeBlock.
filenamestringfilenameThe file name. Falls back to the filename of UiCodeBlock.
variantstring"ghost"The variant of the button.
sizestring"icon-xs"The size of the button.
labelstring"Download"The accessible name.
download(filename: string) => void-Emitted after the download was started.

UiCodeBlockWrapButton

A toggle for the wrap state of the surrounding UiCodeBlock. It sets aria-pressed.

PropTypeDefaultDescription
variantstring"ghost"The variant of the button.
sizestring"icon-xs"The size of the button.
labelstring"Toggle line wrapping"The accessible name.

The default slot receives { wrap }.

UiCodeBlockExpandButton

Toggles the expanded state of the surrounding UiCodeBlock. It only renders when the code has more lines than max-lines.

PropTypeDefaultDescription
variantstring"ghost"The variant of the button.
sizestring"xs"The size of the button.
expand-textstring"Show more"The text while collapsed.
collapse-textstring"Show less"The text while expanded.

The default slot receives { expanded }.

UiCodeBlockContent

The pre that scrolls or wraps.

PropTypeDefaultDescription
default slot--Plain text, or a list of UiCodeBlockLine. Used when there is no code.
codestringroot codeThe code to render and highlight.
languagestringroot languageThe language of the code.
themes{ light: string; dark: string }root themesThe Shiki themes.
wrapbooleanroot wrapWrap 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.
highlight-lines(number | string)[]-Lines to highlight: numbers or ranges such as "3-5", using the displayed line numbers.
diffbooleanlanguage === "diff"Mark lines that start with + as added and - as removed.
max-linesnumberroot max-linesShow only this many lines until expanded.
tabindexnumber0Makes the content focusable so it can be scrolled by keyboard.
classstring-Additional classes to apply to the content, such as max-h-72.

UiCodeBlockLine

One line of code.

PropTypeDefaultDescription
variant"default" | "added" | "removed" | "highlighted""default"The state of the line.
markerbooleantrueShow the + / − gutter marker on added and removed lines. Turn it off when the text already starts with + or -.
classstring-Additional classes to apply to the line.

UiCodeBlockToken

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

PropTypeDefaultDescription
tokenHighlightedToken-Required. The token.

UiCodeBlockFooter

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, themes);

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

ArgumentTypeDescription
codeMaybeRefOrGetter<string>The code to highlight. A trailing newline is ignored.
languageMaybeRefOrGetter<string | undefined>A language or alias, or "ansi".
themesMaybeRefOrGetter<CodeBlockThemes | undefined>Optional. The light and dark theme names.

highlightCode

const lines = await highlightCode(code, language, themes);

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

loadHighlighter

const highlighter = await loadHighlighter(language, themes);

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

registerCodeLanguage / registerCodeTheme

registerCodeLanguage("zig", () => import("shiki/langs/zig.mjs"), "zg");
registerCodeTheme("nord", () => import("shiki/themes/nord.mjs"));

Make a language (with optional aliases) or a theme available to every block.

HighlightedToken

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