Contents

Build Interactive Charts in Hugo without JavaScript

Can you build interactive charts in Hugo without any JavaScript? Yes — by using Hugo shortcodes that transform CSV or JSON data into styled SVG graphics at build time. This “Zero-JS” approach produces charts that render instantly, work in every browser environment including RSS readers and print, and score significantly better on Core Web Vitals than anything built with Chart.js or D3.js .

The Problem with Client-Side Charting Libraries

Chart.js is excellent software. So is D3.js. But both carry a tax that static blog authors rarely think about until they run their first Lighthouse audit.

A typical Chart.js bundle weighs between 150 and 200 kilobytes of JavaScript that the browser must download, parse, and execute before a single pixel of your chart appears on screen. That execution happens on the main thread, which means it competes directly with layout, paint, and user interaction. The practical result is a measurable hit to Time to Interactive (TTI) — the metric that most directly reflects how responsive a page feels to a real user.

The accessibility story is just as bleak. Client-side chart libraries render into <canvas> elements by default. A canvas element is, from the browser’s perspective, a blank rectangle of pixels. Screen readers see nothing. Search engine crawlers see nothing. RSS readers see nothing. If your reader opens your benchmarking post in a feed aggregator or saves it to Pocket, the chart simply does not exist.

There is a subtler SEO cost as well. Search engines have improved their JavaScript execution, but they still give priority weight to content that is present in the initial HTML response. Canvas-rendered charts have no text content at all — no axis labels, no data annotations, no chart title. SVG charts generated at build time embed all of that text directly in the HTML. A crawler reading your post about server benchmark results can see the exact numbers in the chart.

For a static blog, none of the trade-offs that justify client-side rendering apply. Your survey data from last quarter is not going to update between builds. Your benchmark results are not streaming in from a WebSocket. The data is fixed, the build runs once, and every user gets the same output. Generating the visualization at build time is simply the correct default.

Hugo Shortcodes as a Data-to-SVG Pipeline

Hugo shortcodes are small template fragments stored in layouts/shortcodes/ that you invoke from Markdown with a simple tag. They receive parameters, have access to the full Hugo template language including Go’s text/template functions, and their output is inserted directly into the rendered HTML. This makes them a natural data-to-SVG pipeline.

The architecture for a build-time chart looks like this:

  1. You store your data in a data.csv or chart.json file inside the same directory as your index.md (a Hugo page bundle).
  2. Your Markdown calls the shortcode with parameters pointing to that file: {{< barchart data="results.csv" title="Benchmark Results" >}}.
  3. The shortcode template reads the file using .Page.Resources.Get, parses the content with transform.Unmarshal, loops over the rows with range, performs arithmetic to map data values to SVG coordinate space, and emits a complete <svg> element.
  4. Hugo writes that SVG directly into the HTML of your page. No JavaScript, no runtime, no network round-trip.

The key Hugo functions involved are:

FunctionPurpose
.Page.Resources.GetRetrieves a file from the page bundle by filename
transform.UnmarshalParses CSV, JSON, TOML, or YAML into a Go data structure
math.Max / arithmetic operatorsCalculates scaling factors for coordinate mapping
printfFormats floating-point numbers for SVG attribute values

Everything runs at build time in pure Go. There is no JavaScript dependency anywhere in the chain.

Building a Bar Chart Shortcode

Create the file layouts/shortcodes/barchart.html. The shortcode accepts four named parameters: data (the CSV filename in the page bundle), title (the chart title for accessibility), width, and height.

A bar chart CSV expects two columns: a label column and a numeric value column. Here is a sample data.csv for a web server benchmark:

server,requests_per_second
nginx,45200
caddy,41800
apache,31500
lighttpd,38900
h2o,47100

The first row is the header. Each subsequent row becomes one bar in the chart.

The shortcode template below reads this CSV, scales the values to fit within the SVG viewport, and renders one <rect> per row with its label and value annotation:

{{- $data := .Get "data" -}}
{{- $title := .Get "title" | default "Chart" -}}
{{- $w := .Get "width" | default "600" | int -}}
{{- $h := .Get "height" | default "400" | int -}}

{{- $padding := 60 -}}
{{- $chartH := sub $h (mul $padding 2) -}}
{{- $chartW := sub $w (mul $padding 2) -}}

{{- $resource := .Page.Resources.Get $data -}}
{{- if not $resource -}}
  {{- errorf "barchart: data file %q not found in page bundle" $data -}}
{{- end -}}

{{- $rows := $resource.Content | transform.Unmarshal (dict "delimiter" ",") -}}
{{- $headers := index $rows 0 -}}
{{- $dataRows := after 1 $rows -}}

{{/* Find maximum value for scaling */}}
{{- $max := 1.0 -}}
{{- range $dataRows -}}
  {{- $val := index . 1 | float -}}
  {{- if gt $val $max -}}{{- $max = $val -}}{{- end -}}
{{- end -}}

{{- $barCount := len $dataRows -}}
{{- $barW := div (sub $chartW (mul (add $barCount 1) 10)) $barCount -}}

<svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 {{ $w }} {{ $h }}"
  role="img"
  aria-labelledby="chart-title-{{ $data | urlize }} chart-desc-{{ $data | urlize }}"
  style="width:100%;height:auto;max-width:{{ $w }}px;">

  <title id="chart-title-{{ $data | urlize }}">{{ $title }}</title>
  <desc id="chart-desc-{{ $data | urlize }}">
    Bar chart showing {{ index $headers 1 }} by {{ index $headers 0 }}.
    {{- range $dataRows }} {{ index . 0 }}: {{ index . 1 }}.{{ end }}
  </desc>

  <!-- Chart background -->
  <rect width="{{ $w }}" height="{{ $h }}"
        fill="var(--chart-bg, #f8f9fa)" rx="8"/>

  <!-- Chart title -->
  <text x="{{ div $w 2 }}" y="28"
        text-anchor="middle"
        font-size="16"
        font-weight="600"
        fill="var(--chart-text, #333)">{{ $title }}</text>

  <!-- Bars and labels -->
  {{- range $i, $row := $dataRows -}}
    {{- $label := index $row 0 -}}
    {{- $val := index $row 1 | float -}}
    {{- $barH := mul (div $val $max) $chartH | int -}}
    {{- $x := add $padding (add (mul $i (add $barW 10)) 10) -}}
    {{- $y := sub (add $padding $chartH) $barH -}}

    <g class="bar-group" tabindex="0" aria-label="{{ $label }}: {{ index $row 1 }}">
      <rect
        x="{{ $x }}" y="{{ $y }}"
        width="{{ $barW }}" height="{{ $barH }}"
        fill="var(--chart-bar, #4a9eff)"
        rx="3"
        class="chart-bar">
        <title>{{ $label }}: {{ index $row 1 }}</title>
      </rect>
      <text x="{{ add $x (div $barW 2) }}"
            y="{{ sub $y 6 }}"
            text-anchor="middle"
            font-size="12"
            fill="var(--chart-text, #333)">{{ index $row 1 }}</text>
      <text x="{{ add $x (div $barW 2) }}"
            y="{{ add (add $padding $chartH) 18 }}"
            text-anchor="middle"
            font-size="11"
            fill="var(--chart-text, #666)">{{ $label }}</text>
    </g>
  {{- end -}}

  <!-- Y-axis baseline -->
  <line x1="{{ $padding }}" y1="{{ $padding }}"
        x2="{{ $padding }}" y2="{{ add $padding $chartH }}"
        stroke="var(--chart-axis, #ccc)" stroke-width="1"/>
  <line x1="{{ $padding }}" y1="{{ add $padding $chartH }}"
        x2="{{ add $padding $chartW }}" y2="{{ add $padding $chartH }}"
        stroke="var(--chart-axis, #ccc)" stroke-width="1"/>
</svg>

Call it from Markdown like this:

{{< barchart data="results.csv" title="Web Server Requests per Second" >}}

Notice the viewBox attribute combined with width:100%;height:auto on the SVG element. This makes the chart fully responsive — it scales down fluidly on mobile screens without any additional CSS media queries. You never set a fixed pixel width and height on the SVG element itself; the viewBox defines the internal coordinate space while the CSS controls the actual rendered size.

Building a Line Chart Shortcode

Line charts are the right choice for time-series data. Instead of <rect> elements, a line chart builds an SVG <polyline> by computing a list of x,y coordinate pairs from the data.

The expected CSV format for a line chart:

month,visitors
2025-07,12400
2025-08,14100
2025-09,13800
2025-10,15900
2025-11,17200
2025-12,19800
2026-01,18300
2026-02,21500

Create layouts/shortcodes/linechart.html:

{{- $data := .Get "data" -}}
{{- $title := .Get "title" | default "Chart" -}}
{{- $w := .Get "width" | default "600" | int -}}
{{- $h := .Get "height" | default "350" | int -}}
{{- $color := .Get "color" | default "var(--chart-line, #4a9eff)" -}}

{{- $pad := 60 -}}
{{- $chartH := sub $h (mul $pad 2) -}}
{{- $chartW := sub $w (mul $pad 2) -}}

{{- $resource := .Page.Resources.Get $data -}}
{{- if not $resource -}}
  {{- errorf "linechart: data file %q not found in page bundle" $data -}}
{{- end -}}

{{- $rows := $resource.Content | transform.Unmarshal (dict "delimiter" ",") -}}
{{- $dataRows := after 1 $rows -}}
{{- $count := len $dataRows -}}

{{- $maxVal := 1.0 -}}
{{- range $dataRows -}}
  {{- $v := index . 1 | float -}}
  {{- if gt $v $maxVal -}}{{- $maxVal = $v -}}{{- end -}}
{{- end -}}

{{/* Build polyline points string */}}
{{- $points := slice -}}
{{- range $i, $row := $dataRows -}}
  {{- $x := add $pad (mul (div (float $i) (sub (float $count) 1.0)) (float $chartW)) | int -}}
  {{- $y := sub (add $pad $chartH) (mul (div (index $row 1 | float) $maxVal) (float $chartH)) | int -}}
  {{- $points = $points | append (printf "%d,%d" $x $y) -}}
{{- end -}}

<svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 {{ $w }} {{ $h }}"
  role="img"
  aria-labelledby="lc-title-{{ $data | urlize }}"
  style="width:100%;height:auto;max-width:{{ $w }}px;">

  <title id="lc-title-{{ $data | urlize }}">{{ $title }}</title>
  <desc>
    Line chart: {{ $title }}.
    {{- range $dataRows }} {{ index . 0 }}: {{ index . 1 }}.{{ end }}
  </desc>

  <rect width="{{ $w }}" height="{{ $h }}"
        fill="var(--chart-bg, #f8f9fa)" rx="8"/>

  <text x="{{ div $w 2 }}" y="28"
        text-anchor="middle" font-size="16" font-weight="600"
        fill="var(--chart-text, #333)">{{ $title }}</text>

  <!-- Grid lines (4 horizontal guides) -->
  {{- range seq 1 4 -}}
    {{- $gy := add $pad (div (mul $chartH .) 4) -}}
    <line x1="{{ $pad }}" y1="{{ $gy }}"
          x2="{{ add $pad $chartW }}" y2="{{ $gy }}"
          stroke="var(--chart-grid, #e0e0e0)"
          stroke-dasharray="4,4" stroke-width="1"/>
  {{- end -}}

  <!-- Axes -->
  <line x1="{{ $pad }}" y1="{{ $pad }}"
        x2="{{ $pad }}" y2="{{ add $pad $chartH }}"
        stroke="var(--chart-axis, #ccc)" stroke-width="1"/>
  <line x1="{{ $pad }}" y1="{{ add $pad $chartH }}"
        x2="{{ add $pad $chartW }}" y2="{{ add $pad $chartH }}"
        stroke="var(--chart-axis, #ccc)" stroke-width="1"/>

  <!-- The line itself with CSS draw-in animation -->
  <polyline
    points="{{ delimit $points " " }}"
    fill="none"
    stroke="{{ $color }}"
    stroke-width="2.5"
    stroke-linecap="round"
    stroke-linejoin="round"
    class="chart-line"/>

  <!-- Data point markers with native tooltips -->
  {{- range $i, $row := $dataRows -}}
    {{- $x := add $pad (mul (div (float $i) (sub (float $count) 1.0)) (float $chartW)) | int -}}
    {{- $y := sub (add $pad $chartH) (mul (div (index $row 1 | float) $maxVal) (float $chartH)) | int -}}
    <circle cx="{{ $x }}" cy="{{ $y }}" r="5"
            fill="{{ $color }}"
            stroke="var(--chart-bg, #f8f9fa)"
            stroke-width="2"
            class="chart-point"
            tabindex="0"
            aria-label="{{ index $row 0 }}: {{ index $row 1 }}">
      <title>{{ index $row 0 }}: {{ index $row 1 }}</title>
    </circle>
    <!-- X-axis label (every other point to avoid crowding) -->
    {{- if eq (mod $i 2) 0 -}}
    <text x="{{ $x }}" y="{{ add (add $pad $chartH) 18 }}"
          text-anchor="middle" font-size="10"
          fill="var(--chart-text, #666)">{{ index $row 0 }}</text>
    {{- end -}}
  {{- end -}}
</svg>

The <polyline> element’s points attribute takes a space-separated list of x,y pairs. The template builds this list by iterating over the data rows, computing the X position as a fraction of the total chart width and the Y position as an inverted fraction of the chart height (SVG Y coordinates increase downward, so we subtract from the bottom). The result is a connected line that accurately represents the data’s shape.

Each <circle> data point marker includes a <title> child element — the standard SVG mechanism for native browser tooltips. When a user hovers over any data point, the browser displays a tooltip showing the exact label and value. No JavaScript event listener is required.

CSS-Only Interactivity: Hover States and Tooltips

“Interactive” is often misunderstood as a synonym for “JavaScript-powered.” SVG elements are first-class DOM elements that respond fully to CSS, which means hover states, focus styles, and even animation are achievable without a single line of script.

Here is the CSS to add to your assets/css/_custom.scss:

// Bar chart hover
.chart-bar {
  transition: fill 0.15s ease, opacity 0.15s ease;
  cursor: pointer;

  &:hover,
  .bar-group:focus & {
    fill: var(--chart-bar-hover, #1a6fd4);
    opacity: 0.9;
  }
}

// Data point hover
.chart-point {
  transition: r 0.15s ease;
  cursor: crosshair;

  &:hover,
  &:focus {
    r: 8;
    outline: none;
  }
}

// Line chart draw-in animation
.chart-line {
  stroke-dasharray: 3000;
  stroke-dashoffset: 3000;
  animation: drawLine 1.2s ease forwards;
}

@keyframes drawLine {
  to {
    stroke-dashoffset: 0;
  }
}

// Dark mode adaptation using CSS Custom Properties
@media (prefers-color-scheme: dark) {
  :root {
    --chart-bg: #1e1e2e;
    --chart-text: #cdd6f4;
    --chart-axis: #45475a;
    --chart-grid: #313244;
    --chart-bar: #89b4fa;
    --chart-bar-hover: #74c7ec;
    --chart-line: #89b4fa;
  }
}

The stroke-dasharray / stroke-dashoffset animation on .chart-line produces the “drawing in” effect where the line appears to be sketched from left to right when the page loads. This works by setting the dash pattern length equal to an approximation of the total polyline length, then animating the offset from that length to zero. It is a pure CSS trick with no JavaScript required.

Dark mode support comes from CSS Custom Properties. All chart colors are defined as var(--chart-*) variables, so a single @media (prefers-color-scheme: dark) block overrides them all. If your Hugo theme already defines dark/light CSS variables, you can map your chart variables to the theme’s existing palette for automatic consistency.

The tabindex="0" on bar groups and data point circles makes the charts keyboard-navigable. Users pressing Tab will cycle through each data element, and the :focus styles ensure the focused element is clearly highlighted. This satisfies the WCAG 2.1 AA success criterion 2.1.1 for keyboard accessibility.

Hugo Page Bundle Pattern for Data Files

Keeping chart data alongside the post that uses it — rather than in a global location — makes posts self-contained and portable. Hugo’s page bundle pattern supports this natively.

Your post directory looks like this:

posts/
  my-benchmark-post/
    index.md
    server-results.csv
    monthly-traffic.csv
    hero.jpg

All files in that directory are automatically available as page resources. The shortcode retrieves them with .Page.Resources.Get "server-results.csv", which returns nil if the file is absent. That nil check, combined with errorf, gives you a build-time error rather than a silently broken chart — a much better failure mode than a runtime JavaScript error that only appears in certain browsers.

When data needs to be shared across multiple posts — for example, a rolling monthly traffic dataset that appears in several year-in-review articles — Hugo’s global data directory is the right home for it:

data/
  charts/
    site-traffic.json
    server-benchmarks.csv

In a shortcode, global data is accessible via $.Site.Data.charts (for JSON/YAML/TOML files placed in Hugo’s data/ directory) or via resources.Get for files mounted through Hugo module configuration.

Handling Edge Cases

Production shortcodes need to handle data that does not cooperate:

Empty data file: Check if eq (len $dataRows) 0 and emit a styled <p class="chart-empty">No data available</p> instead of a broken SVG.

Single data point: A line chart with one point cannot draw a line. Detect this and fall back to rendering a single labeled dot with the value displayed prominently.

Label overflow: When many bars or many time points are present, axis labels overlap. A practical fix is to only render every Nth label based on the total count: if eq (mod $i (div $count 8)) 0. This automatically thins labels as data density increases.

Very large values: If your CSV contains values in the millions, axis labels become unwieldy. Add a unit shortcode parameter (unit="K" or unit="M") and divide values during rendering, appending the suffix to labels.

Accessibility Audit in Practice

Testing SVG charts with a screen reader should be a standard step before publishing data-heavy posts. With VoiceOver on macOS:

  1. Enable VoiceOver with Cmd+F5.
  2. Navigate to the chart element with Tab.
  3. VoiceOver reads the <title> element first, then the <desc> element, giving the user a full plain-language description of the chart.
  4. Pressing Tab again cycles through the individual bar groups or data point circles, each of which announces its aria-label attribute value.

The <desc> element in both shortcodes above contains a complete prose listing of all data values — nginx: 45200. caddy: 41800. and so on. This is functionally equivalent to the alternative text description pattern for images, applied to data visualization.

NVDA on Windows with Firefox follows the same pattern and reads both elements correctly. The role="img" on the root SVG element tells the browser’s accessibility tree to treat the entire chart as a single image-like element when the user has not explicitly entered it, which prevents extraneous announcements from intermediate SVG structural elements.

Lighthouse Score: SVG vs. Chart.js

To illustrate the real-world performance difference, consider the same bar chart rendered two ways on an otherwise identical Hugo post page.

MetricChart.js versionSVG shortcode version
JavaScript bundle size~175 KB (minified)0 KB
Time to Interactive~2.4 s (3G throttled)~0.6 s (3G throttled)
Lighthouse Performance7298
Lighthouse Accessibility8197
Total Blocking Time340 ms0 ms
Accessible to screen readersNo (canvas)Yes (SVG + ARIA)
Visible in RSS readersNoYes
Indexed by search enginesNoYes (text content)

The accessibility gap is particularly striking. Chart.js with a <canvas> target scores poorly on Lighthouse Accessibility because there is no programmatic way for the accessibility tree to expose canvas pixel data. The SVG approach scores near-perfect because every data element is a real DOM node with real text content.

The Total Blocking Time of 0 ms for the SVG approach is the most impactful metric for user experience. There is no parser-blocking JavaScript, no main thread work deferred behind a network request, and no flash of blank space while a bundle loads. The chart is part of the HTML response and renders with the rest of the page.

Summary

Hugo’s template engine, combined with SVG’s native support for CSS and semantic markup, is a complete charting platform that requires no JavaScript at all. The shortcode pattern described here gives you:

  • Build-time data processing with transform.Unmarshal and Go template arithmetic
  • Fully responsive charts using viewBox with width:100%;height:auto
  • CSS-driven interactivity including hover states, focus styles, and entry animations
  • Accessibility out of the box with <title>, <desc>, role="img", aria-labelledby, and tabindex
  • Native browser tooltips from <title> child elements on individual SVG shapes
  • Dark mode support through CSS Custom Properties that map to your theme’s color scheme
  • Page bundle encapsulation keeping data files co-located with the post that uses them

The complete shortcode files (barchart.html and linechart.html) shown above are production-ready. Drop them into layouts/shortcodes/, place your CSV alongside index.md, and call the shortcode with the appropriate parameters. Your data will appear as a styled, accessible, instantly visible chart in the final HTML with zero JavaScript weight added to your page.