Skip to content
Blog

Copy and Paste on Flutter Web: The Clipboard API Behind Clipboard.setData

How Flutter's Clipboard.setData and getData map to the browser Clipboard API on web, and the activation and permission rules that make reads fail silently.

Published on • November 11, 2026

AI Assistant

Clipboard.setData and Clipboard.getData are two lines of Dart that behave completely differently per platform. On Android and iOS they talk to the system pasteboard and mostly just work; on the web they become navigator.clipboard.writeText() and readText(), which live behind a secure context, a user gesture, and a permission model that diverges sharply between Chromium, Firefox, and Safari. Copy will surprise you less if you know where the walls are.

What Flutter’s Clipboard maps to

Both methods are async on every platform, which is exactly what the browser API requires — writeText() and readText() return promises. Flutter’s web implementation is a thin wrapper:

// Works on every platform; on web it is navigator.clipboard.writeText()
await Clipboard.setData(const ClipboardData(text: 'invoice-2026-0042'));

// On web this is navigator.clipboard.readText()
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text; // null when permission is denied

Two constraints apply before any of that runs: the Clipboard API only exists in a secure context (HTTPS, or localhost), and it is not available inside web workers. If your site is plain HTTP, navigator.clipboard is simply undefined and the Flutter wrapper has nothing to call.

Writing: stay inside the gesture

Chromium accepts a write either when the page holds the clipboard-write permission or when the call happens with transient user activation — which in practice means inside a click handler. Call Clipboard.setData from a button’s onPressed and it succeeds; call it from a timer, a stream event, or an initState, and it can reject with NotAllowedError.

TextButton(
  onPressed: () async {
    await Clipboard.setData(ClipboardData(text: _controller.text));
    if (!context.mounted) return;
    ScaffoldMessenger.of(context)
        .showSnackBar(const SnackBar(content: Text('Copied')));
  },
  child: const Text('Copy'),
)

Firefox and Safari also require transient activation for writes — they do not implement the clipboard-write permission at all, so the gesture is the whole story there. Note that once Chromium has the clipboard-write permission granted, later writes no longer need activation; do not build logic that depends on that, because users can revoke it.

Wrap the write in a catch as well — the browser’s rejection surfaces as a Dart exception, and a copy button that throws into the void is the same as one that lies:

await Clipboard.setData(ClipboardData(text: text)).catchError((_) {
  _showManualPasteHint(); // NotAllowedError from the browser
});

Reading: the permission maze

Reads are the sharp edge. The spec wants both recent user activation and a user-initiated paste action; browsers diverge on enforcement, and Flutter’s getData simply returns null when it fails rather than throwing something you can catch and explain.

  • Chromium: a read with no prior grant triggers the clipboard-read permission prompt (persisted once accepted). No prompt path outside a focused document means no clipboard.
  • Firefox and Safari: these do not support the clipboard-read/clipboard-write permission names in the Permissions API. Instead, when activation is present they show an ephemeral context menu containing a single “Paste” option — which only becomes enabled after about a second. Scripted, unattended reads therefore cannot work; the user must click Paste.
  • Focus: none of it works from a background tab or an unfocused window — Chromium checks document.hasFocus() before it will even show the prompt.
  • Same-origin shortcut: Firefox and Safari skip the Paste prompt when the clipboard content was written by your own origin; cross-origin data — the normal case when copying from another app — still requires the click.

Because of that, treat Clipboard.getData on web as best-effort: attempt it, and if the result is null or the future throws, fall back to asking the user to paste manually into a TextField, whose context menu and keyboard shortcuts run through the browser’s own trusted paste path.

Do not let the failure be silent either — a copy button that reports success while the clipboard is untouched trains users to distrust your UI, so await the future and surface the outcome.

You can also ask which state you are in before calling anything:

const { state } = await navigator.permissions.query({ name: 'clipboard-read' });
// 'granted' | 'prompt' | 'denied' — and 'denied' never re-prompts

Paste without a prompt

The escape hatch from the permission maze is the paste event: when the user presses Ctrl/Cmd+V over a focused element, the browser hands you clipboardData directly with no prompt at all.

// A TextEditingConnection-free path: listen in the DOM via package:web
web.window.addEventListener('paste', (web.Event event) {
  final clipboard = (event as web.ClipboardEvent).clipboardData;
  final text = clipboard?.getData('text/plain');
  if (text != null) _applyPastedText(text);
}.toJS);

This is the pattern to prefer for import-from-clipboard features: keep a real TextField focusable (or attach the listener at the document level), let the user paste with the keyboard, and read the event payload. It never prompts, never needs a permission, and works identically in all three browser families.

There is also a clipboardchange event on navigator.clipboard — fired whenever the system clipboard’s contents change, from any source. It only arrives with sticky activation or after the clipboard-read permission is granted, so treat it as a state hint for enabling a “Paste” button rather than a universal watcher:

navigator.clipboard.addEventListener('clipboardchange', () => setCanPaste(true));

Gating, images, and legacy paths

Two more pieces round out the picture. First, Permissions-Policy: Chromium requires the clipboard-read and clipboard-write directives to be allowed for <iframe> elements that touch the clipboard, so an embedded app needs the header on its responses:

Permissions-Policy: clipboard-read=(self), clipboard-write=(self)

Second, rich content: text-only setData/getData is the limit of Flutter’s built-in API, but the underlying API is not text-only — ClipboardItem carries multiple representations (text/plain, text/html, image/png, plus custom web -prefixed formats) through navigator.clipboard.write()/read(), so images and HTML are one package:web call away:

const blob = await (await fetch('/og-card.png')).blob();
await navigator.clipboard.write([
  new ClipboardItem({ 'text/plain': new Blob([text]), 'image/png': blob }),
]);

Two limits worth knowing: browsers are required to serve text/plain, text/html, and image/png through read()/write() but may reject a ClipboardItem that carries more than one representation at a time, and ClipboardItem.supports() tells you up front whether a format like image/svg+xml will be accepted at all.

If you need images or HTML, drop to package:web or a bridge package rather than expecting Clipboard.getData to grow a MIME parameter. Finally, document.execCommand('copy') still exists as the legacy synchronous path inside a copy event handler; MDN recommends the async Clipboard API in preference, but it remains the fallback for old browsers and for writing during an overridden copy event:

document.addEventListener('copy', (event) => {
  event.clipboardData.setData('text/plain', `${text} — copied from my app`);
  event.preventDefault();
});

Test matrix:

  • Copy from a button press succeeds; the same call from a timer or initState fails
  • A read returns text with the document focused, and null (or a prompt) when it is not
  • A denied permission state degrades to a manual-paste TextField, not a dead control
  • Keyboard paste works with no prompt in Chromium, Firefox, and Safari alike
  • An iframe without the Permissions-Policy header cannot reach the clipboard

Takeaway: Copy from within the click that triggered it, treat reads as best effort with a manual paste fallback, prefer the paste event over Clipboard.getData, and gate everything behind HTTPS and a Permissions-Policy that actually allows the clipboard.

Sources