Here is a technique that I use to put a custom data format onto the clipboard in the browser. I can’t remember where I found this, I think it might have been when I was investigating how copy/paste worked in Figma many years ago.

The problem

When implementing copy/paste in your application you want to be able to share some custom data structure – think blocks in a page builder app or shapes on a canvas. We don’t want to leak this data structure to other applications. As an example lets say Figma put their shapes on the clipboard as a chunk of plain JSON, if I copied some shapes and then pasted inside a text document I should not see the JSON get pasted into the document. Since the browsers generally only support text or HTML clipboard formats, we need to resort to some techniques with HTML to make this work.

The solution

The TLDR of the technique is to encode your data as base64, set it as a custom attribute on a <div> element and then put the data on the clipboard as HTML. When other apps read the HTML contents of the clipboard they will ignore the custom data attribute and your custom data will be ignored, but when you retrieve the data in your app you can extract the data from the <div> and handle it accordingly.

To start off, add a copy event handler:

document.addEventListener("copy", onCopy);

In the onCopy we can use whatever logic in the app to generate the data to put on the clipboard. For maximum browser support we need to handle navigator.clipboard and e.clipboardData. If the browser supports custom mime-types we can set our own mimetype, but we need to also set text/html as a fallback just in case.

async function onCopy(e) {
  if (e.defaultPrevented) return; // event was handled elsewhere
  
  const serializedData = btoa("my custom data serialised to a string");

  const clipboardHTMLContent = `<div data-x-my-data-type="${serializedData}"></div>`; 

  if (typeof navigator.clipboard?.write === "function") {
    const type = "text/html";
    await navigator.clipboard.write([
      new window.ClipboardItem({
        [type]: new Promise(async resolve =>
          resolve(new Blob(clipboardHTMLContent), { type }))
        )
      })
    ]);
  } else if (e.clipboardData) {
    clipboardData.setData("text/html", clipboardHTMLContent);
    clipboardData.setData(`application/x-my-data-type`, serializedData);
  } else {
    console.log("Not supported");
  }
}

Now add a listener for the paste event:

document.addEventListener("paste", onPaste);

Then we basically do the inverse of the onCopy method to find and extract the custom data:

async function onPaste(e) {
  let data = null;

  if (typeof navigator.clipboard?.read === "function") {
    data = await navigator.clipboard.read();

    const div = document.createElement("div");
    div.innerHTML = data;

    const contentElement = div.querySelector(`[data-x-my-data-type]`);

    if (contentElement) {
      data = contentElement.getAttribute(`data-x-my-data-type`);
    }

  } else if (e.clipboardData) {
    data = e.clipboardData.getData(`application/x-my-data-type`);
  }

  if (!data) return null;

  const parsedData = atob(data);

  // do something with the pasted parsedData!
}

And that’s pretty much the gist of the technique. Just keep in mind that you should do some validation and error handling with the data which I haven’t covered here!