How to Add Copy to Clipboard Buttons to Code Blocks in Hugo


I recently added copy-to-clipboard buttons to my code blocks on my Hugo site (this one you’re reading currently).

You can check out at how it looks by hovering over any code blocks in this article.

I came across Danny Guo’s article on this topic. I’d highly recommend checking out that article for an in-depth explanation of what’s going on. This article contains slight tweaks to the proposed solution.

Understanding Hugo code blocks

The first step is to understand that Hugo generates its code blocks using pre and code tags.

With syntax highlighting, the pre tags will be wrapped in a div with the class highlight.

<div class="highlight">
  <pre>
    <code>some code...</code>
  </pre>
</div>

Creating the copy-to-clipboard buttons with JavaScript

In order to target these code blocks and add a copy-to-clipboard button for each block, we need to do a few things:

  1. Look for pre > code elements in the DOM
  2. Create a button that will trigger a copy operation
  3. Append the button directly before the pre tag

As a result, each block will have this structure: div.highlight > button.clipboard-button > pre > code.

In my Hugo project, I store this script in assets/js/clipboard.js.

const addCopyButtons = (clipboard) => {
  // 1. Look for pre > code elements in the DOM
  document.querySelectorAll("pre > code").forEach((codeBlock) => {
    // 2. Create a button that will trigger a copy operation
    const button = document.createElement("button");
    button.className = "clipboard-button";
    button.type = "button";
    button.innerHTML = svgCopy;
    button.addEventListener("click", () => {
      clipboard.writeText(codeBlock.innerText).then(
        () => {
          button.blur();
          button.innerHTML = svgCheck;
          setTimeout(() => (button.innerHTML = svgCopy), 2000);
        },
        (error) => (button.innerHTML = "Error")
      );
    });
    // 3. Append the button directly before the pre tag
    const pre = codeBlock.parentNode;
    pre.parentNode.insertBefore(button, pre);
  });
};

Next, in the same file, we need to trigger the above addCopyButtons() and pass in the clipboard object to execute the actual copy-to-clipboard function.

Some browsers natively support the Clipboard API, which we will use when available.

For browsers that don’t support the Clipboard API, we’ll add another script tag to load clipboard-polyfill.

if (navigator && navigator.clipboard) {
  addCopyButtons(navigator.clipboard);
} else {
  const script = document.createElement("script");
  script.src =
    "https://cdnjs.cloudflare.com/ajax/libs/clipboard-polyfill/2.7.0/clipboard-polyfill.promise.js";
  script.integrity = "sha256-waClS2re9NUbXRsryKoof+F9qc1gjjIhc2eT7ZbIv94=";
  script.crossOrigin = "anonymous";
  script.onload = () => addCopyButtons(clipboard);
  document.body.appendChild(script);
}

You might have noticed above that we use a copy icon (svgCopy) and check mark (svgCheck) to visually indicate the status of the copy operation.

const svgCopy =
  '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>';
const svgCheck =
  '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>';

Loading the JavaScript file

Once we have the script to create the button for each code block on a page, we want to conditionally load this script whenever the current HTML page contains at least one code block, or a pre tag.

This will go wherever we’re loading the rest of our .js files (preferably at the end of the body, since this if block checks the page’s HTML).

In my Hugo project, I append these lines to layouts/partials/_shared/js.html.

{{ if (findRE "<pre" .Content 1) }}
    <script src="/js/clipboard.js"></script>
{{ end }}

Styling the copy-to-clipboard buttons

Next, we can style our button to only show on hover with smooth transitions.

In my Hugo project, I store this stylesheet in assets/css/clipboard.css.

.clipboard-button {
  position: absolute;
  right: 0;
  padding: 2px 7px 5px 7px;
  margin: 5px;
  color: #767676;
  border-color: #767676;
  background-color: #ededed;
  border: 1px solid;
  border-radius: 6px;
  font-size: 0.8em;
  z-index: 1;
  opacity: 0;
  transition: 0.1s;
}
.clipboard-button > svg {
  fill: #767676;
}
.clipboard-button:hover {
  cursor: pointer;
  border-color: #696969;
  background-color: #e0e0e0;
}
.clipboard-button:hover > svg {
  fill: #696969;
}
.clipboard-button:focus {
  outline: 0;
}
.highlight {
  position: relative;
}
.highlight:hover > .clipboard-button {
  opacity: 1;
  transition: 0.2s;
}

Loading the CSS

Finally, we can load this stylesheet for the copy-to-clipboard button just like we do with the JavaScript file.

In my Hugo project, I append these lines to layouts/partials/_shared/head.html.

{{ if (findRE "<pre" .Content 1) }}
    <link rel="stylesheet" href="/css/clipboard.css">
{{ end }}