Hugo’s code highlighting (powered by Chroma) is excellent. However, by default, some Hugo themes lack two crucial usability features for code blocks: a “Copy to Clipboard” button and a descriptive title bar showing exactly the file we are editing, or the programming language in use.

In this post, I will walk you through implementing these features using JavaScript and CSS in a theme-agnostic way. We’ll handle both explicitly titled blocks and automatically inferring titles from language declarations, or falling back to a generic title.

Quick workflow

  1. JavaScript logic: Create a new static/js/code-block.js file to dynamically create the title bar and copy button.
  2. CSS styling: Create a new assets/css/code-block.css file to style the title bar and copy button, ensuring consistent alignment.
  3. Load files in HTML layouts: Link the new .js and .css files in Hugo theme’s base HTML layout files.
  4. Configure Hugo for code block titles: Ensure our config.toml is set up to pass title attributes to code blocks.

Step 1: JavaScript logic

First, create a new JavaScript file assets/js/code-block.js. This script will run after a page is loaded, find all code blocks, and then dynamically inject the title bar and copy button into each one.

document.addEventListener('DOMContentLoaded', (event) => {
    // Select all <pre> blocks on the page.
    // We will determine their parent context inside the loop.
    document.querySelectorAll('pre').forEach((preBlock) => {
        // Ensure there's a <code> element inside <pre> block.
        const codeElement = preBlock.querySelector('code');
        if (!codeElement) {
            return; // Skip if no code element found inside pre
        }

        // Determine actual container to prepend the header to.
        // Hugo's Chroma highlighter wraps <pre> in <div class="highlight">.
        // For plain code blocks (no language specified), the <pre> itself is the container.
        const containerBlock = preBlock.closest('.highlight') || preBlock;

        // Prevent adding multiple headers or if an element matches multiple selectors 
        if (containerBlock.querySelector('.code-header')) {
            return;
        }

        // Create the header container div
        const header = document.createElement('div');
        header.className = 'code-header';

        let titleText = null;

        // --- Logic for determining the title text ---

        // 1. Check for explicit 'title' attribute on main container block.
        // This is set like ```go {title="main.go"}
        if (containerBlock.hasAttribute('title')) {
            titleText = containerBlock.getAttribute('title');
        }

        // 2. If no explicit title, extract language from  <code> element's classes. Chroma typically adds a class like 'language-go' or 'language-bash'.
        if (!titleText) {
            const languageClass = Array.from(codeElement.classList).find(
                cls => cls.startsWith('language-')
            );
            if (languageClass) {
                // Extract language name (e.g., 'go' from 'language-go')
                titleText = languageClass.substring('language-'.length);
            }
        }
        
        // 3. Fallback to default title if no explicit title or language was found
        if (!titleText) {
             titleText = 'code'; 
        }
        
        // 4. Create title span and set text
        const titleSpan = document.createElement('span');
        titleSpan.className = 'code-title';
        if (titleText) {
            titleSpan.innerText = titleText;
        }

        // --- Create Copy button ---
        const copyButton = document.createElement('button');
        copyButton.className = 'copy-code-button';
        // SVG icon for "Copy" from Bootstrap Icons
        copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg> Copy`;

        header.appendChild(titleSpan);
        header.appendChild(copyButton);
        
        // Prepend the entire header (title + button) to the determined container
        containerBlock.prepend(header);

        // Add click event listener to Copy button
        copyButton.addEventListener('click', () => {
            // Get plain text content of the <code> block.
            const code = preBlock.querySelector('code').innerText;
            navigator.clipboard.writeText(code); // Copy to clipboard

            // Provide visual feedback to the user
            copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg" viewBox="0 0 16 16"><path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022z"/></svg> Copied!`;
            setTimeout(() => {
                // Revert button text after a short delay
                copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg> Copy`;
            }, 2000);
        });
    });
});

How it works

  • Detects title dynamically:

    • Prioritizes explicitly-set titles ({title="My Title"}).
    • Since Hugo’s syntax highlighter (Chroma) typically adds a class named language-LANGUAGE_NAME (e.g., language-python) to the <code> element inside the .highlight container, we can extract the language name from that class if no explicit title is provided.
    • Uses a generic “code” title if neither is available, ensuring all code blocks get a header and copy button. Hugo’s default Markdown parser (Goldmark) and Chroma often render these blocks differently than blocks with an explicit language. Such plain code fence does not generate the .highlight wrapper but a basic <pre><code>...</code></pre>. Our script checks for either the .highlight wrapper or a basic <pre> tag containing a <code> block, and applies the title bar/copy button to whatever container is present.
  • Detects container:

    The preBlock.closest('.highlight') || preBlock line identifies the correct parent (.highlight div for highlighted blocks, or the <pre> itself for plain blocks), preventing double headers and ensuring the header is attached correctly.

  • User Feedback: The copy button provides visual feedback when clicked.

Things to watch out for:

  • Script execution order: Ensure this script runs after the DOM is fully loaded, which DOMContentLoaded handles. We will take care of this by loading the script at the end of <body> in Step 3. If we load it deferring, we will need to make sure no other script relies on these elements being present immediately.

Step 2: CSS styling

Next, create a new CSS file assets/css/code-block.css. This CSS styles the title bar, the title text, and the copy button, ensuring consistent appearance and alignment across all code blocks.

/* main container for code blocks generated by Hugo */
.highlight {
    position: relative;
    margin: 1rem 0; /* Consistent external margin for the entire block */
    border-radius: 8px;
    overflow: hidden; /* Ensures child elements (like the header) respect the border-radius */
    background-color: #272822; /* A dark background for the code block */
}

/* standalone <pre> blocks that are not inside a highlight wrapper, typically for code fences without a specified language. */
pre:not(.highlight pre) {
    position: relative;
    margin: 1rem 0 
    border-radius: 8px;
    overflow: hidden;
    padding: 0; /* Remove default padding from the <pre> itself */
    background-color: #272822; /* Consistent dark background */
}

/* <pre> tag inside a .highlight div, with its margin and padding removed */
.highlight pre {
    margin: 0; /* Remove default margin */
    padding: 1em; /* Add padding to the actual code text area */
    background-color: transparent; /* Inherit from .highlight parent */
    color: #f8f8f2; /* Light text color for code */
    font-family: monospace; /* Monospaced font for code */
}

/* standalone <pre> block (not inside .highlight) */
pre:not(.highlight pre) code {
    display: block; /* Ensure <code> element takes up full width */
    padding: 1em; /* Add padding to actual code text area */
    color: #f8f8f2; /* Light text color for code */
    font-family: monospace; /* Monospaced font for code */
}

/* header bar that contains the title and copy button */
.code-header {
    display: flex;
    justify-content: space-between; /* Pushes title to left, button to right */
    align-items: center; /* Vertically aligns title and button */
    background-color: #3a3a3a; /* Darker grey for the header */
    color: #e0e0e0; /* Light text for header */
    padding: 0.5rem 1rem;
    font-family: sans-serif;
    font-size: 0.9em;
    border-bottom: 1px solid #555; /* Subtle separator from code */ 
}

/* title text within the header */
.code-header .code-title {
    font-weight: bold;
}

/* copy button */
.copy-code-button {
    display: flex;
    align-items: center;
    gap: 0.5em; /* Space between SVG icon and text */
    background-color: #555; /* Button background */
    color: #fff; /* Button text color */
    border: none;
    padding: 6px 12px;
    border-radius: 5px;
    cursor: pointer;
    font-size: 0.8em;
    transition: background-color 0.2s ease-in-out; /* Smooth hover effect */
}

.copy-code-button:hover {
    background-color: #6a6a6a; /* Lighter background on hover */
}

.copy-code-button svg {
    /* Adjust SVG icon spacing if needed */
    margin-right: 4px;
}

How this works

  • Unified appearance: The CSS ensures that code blocks, whether highlighted by Chroma (within .highlight) or plain (no language or title), have a consistent external margin, border-radius, and background color.
  • Precise internal spacing: Padding is applied to the actual pre or code elements where the text resides, not the outer container, ensuring text alignment with the header.

Things to watch out for:

  • Theme conflicts: While this CSS is designed to be robust, some Hugo themes might have very specific and high-priority rules. If you notice any unexpected styling, use your browser’s developer tools to inspect the elements and identify conflicting CSS rules.

Step 3: Load script and CSS

For the JavaScript and CSS to take effect, we need to include them in our Hugo theme’s layouts. The best place is usually in the for CSS, and at the end of the for JavaScript.

For both cases, we will use Hugo’s asset pipeline to process and transform files in assets folder. Files of the same type in assets can be processed together for:

  • Bundling and minification: For example, bundle all assets/js/ files together and minify the resulting bundle using Hugo’s asset pipeline. This ensures that our modular assets files are combined to produce a single output file, reducing the number of HTTP requests and improving page load times.
  • Fingerprinting: Hugo can automatically append a fingerprint (hash) to the filename for versioning, which helps with cache busting and ensures that browsers load the latest version of the file.

To use the asset pipeline, just make sure to store all files in the assets/js and assets/css folders. When your custom JavaScript file is in the root folder’s assets/js directory and the theme’s main.js is in the theme folder’s assets/js directory, Hugo’s asset pipeline can still find and bundle both files. Doing so will consolidate all our custom CSS and JavaScript files in one place, making it easier to manage and maintain them. Reserve the static folder for files that should be served directly by the web server without any processing by Hugo.

Edit layouts/partial/head.html to load our CSS files:

{{- $styles := resources.Get "css/main.css" -}}
{{- $code_styles := resources.Get "css/code-block.css" -}} 

{{- /* Combine the files, giving the output file a unique name */ -}}
{{- $all_styles := (slice $styles $code_styles ) | resources.Concat "css/bundle.css" -}}

{{- $all_styles := $all_styles | resources.Minify | resources.Fingerprint -}}

<link rel="stylesheet" href="{{ $all_styles.RelPermalink }}">

Then, before the closing tag in layouts/_default/baseof.html, load our custom JavaScript with the theme’s default main.js:

{{ $jsFiles := resources.Match "js/**.js" }}
{{ $jsBundle := $jsFiles | resources.Concat "js/bundle.js" | minify | fingerprint "sha256" }}
<script src="{{ $jsBundle.RelPermalink }}" integrity="{{ $jsBundle.Data.Integrity }}"></script>

How this works

  • Correct loading order:
    • CSS in the <head> ensures styles are applied before content is rendered, preventing a flash of unstyled content.
    • JavaScript at the end of the <body> ensures the DOM is fully available when the script tries to find and modify elements. Since the bundle is large (including multiple js files), loading it at the end of the <body> also allows the browser to display the text and layout instantly, preventing the script from blocking the initial render of visible content.
  • relURL: Hugo’s relURL function correctly resolves the path to our static files, even if our site is hosted in a subdirectory.
  • Bundling: resources.Match "js/**.js" will find all JavaScript files in the assets/js directory and its subdirectories in both the root and theme folders. resources.Concat combine all matched files into a single file named js/bundle.js. We then compress the combined file with minify and generate a unique hash for caching purposes with resources.Fingerprint "sha256".

Things to watch out for:

  • Script bundle order: If the order of scripts matters (e.g., code-block.js depends on main.js), we will need to explicitly define the order using the slice approach:

    {{ $jsBundle := slice (resources.Get "js/main.js") (resources.Get "js/code-block.js") | resources.Concat "js/bundle.js" | minify | fingerprint "sha256" }}
    

    This ensures that main.js is included before code-block.js in the bundle. In our example above, this is not applicable. But it’s something to keep in mind.

Step 4: Configure Hugo for Code Block Titles

For the explicit title functionality ({title="main.go"}) to work, we need to ensure Hugo’s Markdown renderer (Goldmark) is configured to pass these attributes to the HTML output.

Modify config.toml (or config.yaml/config.json) with the following:

[markup]
  [markup.highlight]
    # Set this to true to allow passing attributes to code blocks, specifically enables `{title="..."}`
    codeFences = true
    # set preferred Chroma style 
    style = "github-dark" 

How this works

  • Enables attribute parsing: Setting codeFences = true under highlight explicitly enables attributes on code fences. Without these, {title="..."} simply won’t be passed to the HTML, and our JavaScript won’t find it.

Things to watch out for:

  • Existing markup configuration: If you already have [markup] or [markup.goldmark] sections, merge these settings carefully rather than overwriting them.

Step 5: Add title in markdown

Now when we write a code block in our Markdown files, we can simply decorate our code block with a title:

```javascript {title="code-block.js"}`
document.addEventListener...
```

This will produce a code block with “code-block.js” in the title bar and a working copy button. If you omit the {title="..."} part, “javascript” will be the title. If you do not specify the language in the code block, a generic “code” title will be generated.

Appendix: Chroma syntax highlight styles

Hugo uses the Chroma syntax highlighter, which is extremely fast and supports a wide variety of styles . You can configure your site’s syntax highlighting style directly in your Hugo configuration file (hugo.toml or config.toml).

The setting goes under the markup table:

[markup]
  [markup.highlight]
    style = "monokai"
    noClasses = false

Here are some of the most popular styles available. Just replace "monokai" in the example above with any of these names:

  • gruvbox: A retro-style theme available in both dark and light variants (gruvbox or gruvbox-light).
  • solarized-dark / solarized-light: Very popular themes designed for low contrast and eye comfort.
  • github / github-dark: Mimics the syntax highlighting found on GitHub.
  • one-dark: Based on the Atom editor’s default dark theme.
  • nord: A clean, arctic-inspired theme with muted colors.
  • catppuccin-macchiato: A soothing pastel theme.
  • native: A colorful style that is great for presentations.