Code Block
A dark code panel with a language label in the header, a copy button that flips to "Copied!" for two seconds, and syntax highlighting from Shiki. The panel animates in once on mount; the copy confirmation swaps with a short vertical slide.
Highlighting is done in the browser. On mount the component creates a Shiki highlighter for exactly the theme and language it was given, converts the code to HTML, and injects the result. That means the markup is fully highlighted by the time it appears rather than flashing plain text and recolouring — at the cost of the panel being empty on the server render.
Because it goes through Shiki, language has to be a language Shiki knows and a
theme it ships. If either is missing, the component falls back to a plain escaped
<pre> in the same dark shell rather than throwing, so a wrong language degrades
to readable-but-unhighlighted code.
The showLineNumbers gutter is a separate absolutely-positioned column rather
than Shiki's own line rendering, which keeps the gutter out of the injected HTML.
It only appears once highlighting has produced content, so on the server render
and during the highlighter's async load the panel renders without it.
Usage
Pass code as a string and language as a Shiki language id. Toggle
showLineNumbers when the snippet is long enough that position matters.
<template>
<div class="flex w-full max-w-xl flex-col gap-4">
<USwitch
v-model="withNumbers"
label="Show line numbers"
/>
<MCodeBlock
:code="snippet"
language="ts"
:show-line-numbers="withNumbers"
/>
</div>
</template>
<script setup lang="ts">
const withNumbers = ref(true)
const snippet = `const items = ref([
{ id: 1, label: 'Style tokens' },
{ id: 2, label: 'Component audit' },
])
function onChange(next: Item[]) {
items.value = [...next]
}`
</script>
Languages and themes
language and theme both have to be values Shiki ships. The highlighter is
built once when the panel mounts, so the panel is re-keyed (:key) when either
changes to force a fresh grammar and theme.
The highlighter is built once on mount, so the panel is re-keyed to force a fresh grammar and theme when either changes.
<template>
<div class="flex w-full max-w-xl flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<USelect
v-model="language"
:items="languages"
class="w-40"
/>
<USelect
v-model="theme"
:items="themes"
class="w-40"
/>
</div>
<MCodeBlock
:key="`${language}-${theme}`"
:code="snippets[language]"
:language="language"
:theme="theme"
/>
<p class="text-xs text-dimmed">
The highlighter is built once on mount, so the panel is re-keyed to force a fresh grammar and theme when either changes.
</p>
</div>
</template>
<script setup lang="ts">
const languages = ['ts', 'vue', 'css', 'json']
const themes = ['github-dark', 'github-light']
const snippets: Record<string, string> = {
ts: `export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
return { count, increment }
}`,
vue: `<template>
<MCodeBlock
:code="code"
language="vue"
/>
</template>`,
css: `.panel {
display: grid;
gap: 1rem;
border-radius: 0.75rem;
}`,
json: `{
"name": "nuxt-motion-block",
"private": true
}`,
}
const language = ref('ts')
const theme = ref('github-dark')
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | - | The source to display, and the exact text the copy button puts on the clipboard — not the highlighted HTML. Required. |
language | string | 'vue' | Shiki language id used both to load the grammar and to tokenise the code. An unknown id falls back to plain escaped text in the same panel rather than failing. |
theme | string | 'github-dark' | Shiki theme name. Loaded alongside the language and used for the token colours;
the panel background is hardcoded to #0d1117 and does not follow the theme. |
showLineNumbers | boolean | false | Render the line-number gutter and pad the code body to clear it. The gutter only appears after highlighting has resolved, so it is absent from the server render. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.codeBlock in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative group |
container | - | relative overflow-hidden rounded-lg border border-default bg-[#0d1117] text-sm |
header | - | flex items-center justify-between px-4 py-2 border-b border-default |
language | - | text-xs text-dimmed font-mono |
copyButton | - | text-dimmed hover:text-white transition-colors |
copy | - | text-xs |
copied | - | text-xs text-green-400 |
lineNumbers | - | absolute left-0 top-0 bottom-0 w-12 flex flex-col items-end pr-3 pt-4 text-muted text-xs font-mono select-none border-r border-default |
lineNumber | - | leading-6 |
body | - | code-body |
Accessibility
The copy button is a bare <button> with no type and no accessible label beyond
its own visible text, so its accessible name changes with that text — "Copy", then
"Copied!" — but nothing announces the change to a screen reader, and there is no
aria-live region. The language name in the header is a <span>, not a label
element.
The highlighted code is injected with v-html, so the code itself reaches the
accessibility tree as the Shiki spans it produces — token colours carry no extra
semantics, and the code reads as text. There is no tabindex on the scrollable
body, so a keyboard user cannot scroll a long line horizontally; the copy button
is the only focusable element in the panel.
No prefers-reduced-motion handling: the panel animates in on mount and the copy
label slides between its two states regardless of motion preference. The two
second timer that flips the label back is not cleared on unmount.
Performance
- Shiki is loaded and initialised on mount per instance, for one theme and one language. A page with many code panels pays for that many initialisations.
- The highlighter runs once; the copy path only calls
useClipboard'scopyand toggles a ref. - The panel fades and slides in on mount with the default transition, and its
variant declares
staggerChildrenwhile having no motion children of its own — so the stagger has nothing to stagger and the whole panel is one animated element. - The
showLineNumbersbranch is duplicated in the template: two near-identicalmotion.divtrees, one with the gutter and one without. Only one renders. - The fallback path escapes
&,<,>and"and wraps the result in ashikiclass<pre>, so a missing grammar or theme degrades instead of throwing.
onMounted, so changing code or language on
an existing instance does not re-highlight — the old markup stays until the
component is recreated. Key the component on the snippet instead of updating it.
Note also that theme only controls token colours while the panel background is
#0d1117 hardcoded, so a light Shiki theme lands light-coloured tokens on a dark
panel.