Add 'copy to clipboard' button to code snippets in Astro

  • Astro
  • Preact
  • MDX
posted on 19th December 2022 ∙ by Hynek Svacha ∙ 6 min read

I am currently migrating my blog from Next.js to Astro. I also recently fell in love with Preact. In this new blog iteration, I decided to abandon React entirely and recreate the small number of interactive components using its cool 3Kb… sibling?

Since Astro has the ‘next-gen island architecture’, it is actually easy to bring any library/framework for an island component – you can have a Dropdown in React, Dialog in Svelte and ThemeToggle button as a web component. I wouldn’t do that, but it makes decisions like the one above easier to make.

I write my blog posts in MDX, which is a superset of Markdown that allows, among other things, using interactive components. A code block with a button that would copy the contained snippet to the clipboard is a nice and simple example of this component.

Install Astro and create a MDX post

If you don’t have previous experience with Astro, you can follow this tutorial. Of course, using Markdown and MDX in Astro can be a little different than in other frameworks; if you are not sure, check the docs.

So let’s suppose that we have Astro installed, and we have a simple MDX blog post with a frontmatter and code block:

---
title: How to sum up two numbers in JavaScript
category: JavaScript
---

# How to sum up two numbers in JavaScript

Let's make a function:

```js
function sum(a, b) {
  return a + b;
}
```

And this is how we do it!

This will serve as our starting point.

Create Preact integration

To create Preact integration, you need to basically use just one simple command:

# Using NPM
npx astro add preact
# Using Yarn
yarn astro add preact

However, I am using TypeScript, and the default config considers JSX as React. So I would also update the tsconfig.json file to prevent type errors:

{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

As the docs suggests, you can also use pragma comment (which is handy if you are using different libraries in the same Astro project):

/** @jsxImportSource preact */

Create an enhanced <pre> component

In most markdown compilers, the block between the triple backticks is converted to a <pre> element. So we need to enhance it.

First, let’s make a file for our component in components folder, called EnhancedPreElement.tsx

// EnhancedPreElement.tsx
import { createRef, ComponentChildren } from "preact";

const EnhancedPreElement = ({ children, ...props }: { children: ComponentChildren }) => {
  const snippetRef = createRef<HTMLPreElement>();

  const copyToClipboard = async () => {
    let snippet = snippetRef.current;
    let snippetText = snippet?.innerText ?? "";
    await navigator.clipboard.writeText(snippetText);
  };

  return (
    <div className="relative group">
      <pre {...props} ref={snippetRef}>
        {children}
      </pre>
      <button
        className="text-sm text-gray-300 bg-gray-700 rounded absolute top-4 right-4 px-2 invisible group-hover:visible"
        onClick={() => {
            copyToClipboard();
          }
        }
      >
        Copy
      </button>
    </div>
  );
};

export default EnhancedPreElement;

In this component, we are recreating the original code block, but we’ve added a wrapper that includes the “copy to clipboard” button.

Note the three lines through the opening <pre> and closing </pre> tags. With {...props}, we are passing down all the meta parameters used in MDX (language, filename, etc.); with {children}, we are passing down the original contents.

The copying action is a little messy (because of cross-browser compatibility issues)—see the comment in copyToClipboard() function.

Styling is done using Tailwind. If you hate it, you can use a different way to style the markup.

‘Signal’ that our component is really slick

Preact has recently added a new api called Signals. This api is obviously inspired by Solid’s Signals, but it is even terser and more different from React’s Hooks.

Let’s try it! Firstly, we’ll need to install a dedicated module (signals are not a part of Preact’s core library):

# Using NPM
npm install @preact/signals

# Using Yarn
yarn add @preact/signals

Then we can use the signals to add interactivity. It will be simple:

When clicked, our button will change its text to “Copied!”. After one second, it will return to the default “Copy”. To observe the “Copy” action, we’ll use a signal, that will be updated after a timeout. We’ll derive the button’s current text from computed signal:

import { createRef, ComponentChildren } from "preact";
import { signal, computed } from "@preact/signals";

const EnhancedPreElement = ({ children, ...props }: { children: ComponentChildren }) => {
  const snippetRef = createRef<HTMLPreElement>();
  const timeoutRef = createRef<ReturnType<typeof setTimeout>>();

  const hasBeenCopiedRecently = signal(false);

  const buttonText = computed(() => {
    return hasBeenCopiedRecently.value ? "Copied!" : "Copy";
  });

  const copyToClipboard = async () => {
    let snippet = snippetRef.current;
    let snippetText = snippet?.innerText ?? "";
    await navigator.clipboard.writeText(snippetText);

    hasBeenCopiedRecently.value = true;

    timeoutRef.current = setTimeout(() => {
      timeoutRef.current = null;
      hasBeenCopiedRecently.value = false;
    }, 1000);
  };

  return (
    <div className="relative group">
      <pre {...props} ref={snippetRef}>
        {children}
      </pre>
      <button
        className="text-sm text-gray-300 bg-gray-700 rounded absolute top-4 right-4 px-2 invisible group-hover:visible"
        onClick={() => {
          if (timeoutRef.current) {
            return false;
          } else {
            copyToClipboard();
          }
        }}
      >
        {buttonText}
      </button>
    </div>
  );
};

export default EnhancedPreElement;

Use the component in MDX

There are basically two options for telling the MDX compiler that we want to render <pre> elements using our EnhancedPreElement component.

We can do it either inline in a MDX file like this:

---
title: How to sum up two numbers in JavaScript
category: JavaScript
---

import EnhancedPreElement from "../components/EnhancedPreElement"
export const components = { pre: (props) => <EnhancedPreElement client:load {...props} />}

// The rest of the MDX file…

It’s very similar to what it was when using Next.js. The one very important difference is that you need to use the client:load (or other) directive to tell Astro when and how to load the compiled script. Just try to omit it—the component won’t load at all.

Alternatively, we can use Astro’s template (layout) page, which will make the replacement globally using the <Content /> component. There is one catch, though: We can’t use JSX syntax in .astro files, so we need to wrap our Preact component in an .astro component:

EnhancedPreElement.astro (the wrapper):

---
// This is our Preact component
import EnhancedPreElement from &quot;../components/EnhancedPreElement&quot;;
---

&lt;EnhancedPreElement client:load {...props}&gt;
  &lt;slot /&gt;
&lt;/EnhancedPreElement&gt;

A dynamic route file (e.g., [slug].astro):

---
// This is the Astro wrapper
import EnhancedPreElement from &quot;../components/EnhancedPreElement.astro&quot;;

export async function getStaticPaths() {
  const posts = await Astro.glob(&quot;../some-path/**/*.mdx&quot;);

  return posts.map((post) =&gt; ({
    params: {
      slug: post.frontmatter.slug,
    },
    props: {
      post,
    },
  }));
}

const { Content } = Astro.props.post;
---

&lt;Content
  components={{
    pre: EnhancedPreElement,
  }}
/&gt;

👍 Enjoy!


If you find anything in this post that should be improved (either factually or in language), feel free to edit it on Github .