19.4 Heading Render Hook: Adding Anchor Links
Right, so you’ve got your Markdown looking sharp, but those headings are just sitting there, looking pretty and utterly un-linkable. You want to drop a direct link to that “Common Pitfalls” section into a Slack channel without saying “uh, it’s somewhere in the middle of that page.” This is where the heading render hook comes in, and it’s one of the most satisfyingly practical hooks in the entire system. We’re going to use it to automatically slap anchor links on every heading, just like the ones you see on every major documentation site.
The core idea is simple: we intercept the process right after Hugo has converted the ### My Heading into an <h3 id="my-heading">My Heading</h3> element, but before it’s dumped into the final HTML. We get to grab that element, play with it, and send it on its way with our modifications.
The Basic Anchor Link Strategy
Here’s the mental model. For every heading, we want to:
- Keep the original heading text and ID.
- Inject a new hyperlink (
<a>) inside the heading. - This new link will point to
#the-heading-id. - Style it to be a subtle, modern anchor link (we’ll use CSS, because we’re not animals).
We’re not messing with the id attribute itself; that’s already perfectly set by Hugo’s smart anchoring. We’re just adding a convenience link.
Implementing the Hook in layouts/_default/_markup/render-heading.html
This is the template file Hugo will look for. Let’s build it up step-by-step. Here’s the initial, robust version:
{{- $heading := .Text -}}
{{- $level := .Level -}}
{{- $id := .Anchor | safeURL -}}
<h{{ $level }} id="{{ $id }}">
<a href="#{{ $id }}" class="anchor" aria-label="Anchor link for {{ $heading | plainify }}">
{{ $heading | safeHTML }}
</a>
</h{{ $level }}>
Let’s break down the moving parts:
.Textis the already-rendered HTML content of the heading. If your heading was### Hello <em>World</em>,.TextisHello <em>World</em>..Levelis the integer representing the heading level (e.g.,3for anh3)..Anchoris the clean, URL-friendly string Hugo generated for theidattribute. We pipe it throughsafeURLjust to be paranoid (it’s probably already safe, but trust no one).- We wrap the entire heading content in an
<a>tag that links to itself. Thearia-labelis crucial for accessibility—it tells screen readers exactly what this invisible-to-sighted-users link is for.
Why This (Initially) Seems Like a Terrible Idea
You might look at that code and think, “You just wrapped the entire heading in a link. That’s going to be a massive, block-level tap target. That’s janky.” And you’d be right. This is where the designers gave us a simple tool and expected us to use our CSS brains to finish the job. The HTML is semantically correct and accessible; the jank is purely a presentational issue, so we solve it with presentation.
The Mandatory CSS Fix
We need to make that link contain only a visual anchor icon, not the entire heading text. But we can’t change the HTML structure too much or we break accessibility. The trick is to use CSS to visually revert the text to being “not a link,” while leaving the underlying structure intact.
.anchor {
/* Take the entire heading out of the document flow so the link doesn't affect its size */
position: relative;
/* Ensure the heading text is still in the flow and behaves normally */
display: inline;
/* Remove the default link styling from the text */
color: inherit;
text-decoration: none;
}
/* Now, we add our icon as a pseudo-element on the link itself */
.anchor::before {
content: "🔗"; /* Or use a proper SVG background image */
/* Position it absolutely to the left of the heading */
position: absolute;
left: -1.5rem; /* Push it into the margin */
top: 50%;
transform: translateY(-50%);
/* Make it subtle */
opacity: 0.5;
font-size: 0.8em;
}
/* Show the icon on hover or when the heading itself is focused */
.anchor:hover::before,
h1:hover .anchor::before,
h2:hover .anchor::before,
h3:hover .anchor::before,
h4:hover .anchor::before,
h5:hover .anchor::before,
h6:hover .anchor::before,
:target .anchor::before { /* Highlight the anchor of the currently targeted heading */
opacity: 1;
}
This CSS ensures the link is only visually represented by the icon, which appears on hover. The text itself doesn’t get underlined or change color, preserving the design. The :target selector is a nice touch—it highlights the anchor of the section you’ve just jumped to.
Handling Edge Cases and Pitfalls
Empty Headings: What if you have a heading that’s just an image? .Text would be an <img> tag. Our current code would wrap that image in a link, which might break its styling or be entirely unnecessary. You might want to add logic to skip headings that contain only non-text content.
{{- if .Text | findRE "\\w" -}}
{{/* ... the anchor link code ... */}}
{{- else -}}
{{/* ... just render the heading without an anchor ... */}}
{{- end -}}
Custom IDs: A user can override Hugo’s auto-generated ID with a custom one like ### My Heading {#custom-id}. Our hook respects this completely because we’re using the .Anchor property, which already accounts for both automatic and user-defined IDs. This is why we use the provided properties instead of trying to be clever and regenerate the ID ourselves.
The beauty of this approach is its robustness. You’re leveraging Hugo’s built-in logic for everything hard (ID generation, HTML sanitization) and only adding the specific UI feature you want. It’s a perfect example of stepping in at exactly the right moment to customize behavior without reinventing the entire wheel. Now go forth and make your headings permanently linkable. Your future self, trying to share that one specific subsection, will thank you.