<!-- Source: https://markdown.svelte.page/examples/linked-headings -->

# Linked Headings

> Add clickable anchor links to markdown headings with @humanspeak/svelte-markdown using default output, a custom heading renderer, or Svelte 5 snippets.

**Source:** [https://markdown.svelte.page/examples/linked-headings](https://markdown.svelte.page/examples/linked-headings)

**Markdown mirror:** [https://markdown.svelte.page/examples/linked-headings.md](https://markdown.svelte.page/examples/linked-headings.md)

---

This mirror preserves the prose, implementation notes, and runnable Svelte source behind the live example page.

## FIG-001: default headings.

Stock headings — each gets an `id` derived from its text (so `#getting-started` works in the URL), but there’s no visible anchor link.

**Metadata:** tag: `DEFAULT` | override: `none`

### Notes

- Every heading already gets an `id` derived from its text via `github-slugger`. The URL `#getting-started` jumps to that heading.
- There's just no visible anchor link — readers can't deep-link by clicking. The next two FIGs add that affordance.

### Source

#### DefaultHeadings.svelte

Source file: [src/lib/examples/linked-headings/demos/DefaultHeadings.svelte](https://github.com/humanspeak/svelte-markdown/blob/main/docs/src/lib/examples/linked-headings/demos/DefaultHeadings.svelte)

```svelte
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    const markdown = `## Linked Headings Demo

### Getting Started

Headings rendered by svelte-markdown include \`id\` attributes by default, but they don't have clickable anchor links. This example shows two ways to add them.

#### Installation

Install the package with your preferred package manager:

\`\`\`bash
pnpm add @humanspeak/svelte-markdown
\`\`\`

### Configuration

Configure the component with your markdown source and any options you need.

#### Basic Usage

Import and use the component in your Svelte file.

### Advanced Topics

#### Custom Slug Functions

You can provide a custom slug function for full control over heading IDs.

##### Nested Heading Example

This is a deeply nested heading to show all levels work correctly.`
</script>

<!--
  Default heading rendering — no override. Each heading gets an `id`
  derived from its text (so `#getting-started` works in the URL), but
  there's no visible anchor link.
-->
<div class="prose prose-sm dark:prose-invert mx-auto max-w-4xl px-6 py-6">
    <SvelteMarkdown source={markdown} />
</div>
```

## FIG-002: custom renderer.

A dedicated `LinkedHeading.svelte` component replaces the default heading renderer via `renderers={{ heading: LinkedHeading }}`. Best when the override has its own state, or you want to reuse it across pages.

**Metadata:** tag: `RENDERER` | override: `component`

### Notes

- Pass `renderers={{ heading: LinkedHeading }}` and your component receives the heading depth + text + raw HTML.
- Best when the override has its own state (clipboard copy, scroll-spy) or needs to be reused across multiple pages.

### Source

#### RendererHeadings.svelte

Source file: [src/lib/examples/linked-headings/demos/RendererHeadings.svelte](https://github.com/humanspeak/svelte-markdown/blob/main/docs/src/lib/examples/linked-headings/demos/RendererHeadings.svelte)

```svelte
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import LinkedHeading from './LinkedHeading.svelte'

    const markdown = `## Linked Headings Demo

### Getting Started

Headings rendered by svelte-markdown include \`id\` attributes by default, but they don't have clickable anchor links. This example shows two ways to add them.

#### Installation

Install the package with your preferred package manager:

\`\`\`bash
pnpm add @humanspeak/svelte-markdown
\`\`\`

### Configuration

Configure the component with your markdown source and any options you need.

#### Basic Usage

Import and use the component in your Svelte file.

### Advanced Topics

#### Custom Slug Functions

You can provide a custom slug function for full control over heading IDs.

##### Nested Heading Example

This is a deeply nested heading to show all levels work correctly.`
</script>

<!--
  Custom component renderer — `LinkedHeading.svelte` replaces the default
  heading renderer for every depth. Best for reuse across multiple pages
  or when the override needs its own complex state.
-->
<div class="prose prose-sm dark:prose-invert mx-auto max-w-4xl px-6 py-6">
    <SvelteMarkdown source={markdown} renderers={{ heading: LinkedHeading }} />
</div>
```

#### LinkedHeading.svelte

Source file: [src/lib/examples/linked-headings/demos/LinkedHeading.svelte](https://github.com/humanspeak/svelte-markdown/blob/main/docs/src/lib/examples/linked-headings/demos/LinkedHeading.svelte)

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

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

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

    // Resolve the heading id the same way svelte-markdown's default
    // renderer would — honouring `headerIds` and `headerPrefix` so this
    // component drops in without surprises when the consumer toggles
    // those options.
    const id = $derived(options.headerIds ? options.headerPrefix + slug(text) : undefined)
</script>

<svelte:element this={`h${depth}`} {id} class="lh-heading">
    <a href={id ? `#${id}` : undefined} class="lh-link">
        {@render children?.()}
        {#if id}
            <span class="lh-icon" aria-hidden="true">🔗</span>
        {/if}
    </a>
</svelte:element>

<style>
    :global(.lh-heading) {
        position: relative;
    }
    :global(.lh-link) {
        color: inherit;
        text-decoration: none;
    }
    :global(.lh-link:hover) {
        text-decoration: underline;
    }
    :global(.lh-icon) {
        margin-left: 8px;
        font-size: 0.7em;
        opacity: 0;
        transition: opacity 0.15s;
        color: var(--brut-accent);
    }
    :global(.lh-heading:hover .lh-icon),
    :global(.lh-link:focus-visible .lh-icon) {
        opacity: 1;
    }
</style>
```

## FIG-003: snippet override.

An inline `{#snippet heading()}` block adds a hover-reveal `#` anchor right at the call site. Best for one-off tweaks that don’t earn their own component file.

**Metadata:** tag: `SNIPPET` | override: `inline snippet`

### Notes

- `{#snippet heading({ depth, text })}` renders right at the call site — no separate file or props passing required.
- Best for one-off layout tweaks per page. The hover-reveal `#` anchor here is just a few lines of inline template.

### Source

#### SnippetHeadings.svelte

Source file: [src/lib/examples/linked-headings/demos/SnippetHeadings.svelte](https://github.com/humanspeak/svelte-markdown/blob/main/docs/src/lib/examples/linked-headings/demos/SnippetHeadings.svelte)

```svelte
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    const markdown = `## Linked Headings Demo

### Getting Started

Headings rendered by svelte-markdown include \`id\` attributes by default, but they don't have clickable anchor links. This example shows two ways to add them.

#### Installation

Install the package with your preferred package manager:

\`\`\`bash
pnpm add @humanspeak/svelte-markdown
\`\`\`

### Configuration

Configure the component with your markdown source and any options you need.

#### Basic Usage

Import and use the component in your Svelte file.

### Advanced Topics

#### Custom Slug Functions

You can provide a custom slug function for full control over heading IDs.

##### Nested Heading Example

This is a deeply nested heading to show all levels work correctly.`
</script>

<!--
  Snippet override — an inline `{#snippet heading()}` block adds a
  hover-reveal `#` anchor link after each heading. Best for one-off
  customizations that don't need their own component file.
-->
<div class="prose prose-sm dark:prose-invert mx-auto max-w-4xl px-6 py-6">
    <SvelteMarkdown source={markdown}>
        {#snippet heading({ depth, text, slug, options, children })}
            {@const id = options.headerIds ? options.headerPrefix + slug(text) : undefined}
            <svelte:element this={`h${depth}`} {id} class="lh-heading">
                {@render children?.()}
                {#if id}
                    <a href="#{id}" class="lh-anchor" aria-label="Link to {text}"> # </a>
                {/if}
            </svelte:element>
        {/snippet}
    </SvelteMarkdown>
</div>

<style>
    /* Reveal-on-hover anchor styled with brut tokens so it flips cleanly
       between light and dark themes. The wrapper heading uses `position:
       relative` so the absolute-positioned anchor (when we'd want one)
       could anchor to it; for now the anchor sits inline after the text. */
    :global(.lh-heading) {
        position: relative;
    }
    :global(.lh-anchor) {
        margin-left: 8px;
        color: var(--brut-accent);
        text-decoration: none;
        opacity: 0;
        font-size: 0.7em;
        transition: opacity 0.15s;
    }
    :global(.lh-heading:hover .lh-anchor),
    :global(.lh-anchor:focus-visible) {
        opacity: 1;
    }
</style>
```
