logo

Custom Renderers

One of the most powerful features of @humanspeak/svelte-markdown is the ability to replace any built-in renderer with your own Svelte component. This gives you full control over how each markdown element is rendered.

How It Works

When SvelteMarkdown parses markdown, it produces a tree of tokens. Each token has a type (e.g., heading, paragraph, link) that maps to a renderer key. The component looks up the renderer for each token and passes the token’s data as props.

Your custom renderers replace the defaults for whichever keys you specify:

<SvelteMarkdown
    source={markdown}
    renderers={{
        heading: MyHeading,
        link: MyLink,
        code: MyCode
    }}
/>
<SvelteMarkdown
    source={markdown}
    renderers={{
        heading: MyHeading,
        link: MyLink,
        code: MyCode
    }}
/>

Creating a Custom Renderer

A custom renderer is a standard Svelte 5 component. It receives props that correspond to the token’s data, plus a children snippet for tokens that contain nested content.

Step 1: Check the Props

Look up the renderer you want to override in the Markdown Renderers reference to see what props it receives.

Step 2: Create the Component

Create a Svelte component that accepts those props:

<!-- CustomLink.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    interface Props {
        href?: string
        title?: string
        children?: Snippet
    }

    const { href = '', title = undefined, children }: Props = $props()
</script>

<a
    {href}
    {title}
    target="_blank"
    rel="noopener noreferrer"
    class="custom-link"
>
    {@render children?.()}
</a>

<style>
    .custom-link {
        color: #0066cc;
        text-decoration: underline;
    }
</style>
<!-- CustomLink.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    interface Props {
        href?: string
        title?: string
        children?: Snippet
    }

    const { href = '', title = undefined, children }: Props = $props()
</script>

<a
    {href}
    {title}
    target="_blank"
    rel="noopener noreferrer"
    class="custom-link"
>
    {@render children?.()}
</a>

<style>
    .custom-link {
        color: #0066cc;
        text-decoration: underline;
    }
</style>

Step 3: Pass It to SvelteMarkdown

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import CustomLink from './CustomLink.svelte'
</script>

<SvelteMarkdown
    source="Visit [our site](https://example.com) for more info."
    renderers={{ link: CustomLink }}
/>
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import CustomLink from './CustomLink.svelte'
</script>

<SvelteMarkdown
    source="Visit [our site](https://example.com) for more info."
    renderers={{ link: CustomLink }}
/>

Common Custom Renderer Examples

Custom Heading with Anchor Links

<!-- AnchorHeading.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'
    import type { SvelteMarkdownOptions } from '@humanspeak/svelte-markdown'

    interface Props {
        depth: number
        raw: string
        text: string
        options: SvelteMarkdownOptions
        slug: (val: string) => string
        children?: Snippet
    }

    const { depth, text, options, slug, children }: Props = $props()

    const id = $derived(
        options.headerIds ? options.headerPrefix + slug(text) : undefined
    )
</script>

<svelte:element this="h{depth}" {id} class="heading">
    {#if id}
        <a href="#{id}" class="anchor-link">#</a>
    {/if}
    {@render children?.()}
</svelte:element>

<style>
    .heading {
        position: relative;
    }
    .anchor-link {
        position: absolute;
        left: -1.5em;
        opacity: 0;
        text-decoration: none;
    }
    .heading:hover .anchor-link {
        opacity: 0.5;
    }
</style>
<!-- AnchorHeading.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'
    import type { SvelteMarkdownOptions } from '@humanspeak/svelte-markdown'

    interface Props {
        depth: number
        raw: string
        text: string
        options: SvelteMarkdownOptions
        slug: (val: string) => string
        children?: Snippet
    }

    const { depth, text, options, slug, children }: Props = $props()

    const id = $derived(
        options.headerIds ? options.headerPrefix + slug(text) : undefined
    )
</script>

<svelte:element this="h{depth}" {id} class="heading">
    {#if id}
        <a href="#{id}" class="anchor-link">#</a>
    {/if}
    {@render children?.()}
</svelte:element>

<style>
    .heading {
        position: relative;
    }
    .anchor-link {
        position: absolute;
        left: -1.5em;
        opacity: 0;
        text-decoration: none;
    }
    .heading:hover .anchor-link {
        opacity: 0.5;
    }
</style>

Custom Code Block with Syntax Highlighting

<!-- HighlightedCode.svelte -->
<script lang="ts">
    interface Props {
        lang: string
        text: string
    }

    const { lang, text }: Props = $props()
</script>

<div class="code-block">
    {#if lang}
        <div class="code-lang">{lang}</div>
    {/if}
    <pre><code class="language-{lang}">{text}</code></pre>
</div>

<style>
    .code-block {
        position: relative;
        border-radius: 8px;
        overflow: hidden;
    }
    .code-lang {
        position: absolute;
        top: 0;
        right: 0;
        padding: 2px 8px;
        font-size: 0.75rem;
        background: rgba(0, 0, 0, 0.1);
        border-bottom-left-radius: 4px;
    }
</style>
<!-- HighlightedCode.svelte -->
<script lang="ts">
    interface Props {
        lang: string
        text: string
    }

    const { lang, text }: Props = $props()
</script>

<div class="code-block">
    {#if lang}
        <div class="code-lang">{lang}</div>
    {/if}
    <pre><code class="language-{lang}">{text}</code></pre>
</div>

<style>
    .code-block {
        position: relative;
        border-radius: 8px;
        overflow: hidden;
    }
    .code-lang {
        position: absolute;
        top: 0;
        right: 0;
        padding: 2px 8px;
        font-size: 0.75rem;
        background: rgba(0, 0, 0, 0.1);
        border-bottom-left-radius: 4px;
    }
</style>

Custom Image with Caption

<!-- CaptionedImage.svelte -->
<script lang="ts">
    interface Props {
        href?: string
        title?: string
        text?: string
    }

    const { href = '', title = undefined, text = '' }: Props = $props()
</script>

<figure>
    <img src={href} alt={text} {title} loading="lazy" />
    {#if text}
        <figcaption>{text}</figcaption>
    {/if}
</figure>

<style>
    figure {
        margin: 1.5em 0;
        text-align: center;
    }
    img {
        max-width: 100%;
        border-radius: 4px;
    }
    figcaption {
        margin-top: 0.5em;
        font-size: 0.875rem;
        color: #666;
    }
</style>
<!-- CaptionedImage.svelte -->
<script lang="ts">
    interface Props {
        href?: string
        title?: string
        text?: string
    }

    const { href = '', title = undefined, text = '' }: Props = $props()
</script>

<figure>
    <img src={href} alt={text} {title} loading="lazy" />
    {#if text}
        <figcaption>{text}</figcaption>
    {/if}
</figure>

<style>
    figure {
        margin: 1.5em 0;
        text-align: center;
    }
    img {
        max-width: 100%;
        border-radius: 4px;
    }
    figcaption {
        margin-top: 0.5em;
        font-size: 0.875rem;
        color: #666;
    }
</style>

Custom Blockquote with Callout Styling

<!-- CalloutBlockquote.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    interface Props {
        children?: Snippet
    }

    const { children }: Props = $props()
</script>

<div class="callout">
    <div class="callout-icon">i</div>
    <div class="callout-content">
        {@render children?.()}
    </div>
</div>

<style>
    .callout {
        display: flex;
        gap: 12px;
        padding: 16px;
        background: #f0f4ff;
        border-left: 4px solid #3b82f6;
        border-radius: 4px;
        margin: 1em 0;
    }
    .callout-icon {
        font-weight: bold;
        color: #3b82f6;
    }
</style>
<!-- CalloutBlockquote.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    interface Props {
        children?: Snippet
    }

    const { children }: Props = $props()
</script>

<div class="callout">
    <div class="callout-icon">i</div>
    <div class="callout-content">
        {@render children?.()}
    </div>
</div>

<style>
    .callout {
        display: flex;
        gap: 12px;
        padding: 16px;
        background: #f0f4ff;
        border-left: 4px solid #3b82f6;
        border-radius: 4px;
        margin: 1em 0;
    }
    .callout-icon {
        font-weight: bold;
        color: #3b82f6;
    }
</style>

Overriding HTML Tag Renderers

You can also override the renderers for specific HTML tags that appear in raw HTML within markdown:

<!-- CustomDiv.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    const { class: className, children, ...rest }: any = $props()
</script>

<div class="custom-wrapper {className || ''}" {...rest}>
    {@render children?.()}
</div>
<!-- CustomDiv.svelte -->
<script lang="ts">
    import type { Snippet } from 'svelte'

    const { class: className, children, ...rest }: any = $props()
</script>

<div class="custom-wrapper {className || ''}" {...rest}>
    {@render children?.()}
</div>
<SvelteMarkdown
    source={markdown}
    renderers={{
        html: {
            div: CustomDiv
        }
    }}
/>
<SvelteMarkdown
    source={markdown}
    renderers={{
        html: {
            div: CustomDiv
        }
    }}
/>

Setting a Renderer to null

You can set any renderer to null to suppress that element type entirely:

<SvelteMarkdown
    source={markdown}
    renderers={{
        image: null,
        hr: null
    }}
/>
<SvelteMarkdown
    source={markdown}
    renderers={{
        image: null,
        hr: null
    }}
/>

Using defaultRenderers as a Base

You can import the default renderer map and extend it:

import { defaultRenderers } from '@humanspeak/svelte-markdown'

const myRenderers = {
    ...defaultRenderers,
    heading: MyHeading,
    link: MyLink
}
import { defaultRenderers } from '@humanspeak/svelte-markdown'

const myRenderers = {
    ...defaultRenderers,
    heading: MyHeading,
    link: MyLink
}

Passing Custom Data to Renderers

Any extra props you pass to <SvelteMarkdown> beyond the standard props (source, renderers, options, isInline, parsed) are automatically forwarded to every renderer component. This lets you pass application-specific context that your custom renderers can use.

How It Works

SvelteMarkdown collects unknown props via ...rest and spreads them through the Parser to each renderer. Your custom renderer simply declares the extra prop in its interface:

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import CustomText from './CustomText.svelte'

    let userData = $state({
        theme: 'dark',
        locale: 'en-US',
        permissions: ['read', 'write']
    })
</script>

<SvelteMarkdown
    source={markdown}
    {userData}
    renderers={{ rawtext: CustomText }}
/>
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import CustomText from './CustomText.svelte'

    let userData = $state({
        theme: 'dark',
        locale: 'en-US',
        permissions: ['read', 'write']
    })
</script>

<SvelteMarkdown
    source={markdown}
    {userData}
    renderers={{ rawtext: CustomText }}
/>

Inside your custom renderer, access the forwarded prop directly:

<!-- CustomText.svelte -->
<script lang="ts">
    interface Props {
        text?: string
        userData?: { theme: string; locale: string; permissions: string[] }
    }

    const { text, userData }: Props = $props()
</script>

<span class="text-{userData?.theme}">
    {text}
</span>
<!-- CustomText.svelte -->
<script lang="ts">
    interface Props {
        text?: string
        userData?: { theme: string; locale: string; permissions: string[] }
    }

    const { text, userData }: Props = $props()
</script>

<span class="text-{userData?.theme}">
    {text}
</span>

Production Example: Dynamic Value Interpolation

A powerful real-world use case is passing structured data alongside markdown and using a custom renderer to interpolate live values into the rendered output. For example, you can pass a data object containing placeholder values, and the renderer replaces template markers with rich interactive elements:

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import RawText from './RawText.svelte'

    let markdownBlock = $state({
        message: 'The total revenue is {revenue} across {count} transactions.',
        values: {
            revenue: { type: 'currency', value: 125000, description: 'Total Q4 revenue' },
            count: { type: 'number', value: 1847, description: 'Transaction count' }
        }
    })
</script>

<SvelteMarkdown
    source={markdownBlock.message}
    {markdownBlock}
    renderers={{ rawtext: RawText }}
/>
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import RawText from './RawText.svelte'

    let markdownBlock = $state({
        message: 'The total revenue is {revenue} across {count} transactions.',
        values: {
            revenue: { type: 'currency', value: 125000, description: 'Total Q4 revenue' },
            count: { type: 'number', value: 1847, description: 'Transaction count' }
        }
    })
</script>

<SvelteMarkdown
    source={markdownBlock.message}
    {markdownBlock}
    renderers={{ rawtext: RawText }}
/>

The custom RawText renderer receives both the text prop (from the token) and the markdownBlock prop (forwarded from SvelteMarkdown). It can then parse the text for {placeholder} patterns and replace them with rich UI elements — tooltips, hover cards, formatted values, loading skeletons, etc.

<!-- RawText.svelte -->
<script lang="ts">
    interface Props {
        text?: string
        markdownBlock?: {
            message: string
            values: Record<string, { type: string; value: unknown; description?: string }>
        }
    }

    const { text, markdownBlock }: Props = $props()

    // Parse text for {placeholder} patterns and replace with values from markdownBlock
    const segments = $derived.by(() => {
        if (!text || !markdownBlock?.values) return [{ type: 'text', content: text }]

        const result = []
        const regex = /\{([^}]+)\}/g
        let lastIndex = 0
        let match

        while ((match = regex.exec(text)) !== null) {
            if (match.index > lastIndex) {
                result.push({ type: 'text', content: text.slice(lastIndex, match.index) })
            }
            const key = match[1]
            const value = markdownBlock.values[key]
            if (value) {
                result.push({ type: 'value', data: value })
            }
            lastIndex = match.index + match[0].length
        }

        if (lastIndex < text.length) {
            result.push({ type: 'text', content: text.slice(lastIndex) })
        }
        return result
    })
</script>

{#each segments as segment}
    {#if segment.type === 'text'}
        <span>{segment.content}</span>
    {:else if segment.type === 'value'}
        <strong title={segment.data.description}>{segment.data.value}</strong>
    {/if}
{/each}
<!-- RawText.svelte -->
<script lang="ts">
    interface Props {
        text?: string
        markdownBlock?: {
            message: string
            values: Record<string, { type: string; value: unknown; description?: string }>
        }
    }

    const { text, markdownBlock }: Props = $props()

    // Parse text for {placeholder} patterns and replace with values from markdownBlock
    const segments = $derived.by(() => {
        if (!text || !markdownBlock?.values) return [{ type: 'text', content: text }]

        const result = []
        const regex = /\{([^}]+)\}/g
        let lastIndex = 0
        let match

        while ((match = regex.exec(text)) !== null) {
            if (match.index > lastIndex) {
                result.push({ type: 'text', content: text.slice(lastIndex, match.index) })
            }
            const key = match[1]
            const value = markdownBlock.values[key]
            if (value) {
                result.push({ type: 'value', data: value })
            }
            lastIndex = match.index + match[0].length
        }

        if (lastIndex < text.length) {
            result.push({ type: 'text', content: text.slice(lastIndex) })
        }
        return result
    })
</script>

{#each segments as segment}
    {#if segment.type === 'text'}
        <span>{segment.content}</span>
    {:else if segment.type === 'value'}
        <strong title={segment.data.description}>{segment.data.value}</strong>
    {/if}
{/each}

This pattern is especially useful for AI chat interfaces, data dashboards, and CMS applications where markdown content contains dynamic references that need to be resolved at render time.

Tips

  • Your custom component receives the same props as the built-in renderer it replaces. Check the Markdown Renderers reference for each renderer’s prop signature.
  • Components that render child content must accept and render a children snippet: {@render children?.()}.
  • Use $props() (Svelte 5 runes) to destructure incoming props.
  • You can mix custom and default renderers freely — only override the ones you need.
  • Any extra props passed to SvelteMarkdown are forwarded to all renderers, so you can pass application context without needing Svelte context stores.

Related