Custom Hugo Shortcode Development
Hugo shortcodes are a mechanism for embedding reusable components directly into Markdown content. This is a solution for cases when you need to add something more complex than plain text to an article: warnings, comparison tables, video embeds with parameters, interactive blocks. Content authors don't touch HTML — they use simple syntax.
Invocation Syntax
{{</* callout type="warning" */>}}
Warning text
{{</* /callout */>}}
{{</* youtube id="dQw4w9WgXcQ" autoplay="false" */>}}
{{</* quote author="Donald Knuth" source="The Art of Computer Programming" */>}}
Premature optimization is the root of all evil.
{{</* /quote */>}}
Two syntax variants:
-
{{< >}}— output without HTML escaping (for components with markup) -
{{% %}}— content inside is processed as Markdown
Simple Shortcode: Callout/Warning
{{/* layouts/shortcodes/callout.html */}}
{{ $type := .Get "type" | default "info" }}
{{ $title := .Get "title" }}
{{ $icons := dict
"info" "ℹ️"
"warning" "⚠️"
"danger" "🚨"
"success" "✅"
"tip" "💡"
}}
<div class="callout callout--{{ $type }}">
<div class="callout__icon">{{ index $icons $type }}</div>
<div class="callout__body">
{{ with $title }}
<strong class="callout__title">{{ . }}</strong>
{{ end }}
<div class="callout__content">{{ .Inner | markdownify }}</div>
</div>
</div>
Usage:
{{</* callout type="warning" title="Important" */>}}
Back up your database before updating.
{{</* /callout */>}}
Shortcode with Positional Parameters
{{/* layouts/shortcodes/figure.html */}}
{{ $src := .Get 0 }}
{{ $alt := .Get 1 | default "" }}
{{ $caption := .Get 2 | default "" }}
{{ $width := .Get "width" | default "100%" }}
{{ $img := resources.Get $src }}
{{ if $img }}
{{ $webp := $img | images.Resize (printf "%s WebP" (default "1200x" (.Get "resize"))) }}
<figure class="article-figure" style="max-width: {{ $width }}">
<picture>
<source srcset="{{ $webp.Permalink }}" type="image/webp">
<img src="{{ $img.Permalink }}" alt="{{ $alt }}" loading="lazy">
</picture>
{{ with $caption }}
<figcaption>{{ . | markdownify }}</figcaption>
{{ end }}
</figure>
{{ else }}
<!-- Fallback for external images -->
<figure class="article-figure" style="max-width: {{ $width }}">
<img src="{{ $src }}" alt="{{ $alt }}" loading="lazy">
{{ with $caption }}<figcaption>{{ . }}</figcaption>{{ end }}
</figure>
{{ end }}
Usage:
{{</* figure "images/architecture.png" "System Architecture" "Component diagram of the application" width="80%" */>}}
Shortcode with Tabs
Complex case: multiple shortcodes related to each other through scratch:
{{/* layouts/shortcodes/tabs.html */}}
{{ .Scratch.Set "tabs" slice }}
{{ .Inner | markdownify }}
{{ $tabs := .Scratch.Get "tabs" }}
<div class="tabs" data-tabs>
<div class="tabs__nav" role="tablist">
{{ range $i, $tab := $tabs }}
<button
class="tabs__trigger{{ if eq $i 0 }} is-active{{ end }}"
role="tab"
aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}"
aria-controls="tab-panel-{{ $tab.id }}"
>{{ $tab.label }}</button>
{{ end }}
</div>
{{ range $i, $tab := $tabs }}
<div
id="tab-panel-{{ $tab.id }}"
class="tabs__panel{{ if not (eq $i 0) }} is-hidden{{ end }}"
role="tabpanel"
>{{ $tab.content | safeHTML }}</div>
{{ end }}
</div>
{{/* layouts/shortcodes/tab.html */}}
{{ $label := .Get "label" }}
{{ $id := $label | urlize }}
{{ $content := .Inner | markdownify }}
{{ $tabs := .Parent.Scratch.Get "tabs" }}
{{ $tabs = $tabs | append (dict "label" $label "id" $id "content" $content) }}
{{ .Parent.Scratch.Set "tabs" $tabs }}
JavaScript for switching:
document.querySelectorAll('[data-tabs]').forEach(tabGroup => {
tabGroup.querySelectorAll('.tabs__trigger').forEach((trigger, i) => {
trigger.addEventListener('click', () => {
tabGroup.querySelectorAll('.tabs__trigger').forEach(t => {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
});
tabGroup.querySelectorAll('.tabs__panel').forEach(p => p.classList.add('is-hidden'));
trigger.classList.add('is-active');
trigger.setAttribute('aria-selected', 'true');
tabGroup.querySelector(`#${trigger.getAttribute('aria-controls')}`).classList.remove('is-hidden');
});
});
});
Shortcode for Code with Header
{{/* layouts/shortcodes/code.html */}}
{{ $lang := .Get "lang" | default "bash" }}
{{ $title := .Get "title" | default "" }}
{{ $filename := .Get "filename" | default "" }}
<div class="code-block">
{{ if or $title $filename }}
<div class="code-block__header">
{{ with $filename }}<span class="code-block__filename">{{ . }}</span>{{ end }}
{{ with $title }}<span class="code-block__title">{{ . }}</span>{{ end }}
<button class="code-block__copy" onclick="copyCode(this)">Copy</button>
</div>
{{ end }}
<div class="code-block__content">
{{ highlight (.Inner | trim "\n") $lang "" }}
</div>
</div>
Shortcode for Comparison Table
{{/* layouts/shortcodes/compare.html */}}
{{ $headers := split (.Get "headers") "," }}
{{ $rows := split .Inner "\n" | after 0 }}
<div class="compare-table-wrapper">
<table class="compare-table">
<thead>
<tr>
{{ range $headers }}
<th>{{ . | trim " " }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range $rows }}
{{ if . }}
<tr>
{{ $cells := split . "|" }}
{{ range $cells }}
<td>{{ . | trim " " | markdownify }}</td>
{{ end }}
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>
Shortcode Debugging
{{/* Output all parameters for debugging */}}
{{ if hugo.IsServer }}
<pre class="debug-shortcode">
Params: {{ .Params | jsonify (dict "indent" " ") }}
Inner: {{ .Inner }}
Parent: {{ with .Parent }}{{ .Name }}{{ end }}
</pre>
{{ end }}
Timeline
One simple shortcode (warning, quote, badge) — 2–4 hours. Set of 5–10 shortcodes with CSS and basic JS — 2–3 days. Complex related shortcodes (tabs, accordion, compare table) with full edge-case coverage — 3–5 days.







