Dan Stroot

Add a Copy to Clipboard Button in MDX with Next.js

How to create a copy to clipboard button using Rehype Pretty Code.

Date:


When there is a markdown style codeblock within an MDX file it will be processed like markdown, and will result in an html structure like this:

(html)
<figure>
  <figcaption>caption text</figcaption>
  <pre>
    <code>
      some code...
    </code>
  </pre>
</figure>

The basic outline to create a copy button is:

  1. First, store a copy of the code string in the 'pre' html element using an attribute called 'rawstring'. This will be done by some code in the MDX processing pipeline. Mine looks this:
(javascript)
import rehypePrettyCode, { type Options } from 'rehype-pretty-code'
import { visit } from 'unist-util-visit'
 
// MDX processing configuration
mdx: {
    rehypePlugins: [
      () => (tree) => {
        visit(tree, (node) => {
          if (node?.type === 'element' && node?.tagName === 'pre') {
            const [codeEl] = node.children
 
            // skip if `pre` element doesn't have a `code` child element
            if (codeEl.tagName !== 'code') return
 
            // save the code string value in node attribute `__rawstring__`
            // before we run `rehypePrettyCode` which chops up the
            // string into colored, styled spans
            node.__rawstring__ = codeEl.children?.[0].value
          }
        })
      },
      [
        rehypePrettyCode,
        {
          theme: "one-dark-pro",
          keepBackground: false,
          onVisitLine(node: any) {
            if (node.children.length === 0) {
              node.children = [{ type: "text", value: " " }];
            }
          },
        },
      ],
      () => (tree) => {
        visit(tree, (node) => {
          if (node?.type === 'element' && node?.tagName === 'figure') {
            // skip if not a rehypePrettyCode added `figure`
            if (!('data-rehype-pretty-code-figure' in node.properties)) {
              return
            }
 
            // select the `pre` element (if there is one)
            const preElement = node.children.at(-1)
            if (preElement.tagName !== 'pre') {
              return
            }
 
            // add the '__rawstring__' property to the `pre` element
            preElement.properties['__rawstring__'] = node.__rawstring__
          }
        })
      },
    ],
  },

The results after processing a real example code block will look like this (notice the __rawstring__ property added?):

(html)
<figure data-rehype-pretty-code-figure="">
  <figcaption
    data-rehype-pretty-code-title=""
    data-language="bash"
    data-theme="one-dark-pro"
  >
    settings.sh
  </figcaption>
  <pre
    tabindex="0"
    data-language="bash"
    data-theme="one-dark-pro"
    __rawstring__='sh -c "$(curl -fsSL \
    https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
  >
    <code data-language="bash" data-theme="one-dark-pro" style="display: grid;">
      <span data-line="">
        <span style="color: rgb(97, 175, 239);">sh</span>
        <span style="color: rgb(209, 154, 102);"> -c</span>
        <span style="color: rgb(152, 195, 121);"> "$(</span>
        <span style="color: rgb(97, 175, 239);">curl</span>
        <span style="color: rgb(209, 154, 102);"> -fsSL</span>
        <span style="color: rgb(86, 182, 194);"> \</span>
      </span>
      <span data-line="">
        <span style="color: rgb(152, 195, 121);">
          https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
        </span>
      </span>
    </code>
  </pre>
</figure>
  1. Next, inside a custom 'pre' component send the data in the 'rawstring' attribute to the copy button component. My custom "pre" component looks like this:
(javascript)
import { CopyButton } from '@/components/CopyButton'
 
interface PreProps extends React.HTMLProps<HTMLPreElement> {
  __rawstring__?: string
  ['data-language']?: string
}
 
export function CustomPre(props: PreProps) {
  const {
    children,
    __rawstring__ = '',
    ['data-language']: dataLanguage = 'Shell',
  } = props
 
  return (
    <div className="relative flex flex-col space-y-2 rounded-lg bg-gray-200 p-4 dark:bg-gray-700">
      <div className="flex items-center space-x-2">
        <span className="h-3 w-3 rounded-full bg-[#ff605c]"></span>
        <span className="h-3 w-3 rounded-full bg-[#ffbd44]"></span>
        <span className="h-3 w-3 rounded-full bg-[#00ca4e]"></span>
        <span className="text-sm">Language: {dataLanguage}</span>
      </div>
      <div className="not-prose rounded bg-gray-800 dark:bg-black">
        <pre
          //   className="relative overflow-auto text-nowrap rounded-xl p-8"
          className="m-3 overflow-auto text-nowrap text-sm font-medium"
          {...props}
        >
          <CopyButton text={__rawstring__} className="absolute right-1 top-1" />
          {children}
        </pre>
      </div>
    </div>
  )
}
  1. Finally, within the copy button component, copy the string to the clipboard when the button is clicked. My CopyButton component looks like this:
(javascript)
'use client'
 
import { useCallback, useState } from 'react'
 
import { cn } from '@/lib/utils'
import { Button, ButtonProps } from '@/components/ui/button'
import { Icons } from '@/components/Icons'
 
interface CopyButtonProps extends ButtonProps {
  text: string
  className?: string
}
 
export function CopyButton({ text, className, ...props }: CopyButtonProps) {
  const [isCopied, setIsCopied] = useState(false)
 
  const copy = useCallback(() => {
    navigator.clipboard.writeText(text).then(() => {
      setIsCopied(true)
      setTimeout(() => setIsCopied(false), 700)
    })
  }, [text])
 
  return (
    <Button
      variant="ghost" // ghost, outline
      size="icon"
      className={cn('hover:bg-gray-100 hover:dark:bg-gray-800', className)}
      //   disabled={isCopied}
      onClick={copy}
      aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
      {...props}
    >
      <span className="sr-only">{isCopied ? 'Copied' : 'Copy'}</span>
      {!isCopied ? (
        <Icons.copy className="h-4 w-4" />
      ) : (
        <Icons.check className="h-4 w-4 text-green-600 dark:text-green-400" />
      )}
    </Button>
  )
}

Rendering MDX Content

There is a little more to it though - your custom components need to be passed to your MDX rendering process. Here's some example code of how to do that. First we will create a const containing our new custom components and then we will setup MDX rendering using the custom components.

(javascript)
import { CustomCode } from './CustomCode'
import { CustomPre } from './CustomPre'
 
// NOTE: MDX Bundler will "bundle" these components for the MDX files to use.
export const MDXComponents = {
  // Override standard HTML Tags
  pre: CustomPre,
  code: CustomCode,
}

Creating a "MDXContent" rendering function:

(javascript)
import * as runtime from 'react/jsx-runtime'
 
import { MDXComponents } from '@/components/MDXComponents'
 
interface MdxProps {
  code: string
  components?: Record<string, React.ComponentType>
}
 
const useMDXComponent = (code: string) => {
  const fn = new Function(code)
  return fn({ ...runtime }).default
}
 
export function MDXContent({ code, components }: MdxProps) {
  const Component = useMDXComponent(code)
  return <Component components={{ ...MDXComponents, ...components }} />
}

Rendering a page in Next.js using MDXContent:

(javascript)
import { posts } from 'velite/generated'
import { MDXContent } from '@/lib/mdx-content'
 
export const generateStaticParams = async () => {
  return posts.map((post) => ({ slug: post.slug }))
}
 
const PostLayout = async (props: { params: Promise<{ slug: string }> }) => {
  const params = await props.params;
  // Find the post for the current slug.
  const post = posts.find((post) => post.slug === params.slug)
 
  return (
    <div className="converted-html">
        <MDXContent code={post.content} />
    </div>
  )
}
 
export default PostLayout

Sharing is Caring

Edit this page