Copy to Clipboard Button In MDX with Next.js and Rehype Pretty Code
Updated on · 6 min read|Code sharing is an essential aspect of modern web development, enabling developers to collaborate and learn from each other. Many websites make code sharing more accessible by adding a "copy to clipboard" button to their code snippets. This feature is especially useful on mobile devices, providing users with a fast and straightforward method of sharing code fragments without the need to select text manually. Some code highlighting libraries include this functionality, either built-in or through a plugin.
However, if you're using MDX in your Next.js application, there is no one-size-fits-all solution for implementing a "copy to clipboard" button.
This tutorial will guide you on how to create a "copy to clipboard" button for the code snippets processed with Rehype Pretty Code, a popular Rehype plugin for syntax highlighting in an MDX-based Next.js website. We'll be using Next.js 13+ with the "app" directory enabled. By the end of this post, you'll be able to streamline code sharing and enhance the user experience on your Next.js website. In fact, this blog uses the exact functionality we'll be creating, so you can see the result in action. Let's dive in!
Setting up
As usual, we need to install the required dependencies.
bashnpm install rehype-pretty-code shiki unist-util-visit
bashnpm install rehype-pretty-code shiki unist-util-visit
In this project, we're utilizing the Rehype Pretty Code plugin, which offers syntax highlighting for MD or MDX files. This highlighting is performed at build time, resulting in a positive impact on page speed. Additionally, you'll likely require a library for processing the MDX content. This blog uses Contentlayer, which has robust support for MDX processing. However, keep in mind that it's currently in beta and may not be suitable for everyone.
If you want to improve the discoverability of your Next.js website, I have written an article about adding a sitemap to it: Add a Dynamic Sitemap to Next.js Website Using Pages or App Directory
Extracting the code content
Rendering the button in our case presents a challenge since the syntax highlighting is executed on the server side. By the time we intend to render the button on the client side, the code content will already be wrapped in various tags necessary for syntax highlighting. One potential solution is to parse the code content when copying it, removing all HTML markup. However, this approach is inefficient as it would require undoing everything that Rehype Pretty Code has done for us.
Thankfully, there's a better way to achieve our objective. We can extract the unstyled code content before the syntax highlighting stage and append it as a property to the code nodes. This content can then be accessed as props inside our custom MDX component that renders the copy button. I originally came across this idea on a Twitter thread and slightly adapted the code to fit our use case.
To begin, we need to create a visitor function that traverses the node tree of the content and extracts the unmodified (raw text) content from all code
elements nested inside the pre
tag. We'll store this text content on the pre
node itself. To traverse the node tree, we'll use the visit
function from the unist-util-visit package. This visitor function should be added to the list of existing Rehype plugins, in the case of Contentlayer, it's the contentlayer.config.js
file.
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, ]; }
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, ]; }
This will give us a way to keep the unmodified code content, which we can access later from the node's raw
property.
Now we add the configuration for the Rehype Pretty Code plugin.
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], ]; }
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], ]; }
It's worth noting that we're utilizing both a light and a dark theme in the following code. The plugin generates two separate code blocks for each theme, with one hidden via CSS depending on the selected theme. To switch between themes, this blog uses the class
property, ensuring that only one theme is visible at a time with the following code:
csshtml.light[data-theme="dark"] { display: none; } html.dark[data-theme="light"] { display: none; }
csshtml.light[data-theme="dark"] { display: none; } html.dark[data-theme="light"] { display: none; }
It's important to note that using two themes has another implication: we must forward the raw
property to two separate pre
elements, rather than just one. To achieve this, we'll need to implement another visitor function that runs after the syntax highlighting has been completed.
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "div") { if (!("data-rehype-pretty-code-fragment" in node.properties)) { return; } for (const child of node.children) { if (child.tagName === "pre") { child.properties["raw"] = node.raw; } } } }); }, ]; }
js{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "div") { if (!("data-rehype-pretty-code-fragment" in node.properties)) { return; } for (const child of node.children) { if (child.tagName === "pre") { child.properties["raw"] = node.raw; } } } }); }, ]; }
In the following code, we select all div
elements that contain a data-rehype-pretty-code-fragment
data attribute. Then, we iterate over the pre
children within each div
(one for each theme) and add the raw code content as a property to them. With this implementation, a custom MDX component for rendering pre
elements will have raw
as one of the available props. Next, we'll add this Pre
component to our code:
jsexport const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"]; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}>{lang}</div> {children} </pre> ); };
jsexport const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"]; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}>{lang}</div> {children} </pre> ); };
We also extract the data-language
to display the code language of a given snippet. Now we will use it as one of the components inside our MDX renderer.
jsimport { useMDXComponent } from "next-contentlayer/hooks"; import { Pre } from "./components/Pre"; const components = { pre: Pre, }; export const Mdx = ({ code }) => { const MDXContent = useMDXComponent(code); return <MDXContent components={components} />; };
jsimport { useMDXComponent } from "next-contentlayer/hooks"; import { Pre } from "./components/Pre"; const components = { pre: Pre, }; export const Mdx = ({ code }) => { const MDXContent = useMDXComponent(code); return <MDXContent components={components} />; };
The Contentlayer library handles the MDX rendering, and we use its next-contentlayer package for Next.js integration. This package provides the useMDXComponent
hook, which we use to render MDX and pass our custom component via the components
object. This allows each pre
element in the MDX files to be replaced with our custom Pre
component. The content of the pre
tags will be accessible through the children
prop inside the Pre
component.
Adding the CopyButton component
Now we are ready to add the CopyButton
component, which will handle the "copy to clipboard" functionality.
To implement the "copy to clipboard" functionality, we can use JavaScript to access the clipboard and React to render the UI. In JavaScript, previously we would use the document.execCommand("copy")
method to copy the content to the clipboard. However, this method is no longer recommended and is not supported in some browsers. A more reliable method is to use the Clipboard API which provides a set of asynchronous methods to read and write to the clipboard.
js"use client"; import { useState } from "react"; export const CopyButton = ({ text }) => { const [isCopied, setIsCopied] = useState(false); const copy = async () => { await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => { setIsCopied(false); }, 10000); }; return ( <button disabled={isCopied} onClick={copy}> {isCopied ? "Copied!" : "Copy"} </button> ); };
js"use client"; import { useState } from "react"; export const CopyButton = ({ text }) => { const [isCopied, setIsCopied] = useState(false); const copy = async () => { await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => { setIsCopied(false); }, 10000); }; return ( <button disabled={isCopied} onClick={copy}> {isCopied ? "Copied!" : "Copy"} </button> ); };
Note that since we're using the app directory, where all components default to Server Components, we need to explicitly mark this component as the client one via the "use client"
directive.
The component itself is quite minimal. When the Copy
text is clicked, we store the code content (available via the text
prop) using the Navigator.clipboard API. Additionally, we change the button text to Copied!
and set a ten-second timeout to reset it.
Finally, we can use this "copy to clipboard" button inside the Pre
component.
jsimport { CopyButton } from "./CopyButton"; export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"] || "shell"; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}> {lang} <CopyButton text={raw} /> </div> {children} </pre> ); };
jsimport { CopyButton } from "./CopyButton"; export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"] || "shell"; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}> {lang} <CopyButton text={raw} /> </div> {children} </pre> ); };
The final result should look similar to the Copy button for the code snippets on this post (minus the styling).
Summary
"Copy to clipboard" buttons are an excellent way to improve the accessibility of code snippets, particularly on mobile devices. However, when it comes to creating a "copy to clipboard" button for code snippets that have been processed with Rehype Pretty Code in an MDX-based Next.js application, a specific approach is required. The primary concept is to pass the unprocessed code content as a property of the pre
element, which allows the content to be accessed later after syntax highlighting has been applied. This approach is efficient, and it streamlines code sharing, thereby enhancing the user experience on Next.js websites that use MDX and Rehype Pretty Code. The concept behind the CopyButton
component is not Next.js-specific, though. It is implemented as a React component that utilizes the Clipboard API provided by JavaScript in the browser.