---
title: "Custom code block"
description: "How to build copy buttons, collapse thresholds, and other code-block UI when Comark does not pass a raw `code` prop."
canonical_url: "https://comark.dev/kb/custom-code-block"
---
# Custom code block

> How to build copy buttons, collapse thresholds, and other code-block UI when Comark does not pass a raw \`code\` prop.

Comark stores fenced code under a `<pre><code>` subtree instead of duplicating it as a `<pre>` attribute. Syntax highlighting plugins such as [Shiki](https://comark.dev/plugins/built-in/shiki) and [Rangi](https://comark.dev/plugins/built-in/rangi) replace the code text with tokenized span nodes, which renderers expose through the default slot.

<callout>
If you are migrating from MDC / Nuxt Content, a custom `ProsePre` that did `(props.code || '').split('\n')` for copy or collapse will break. Reconstruct the source from the AST instead.
</callout>

## What `<pre>` receives

Fence metadata is still on the node attrs and becomes component props:

| Prop         | Origin                                                     |
| ------------ | ---------------------------------------------------------- |
| `language`   | fence info string                                          |
| `filename`   | `[name]` in the fence                                      |
| `highlights` | `{1,3-5}` line highlights                                  |
| `meta`       | leftover fence meta                                        |
| `class`      | highlighter classes added by Shiki or Rangi                |
| `style`      | optional Shiki or Rangi output when `preStyles` is enabled |

There is **no** `code` or `lines` prop. The `<code>` subtree lives in the default slot and remains available on the full AST node.

## Pattern: `__node` + `textContent`

`@comark/vue` injects the full element node when **your component declares a `__node` prop**. Flatten it with [`textContent`](https://comark.dev/getting-started/document-model#extract-text) from `comark/utils` to recover the code contents without the fence or its final newline:

```vue [components/ProsePre.vue]
<script setup lang="ts">
import { computed } from 'vue'
import { textContent } from 'comark/utils'
import type { ElementNode } from 'comark'

defineOptions({ inheritAttrs: false })

const props = defineProps<{
  __node?: ElementNode
  language?: string
  filename?: string
}>()

const COLLAPSE_THRESHOLD = 15

const code = computed(() => (props.__node ? textContent(props.__node) : ''))
const lines = computed(() => code.value.split('\n').length)
const isLong = computed(() => lines.value > COLLAPSE_THRESHOLD)

async function copy() {
  await navigator.clipboard.writeText(code.value)
}
</script>

<template>
  <div class="relative group">
    <button type="button" @click="copy">Copy</button>
    <div :class="isLong && 'max-h-[360px] overflow-hidden'">
      <pre
        v-bind="$attrs"
        :language="props.language"
        :filename="props.filename"
      ><slot /></pre>
    </div>
  </div>
</template>
```

This works during SSR, stays in sync when `__node` changes and does not depend on the rendered DOM.

<tip>
In React, TypeScript prop declarations are erased at runtime. Accept `__node` in your component props and add a runtime marker with `ProsePre.propTypes = { __node: () => null }` so `@comark/react` injects the node, then call `textContent(__node)`.
</tip>

## See also

<card-group cols="2">
<card icon="i-lucide-code" title="Shiki plugin" to="https://comark.dev/plugins/built-in/shiki">
Themes, line highlights, and fence meta that become `pre` props.
</card>
<card icon="i-lucide-arrow-right-left" title="Migration from MDC" to="https://comark.dev/kb/migration-from-mdc">
Package and prose-component changes when moving off `@nuxtjs/mdc`.
</card>
</card-group>


## Sitemap

See the full [sitemap](https://comark.dev/sitemap.md) for all pages.
