jonsully1.dev

Github copy code block feature for markdown blogs

Cover Image for Github copy code block feature for markdown blogs
Photo by Rubaitul Azad  on Unsplash
John O'Sullivan
John O'Sullivan
Senior Lead Engineer

Implementing a Custom Copy Button for Code blocks

Issue: Limitations of rehype-highlight-code-copy

We needed a copy button for code blocks in our markdown blog. While rehype-highlight-code-copy provided a basic solution, it fell short in several ways:

  • Lacked Custom Feedback:

No support for a "Copied!" message to confirm the action.

  • Design Constraints:

  • The button didn’t match GitHub’s clean, user-friendly design.

  • Integration Complexity:

Required additional configuration to work with our Next.js and Tailwind CSS setup.

  • Performance Overhead:

Added unnecessary weight to our build process.

Solution: A Custom useCopyButton Hook To address these issues, we built a lightweight, client-side useCopyButton hook. Here’s what it does:

Dynamically Adds Copy Buttons:

Finds all pre code elements and injects a copy button into each one.

Provides Visual Feedback:

Displays a "Copied!" message for 2 seconds after copying.

Changes the button icon to a checkmark during this time.

Uses Modern APIs:

Leverages navigator.clipboard.writeText for copying code.

Tailwind CSS Integration:

Styled using Tailwind CSS for seamless design consistency.

Key Features Flex Container:

The button and message are wrapped in a div with display: flex for horizontal alignment.

Lightweight:

No external dependencies; uses vanilla JavaScript.

Cleanup:

Removes event listeners and DOM elements on unmount to prevent memory leaks.

Code Implementation

"use client";

import { useEffect } from "react";

const useCopyButton = () => {
  useEffect(() => {
    const codeBlocks = document.querySelectorAll("pre code");

    codeBlocks.forEach((codeBlock) => {
      const container = document.createElement("div");
      container.style.position = "relative";

      codeBlock.parentNode?.insertBefore(container, codeBlock);
      container.appendChild(codeBlock);

      const buttonContainer = document.createElement("div");
      buttonContainer.className = "button-container";
      buttonContainer.style.display = "flex";
      buttonContainer.style.alignItems = "center";
      buttonContainer.style.gap = "0.5rem";
      buttonContainer.style.position = "absolute";
      buttonContainer.style.top = "0.5rem";
      buttonContainer.style.right = "0.5rem";

      const copyButton = document.createElement("button");
      copyButton.className = "copy-button";
      copyButton.innerHTML = `
        <svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16">
          <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
          />
          <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
          />
        </svg>
      `;

      const copiedMessage = document.createElement("span");
      copiedMessage.className = "copied-message";
      copiedMessage.textContent = "Copied!";
      copiedMessage.style.display = "none";

      copyButton.addEventListener("click", () => {
        navigator.clipboard
          .writeText(codeBlock.textContent || "")
          .then(() => {
            copyButton.innerHTML = `
              <svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16">
                <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"
                />
              </svg>
            `;
            copiedMessage.style.display = "inline";

            setTimeout(() => {
              copyButton.innerHTML = `
                <svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16">
                  <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
                  />
                  <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
                  />
                </svg>
              `;
              copiedMessage.style.display = "none";
            }, 2000);
          })
          .catch((err) => {
            console.error("Failed to copy text: ", err);
          });
      });

      buttonContainer.appendChild(copiedMessage);
      buttonContainer.appendChild(copyButton);
      container.appendChild(buttonContainer);
    });

    return () => {
      codeBlocks.forEach((codeBlock) => {
        const container = codeBlock.parentElement;
        const buttonContainer = container?.querySelector(".button-container");

        if (buttonContainer) {
          container?.removeChild(buttonContainer);
        }
      });
    };
  }, []);
};

export default useCopyButton;

CSS for Styling

.button-container {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
}

.copy-button {
  padding: 0.25rem;
  background-color: #4a5568;
  border: none;
  border-radius: 0.25rem;
  cursor: pointer;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.2s;
}

.copy-button:hover {
  background-color: #2d3748;
}

.copy-button:active {
  background-color: #1a202c;
}

.copied-message {
  background-color: #4a5568;
  color: white;
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
  font-size: 0.875rem;
  display: none;
}

.copy-button svg {
  width: 1rem;
  height: 1rem;
}

Result Copy Button: Appears in the top-right corner of every code block.

Feedback: Displays a "Copied!" message and changes the button icon temporarily.

Lightweight: No external dependencies; integrates seamlessly with Next.js and Tailwind CSS.

This solution provides a clean, user-friendly way to copy code snippets, enhancing the reader experience on our blog.