You will add a copy button to code blocks with client-side JavaScript, leverage data attributes and the figure structure, prepare a responsive layout, and ensure accessibility via aria-label and keyboard navigation.

Highlighting makes code readable, but readers still need to copy it to try it. Copying manually line by line is annoying, especially for long blocks. A neat copy button improves the real experience of documentation.
Episode 16 covers the copy button and UI interactions: how to find code blocks via data attributes, adding the button with client-side JavaScript, creating a responsive layout, and meeting accessibility standards like aria-label and keyboard navigation.
Every rendered code block is a <figure> containing <pre> and <code> inside. The figure element carries the data-rehype-pretty-code-figure attribute, while lines and tokens use other data-* attributes. This structure is what JavaScript targets to insert the button.
Because the markup is always consistent, you can write one script that works for all blocks. Just select all elements with the data-rehype-pretty-code-figure attribute, then add a button in the desired location for each element.
The text to be copied is taken from the <code> element, not from the already-colored display text. The textContent property returns clean text without span tags:
const code = pre.querySelector("code");
const text = code.textContent.trim();Note that textContent also includes text from annotations like // + in diff blocks. Filter out annotation lines before copying if you want the copied result free of markers.
In the Next.js App Router, create a client component that runs the insertion logic after the page is rendered:
"use client";
import { useEffect } from "react";
export function CopyButtons() {
useEffect(() => {
document
.querySelectorAll("[data-rehype-pretty-code-figure]")
.forEach((figure) => {
if (figure.querySelector("[data-copy-button]")) return;
const button = document.createElement("button");
button.dataset.copyButton = "true";
button.textContent = "Copy";
button.addEventListener("click", async () => {
const code = figure.querySelector("code");
await navigator.clipboard.writeText(code.textContent);
button.textContent = "Copied";
});
figure.appendChild(button);
});
}, []);
return null;
}This component adds one button per block and doesn't duplicate thanks to the data-copy-button check. After copying, the button text changes to "Copied" as brief feedback.
On pages re-rendered dynamically, useEffect can run more than once. The querySelector("[data-copy-button]") check prevents duplicate buttons. Alternatively, remove the old button before creating a new one so the state stays clean.
The button is placed in the top corner of the block, aligned with the title. Because figure doesn't necessarily have position, make sure CSS provides consistent positioning:
[data-rehype-pretty-code-figure] {
position: relative;
}
[data-rehype-pretty-code-figure] [data-copy-button] {
position: absolute;
top: 0.6rem;
right: 0.6rem;
}With absolute positioning inside the figure, the button doesn't shift the code layout and stays consistent across all blocks.
On phones, the button must not cover the first line. Hide the title or shrink the button on narrow screens, and make sure the touch area is at least 44 pixels:
@media (max-width: 640px) {
[data-rehype-pretty-code-figure] [data-copy-button] {
top: 0.4rem;
right: 0.4rem;
min-height: 44px;
}
}A sufficient touch area and a position that doesn't overlap the code keep the mobile experience comfortable.
A copy button that only contains the text "Copy" doesn't explain what it's copying. Add an aria-label referring to the block's language, for example "Copy TypeScript code". That way screen readers give full context:
const lang = pre.dataset.lang || "code";
button.setAttribute("aria-label", `Copy ${lang}`);Not every language is available in an attribute. Use a "code" fallback word when the language is unknown.
The button is a native <button> element, so it can automatically be focused with the Tab key and activated with Enter. Make sure the focus outline stays visible when the button receives focus, because keyboard users depend on that indicator. Don't disable the outline unless you provide a clear replacement.
The "Copied" feedback must return to its original state so users aren't confused. Reset the button text after a few seconds, and announce the status via aria-live so screen readers say it. Short feedback like this makes the interaction feel complete.
Key takeaways:
data-rehype-pretty-code-figure attribute.<code> element's textContent.aria-label gives language context to screen readers.In episode 17 you'll learn custom HAST and advanced rehype plugins: modifying figure and pre nodes after rehype-pretty-code, arranging the correct plugin chain, and understanding the order of transformations in complex pipelines.