Interactive

Code Block

A Shiki-highlighted code panel with a language label and a copy button.

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.

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

ts

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

vue

Customize

Codeexport function useCounter(initial = 0) { const count = ref(initial) const increment = () => count.value++ return { count, increment } }
Languagevue
Themegithub-dark
Show Line Numbers
Variantcode
Bare

Props

Prop Type Default Description
codestring-The source to display, and the exact text the copy button puts on the clipboard — not the highlighted HTML. Required.
languagestring'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.
themestring'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.
showLineNumbersbooleanfalseRender 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
uiobject-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's copy and toggles a ref.
  • The panel fades and slides in on mount with the default transition, and its variant declares staggerChildren while having no motion children of its own — so the stagger has nothing to stagger and the whole panel is one animated element.
  • The showLineNumbers branch is duplicated in the template: two near-identical motion.div trees, one with the gutter and one without. Only one renders.
  • The fallback path escapes &, <, > and " and wraps the result in a shiki class <pre>, so a missing grammar or theme degrades instead of throwing.
The highlighter is created once in 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.
Copyright © 2026