Giving Hugo code blocks a copy button that still works from the keyboard

Published:

I had wanted to build this for a long time, but I never really learned JavaScript properly, so every attempt ended with me stitching together half the behavior I wanted and then giving up. There are plenty of write-ups out there, but I never found one that really considered accessibility. MDN does show a keyboard-friendly copy button that works with Enter and Space, but the code felt so abstract that I could not make sense of it.

Then, as the saying goes, slacking off is the first productive force. While I was happily not doing what I was supposed to be doing, I suddenly remembered this problem again. Two hours of tinkering later, I had something that actually worked. Procrastination is a dangerous process.

First, the result:

hello world

All file paths below are relative to the root of the Hugo site.

The copy button

Create a JavaScript file at ./themes/diary/static/js/clipboard.js:

// buttons
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" 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>';

// add button function
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.title = "Copy";
    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 after the pre tag (.highlight > pre > button > code)
    const pre = codeBlock.parentNode;
    pre.parentNode.insertBefore(button, pre.nextSibling);
  });
};

// trigger function
if (navigator && navigator.clipboard) {
  addCopyButtons(navigator.clipboard);
} else {
  const script = document.createElement("script");
  script.src =
    "https://cdnjs.cloudflare.com/ajax/libs/clipboard-polyfill/3.0.3/promise/clipboard-polyfill.promise.min.js";
  script.integrity = "sha512-O9Q+AhI1w7LT1/tHysPWDwwrgB1fKJ/nXPNLC30i8LF6RdSz4dGZyWB9WySag3DZMdGuK5yHJEdKXMKI2m5uSQ==";
  script.crossOrigin = "anonymous";
  script.referrerpolicy = "no-referrer";
  script.onload = () => addCopyButtons(clipboard);
  document.body.appendChild(script);
}

A few things changed here:

  1. I removed the fill from svgCheck and let CSS handle the color instead.
  2. I added button.title = "Copy"; so the button shows a tooltip on hover.
  3. I moved the button to after pre, which I explain below.
  4. I updated clipboard-polyfill to the latest stable release, although I did not test that part thoroughly.

Then add this to ./themes/diary/layouts/partials/footer.html:

<footer>
...
<!-- copy code -->
{{ if (findRE "<code" .Content 1) }}
    <script src="{{"/js/clipboard.js" | relURL}}"></script>
{{ end }}
</footer>

Why this structure works

Hugo's built-in Chroma highlighter renders code blocks like this:

<div class="highlight">
    <pre class="...">
        <code class="..." data-lang="...">...</code>
    </pre>
</div>

That is why the layout only includes the JavaScript when the rendered page contains <code. There is no need to load the script everywhere.

The JavaScript itself is straightforward: first come the two SVG icons, then the addCopyButtons function, which handles both inserting the button and copying the code, and finally the function call that decides whether to use the browser clipboard API or fall back to clipboard-polyfill.

The button placement took a little more care. The original version put the button before pre, which is probably just a matter of taste. But when I tested keyboard navigation, :focus landed on pre rather than on div.highlight. If the button sits before pre, then sibling selectors cannot target the button from the focused code block in the way I wanted. Moving it after pre fixes that:

 pre.parentNode.insertBefore(button, pre);
 pre.parentNode.insertBefore(button, pre.nextSibling);

With that change, the rendered HTML becomes:

<div class="highlight">
    <pre class="...">
        <code class="..." data-lang="...">...</code>
    </pre>
    <button class="copy-code-button" type="button">
        <svg ...>...</svg>
    </button>
</div>

If the browser supports the Clipboard API, the script uses it directly. Otherwise it loads clipboard-polyfill. That polyfill is marked deprecated, but on browsers that already support the native API it does not create an extra request, so leaving it in place seemed harmless. I did not specifically want to support IE, but the fallback was already there, so I might as well use it.

Making the button work with keyboard navigation

To support keyboard use, I only needed to add step 4 to addCopyButtons; the rest stays the same:

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 = "copy-code-button";
    button.type = "button";
    button.title = "Copy";
    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 after the pre tag (.highlight > pre > button > code)
    const pre = codeBlock.parentNode;
    pre.parentNode.insertBefore(button, pre.nextSibling);
    // 4. Listen to keyboard press
    const highlight = pre.parentNode;
    highlight.addEventListener('keydown', function(event) {
      if (
        event.key === " " ||
        event.key === "Spacebar" ||
        event.code === "Space" ||
        event.key === "Enter" ||
        event.code === "Enter"
      ) {
        clipboard.writeText(codeBlock.innerText).then(
          () => {
            button.blur();
            button.innerHTML = svgCheck;
            setTimeout(() => (button.innerHTML = svgCopy), 2000);
          },
          (error) => (button.innerHTML = "Error")
        );
      }
    });
  });
};

This part was pieced together from several Stack Overflow answers. One answer gave me the overall structure, another helped with supporting both Enter and Space, and another reminded me that keyCode and which are deprecated, so key and code are the right things to use now.

CSS for the button

I also adapted the CSS from the same general idea, but changed quite a bit. Create ./assets/css/code-fense.css and follow the same approach you already use for custom CSS:

.highlight {
  position: relative;
}

.copy-code-button {
  color: var(--white);
  background-color: rgba(255,255,255,50%);
  border: none;
  border-radius: 6px;
  padding: 0 5px 5px 5px;
  font-size: 1rem;
  position: absolute;
  z-index: 1;
  right: 0;
  top: 0;
  margin: 10px;
  transition: .1s;
  opacity: 0.5;
}

.copy-code-button > svg {
  fill: var(--white);
}

.copy-code-button:hover,
.copy-code-button:focus,
pre:active ~ .copy-code-button,
pre:focus ~ .copy-code-button,
div.highlight:active > .copy-code-button,
div.highlight:focus > .copy-code-button {
  cursor: pointer;
  opacity: 1;
}

Because this site uses a dark mode, hover and focus really wanted four different visual states. I got tired of writing them all and eventually came up with a slightly cursed solution based on double transparency. That also assumes I am still being lazy about automatically switching code highlighting themes; for now I am just using Dracula everywhere, which makes the dark background convenient. If automatic theme switching is added later, the CSS will probably need another round of cleanup.

The two positioning rules are the important part. Without position: relative on .highlight and position: absolute on the button, the button will drift out into the main text column. I do not feel particularly eager to treat position as anything other than a small kind of black magic. The actual placement is easy enough to understand: the button stays fixed in the top-right corner of the code block.

I also changed the icon color behavior. The original approach only showed the button more clearly when the mouse hovered over the code block, but I wanted it visible all the time, so the icon stays on screen instead of appearing only on hover.

The final part is the opacity and pointer behavior when the button, pre, or div.highlight is active or focused. In theory active and focus are different situations, but I did not have the energy to write a third set of rules for every button and link on the site. Putting them together is still better than ignoring active entirely, so that is what I kept.

The two lines involving pre are also the reason I moved the button after pre earlier. If you want to dig into that part further, the CSS sibling combinators in MDN are the right place to start.

A quick note on accessibility

Accessibility is not just about making things easier for other people. It also makes your own work easier to live with later. I have spent a lot of time learning this, and a few references that shaped my thinking include MDN's accessibility material, the 18F Accessibility Guide, Front-end development guidance for teams, the WCAG quick reference, A11ycasts, and the Accessibility Developer Guide.

This project also gave me a reason to finally finish the skip-to-main-content button I had kept putting off. If I am going to make a browser feature feel nice to use, I should probably use the site the same way myself.