rich links on the clipboard

Jun 29, 2026
4 minutes to read

Ever notice how after copying from a website, what later lands in Slack is a clean clickable label rather than a wall of URL? I first noticed it in Harness, and have since started spotting this pattern in a handful of other tools I use daily. It feels like magic until you realise the clipboard’s been a multi-format party platter the whole time and you just weren’t invited!

Here’s what’s going on, why it works, and how to wire it up yourself (with a little demo at the end so you can poke at it.)

the clipboard isn’t a string

The clipboard can hold the same content in several representations at once. When you copy from a rich-text editor, your OS receives a text/html flavour alongside a text/plain fallback. An app like Slack reads the HTML and renders it as a hyperlink; a plain <textarea> only receives the URL string - each surface picks the payload it understands.

So a “formatted hyperlink” on the clipboard is really:

That’s the whole trick. Once you know what’s on the clipboard, the rest is plumbing.

the modern API

navigator.clipboard.write takes a ClipboardItem that can carry multiple MIME types at once:

async function copyFormattedLink(label: string, url: string) {
  const html = `<a href="${url}">${label}</a>`

  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': new Blob([html], { type: 'text/html' }),
      'text/plain': new Blob([url], { type: 'text/plain' }),
    }),
  ])
}

Two caveats worth knowing up front:

This is what a custom “Copy link” button does under the hood. Perfect when you own the trigger.

intercepting the actual copy event

The API above is great for a button you control, but it doesn’t help when the user reaches for the browser’s native copy (right-click → Copy, or Cmd/Ctrl+C). For that you listen for the copy event and rewrite the clipboard payload before it gets baked:

document.addEventListener('copy', (event) => {
  const selection = document.getSelection()
  if (!selection?.toString()) return

  event.preventDefault()
  event.clipboardData.setData('text/html', `<a href="${url}">${label}</a>`)
  event.clipboardData.setData('text/plain', url)
})

The preventDefault() is crucial here (as it usually is) - skip it and the browser’s default payload writes straight over the data you just set.

This is how the “right-click and Copy gives me a rich hyperlink” trick is usually done. The context menu is browser’s own, but your page just hijacks what gets copied.

try it yourself

Below is a link wired up with a copy-event interceptor. Select the words “my thoughts page”, copy them (Cmd/Ctrl+C or right-click → Copy), then paste into both fields underneath to see what each one received.

✂️ Select & copy this: my thoughts page

Plain-text target, which sees the URL fallback:

Rich-text target, which sees the formatted link:

paste here…

The clipboard served the same payload to both; each one just pulled the flavour it knows how to handle. Slack and Notion, once you paste into them, go a step further and translate the text/html into their own formatting models (Slack’s <url|label> syntax, Notion’s link block), but what’s on the clipboard itself doesn’t change.

a few gotchas

A handful of things I’ve tripped over wiring this up before:


The wider lesson, if there is one: the clipboard is more capable than its Cmd+C / Cmd+V reputation suggests. A handful of lines on the copy side make URLs land prettier on the paste side, and your users will quietly thank you every time they share a link in chat.

Maybe in the future we’ll also explore handling media in clipboards, as that is a thorny UX if sites have not considered it, and equally feels like magic if catered to correctly!