Git Stash Pop vs Apply: The Difference (and Conflicts)
git stash apply restores your changes and keeps the stash; git stash pop restores them and deletes it, unless there's a conflict. Tested examples, fixes and recovery.
To make a copy to clipboard button in JavaScript, listen for the button's click event and call await navigator.clipboard.writeText(text) inside an async handler, wrapped in try/catch. That's the modern Clipboard API, and it works in every current browser, with one big condition: the page must be a secure context, which means HTTPS or localhost. On a plain http:// address, navigator.clipboard is undefined, so you need a small fallback. Below is a complete, tested button that copies from a <div>, <span> or <input>, shows "Copied!" for two seconds, and falls back on old-style copying when the modern API isn't available.
I wanted copy buttons in two places on my own site: next to the code examples in these tips, and next to share links on my projects page. Every snippet below was run in headless Chrome 154 with Puppeteer. After each click, my test script read the real clipboard back, so the outputs you see are what actually got copied, not what I hoped would be copied.
Here's the HTML. Each button has a data-copy-target attribute holding a CSS selector for the element it should copy. That way one script can handle as many buttons as you like:
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<title>Copy to clipboard</title>
</head>
<body>
<p>Your referral code: <span id="code">MAHIR-2026</span></p>
<button type="button" data-copy-target="#code">Copy code</button>
<label for="share">Share link</label>
<input id="share" value="https://mfaysal.com/projects" readonly>
<button type="button" data-copy-target="#share">Copy link</button>
<script src="copy.js"></script>
</body>
</html>
And here's copy.js, the whole JavaScript copy to clipboard function plus the click handlers:
// copy.js: copy to clipboard buttons with feedback and an HTTP fallback
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text); // modern API: HTTPS or localhost
return;
}
// Fallback for plain http:// pages and very old browsers
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("Copy command was rejected");
}
document.querySelectorAll("[data-copy-target]").forEach((button) => {
button.addEventListener("click", async () => {
const source = document.querySelector(button.dataset.copyTarget);
const text = "value" in source ? source.value : source.textContent;
const label = button.textContent;
try {
await copyText(text.trim());
button.textContent = "Copied!";
} catch (err) {
button.textContent = "Press Ctrl+C to copy";
console.error("Copy failed:", err);
}
setTimeout(() => (button.textContent = label), 2000);
});
});
There are two parts, and it's worth understanding both.
copyText() takes a string and puts it on the clipboard. If navigator.clipboard exists and the page is a secure context, it calls navigator.clipboard.writeText(text). That returns a Promise, which is why the function is async and uses await. If the browser refuses (more on why in a moment), the Promise rejects and the error bubbles up to whoever called copyText().
If the modern API isn't there, it uses the older trick: create a hidden <textarea>, put the text in it, select it, and run document.execCommand("copy"). That command copies whatever is selected on the page. I use a textarea rather than an input because it keeps line breaks, and I make it readonly so phones don't pop up the on-screen keyboard. position: fixed stops the page jumping to the bottom when the element is added.
The second part finds every button with data-copy-target, looks up the element it points to, and reads its text:
<input> or <textarea>, the text lives in .value. The check "value" in source is true for form fields.<div>, <span>, <p> or <code>, the text lives in .textContent.That one line is how you copy to clipboard from a div and copy text from an input field with the same code. After copying, the button label changes to "Copied!" and a setTimeout() puts the original label back after two seconds. If copying fails, the user sees "Press Ctrl+C to copy" instead of nothing at all, which matters more than it sounds: a button that silently does nothing makes people click it five times and give up.
If async and await are new to you, my JavaScript async/await tutorial explains what's happening when the handler pauses on await copyText(...).
I served the page from a tiny Node server and opened it twice: once as http://localhost:5173, which browsers treat as secure, and once as http://myapp.test:5173, an ordinary http:// hostname that isn't. Here's the localhost run:
# localhost
isSecureContext: true
button: Copied!
clipboard: "MAHIR-2026"
clipboard: "https://mfaysal.com/projects"
button after 2s: Copy code
Both buttons worked. The <span> gave MAHIR-2026 and the <input> gave the full URL, so the "value" in source check picked the right property each time. Two seconds later the label was back to "Copy code".
Now the same page over plain HTTP:
# http://myapp.test
isSecureContext: false
navigator.clipboard: undefined
direct call: TypeError: Cannot read properties of undefined (reading 'writeText')
button (fallback): Copied!
clipboard: "MAHIR-2026"
This is the result that confuses people. On an insecure page, navigator.clipboard isn't just blocked, it doesn't exist. So code that calls navigator.clipboard.writeText() directly crashes with TypeError: Cannot read properties of undefined (reading 'writeText'). My button still showed "Copied!" and the clipboard really did change back to MAHIR-2026, because copyText() noticed the missing API and used the execCommand fallback.
Reading and writing the clipboard is powerful. A page that can write to your clipboard could swap a bank account number you're about to paste, so browsers only expose the Clipboard API to pages that were delivered securely. The rule is simple:
https:// pages: allowed.http://localhost and http://127.0.0.1: allowed, so local development works.http:// on any other hostname, including your local network IP like http://192.168.1.20:3000 when testing on your phone: not allowed.You can check with window.isSecureContext in the console. If you're testing on a phone over Wi-Fi and the button fails, this is almost always why. The fix in production is HTTPS, which hosts like Vercel and Netlify give you free. The fallback is only there so the button doesn't break in the meantime.
It's marked as deprecated on MDN, which means you shouldn't build new features on it, and browsers could remove it one day. But every major browser still supports it for copying, and it's the only option on an insecure page. Using it as a fallback, as above, is a reasonable compromise: modern browsers on HTTPS never touch it, and if it ever disappears, the catch block still shows a helpful message.
On a blog or documentation page, you don't want to write a button by hand for every example. This script finds every <pre> element and adds a Copy button to it automatically:
// codeblocks.js: add a "Copy" button to every <pre> code block
document.querySelectorAll("pre").forEach((pre) => {
const button = document.createElement("button");
button.type = "button";
button.className = "copy-code";
button.textContent = "Copy";
pre.append(button);
button.addEventListener("click", async () => {
const code = pre.querySelector("code")?.innerText ?? "";
try {
await navigator.clipboard.writeText(code);
button.textContent = "Copied!";
} catch {
button.textContent = "Failed";
}
setTimeout(() => (button.textContent = "Copy"), 2000);
});
});
The test page had two code blocks, the second one with two lines. I clicked the second block's button:
# code blocks
buttons added: 2
clipboard: "const total = 3 + 4;\nconsole.log(total);"
Two buttons were added, and the clipboard got exactly the code with the line break preserved, but not the word "Copy". That's why the script reads pre.querySelector("code").innerText instead of pre.textContent: the button is inside the <pre>, so pre.textContent would include the button's own label at the end of every copied snippet. I use innerText here because it respects line breaks the way the code is displayed.
A small CSS rule positions the button in the top-right corner of each block:
pre { position: relative; }
.copy-code { position: absolute; top: .5rem; right: .5rem; }
If you're not sure why the <pre> needs position: relative, my post on CSS position absolute vs relative explains how an absolute element picks the box it's positioned against.
writeText() only copies plain text. If you paste into Word, Google Docs or Gmail, you might want bold text and links to survive. For that, use navigator.clipboard.write() with a ClipboardItem that holds two versions of the same content:
// rich.js: copy formatted HTML with a plain-text version for other apps
async function copyRich(html, plain) {
const item = new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([plain], { type: "text/plain" }),
});
await navigator.clipboard.write([item]);
}
document.querySelector("#copy-rich").addEventListener("click", () =>
copyRich(
'<p>Mark: <strong>72%</strong> (<em>First</em>)</p>',
"Mark: 72% (First)"
)
);
After clicking, I read the clipboard back with navigator.clipboard.read():
# rich copy
{
types: [ 'text/plain', 'text/html' ],
'text/plain': 'Mark: 72% (First)',
'text/html': '<p>Mark: <strong>72%</strong> (<em>First</em>)</p>'
}
The clipboard now holds two formats. Apps that understand HTML, like Google Docs, paste the bold and italic version. Apps that don't, like Notepad or a terminal, paste the plain text. Always include text/plain: if you only provide HTML, pasting into a plain text field can give you nothing at all.
There's one situation where even correct code fails, mainly in Safari: you click a button, the code waits for a fetch() to finish, and then tries to copy. By the time the data arrives, the browser no longer counts it as a response to your click, so it refuses. The documented workaround is to create the ClipboardItem straight away inside the click handler and pass it a Promise that resolves to the Blob once the data arrives:
button.addEventListener("click", () => {
const text = fetch("/api/share-link")
.then((res) => res.text())
.then((link) => new Blob([link], { type: "text/plain" }));
navigator.clipboard.write([new ClipboardItem({ "text/plain": text })]);
});
I don't have a Mac to test Safari, so treat that part as the approach WebKit recommends rather than something I've confirmed myself. In headless Chrome, with the test server waiting 500 ms before replying, it copied the fetched link correctly:
clipboard after fetch: "https://mfaysal.com/projects/ai-question-generator"
If you just want to copy something while debugging, you don't need any of the above. Chrome, Edge and Firefox DevTools have a built-in console helper called copy(). Type copy(document.title) or copy(JSON.stringify(data, null, 2)) in the console and the value goes straight to your clipboard. It only exists in DevTools, not in your page's scripts.
Calling navigator.clipboard.writeText() yourself from the console often fails with "Document is not focused", because focus is on the DevTools panel rather than the page. Click the page first, or use copy().
These are the problems I hit or saw most often while building and testing this:
navigator.clipboard.writeText(text); button.textContent = "Copied!" shows "Copied!" even when copying failed. Always await it inside try/catch.navigator.clipboard is undefined there. Use localhost, HTTPS, or the fallback.<iframe src="..." allow="clipboard-write">. Without that, writeText() rejects with a NotAllowedError.aria-live="polite" that says "Copied" is a nice extra.input event. If you really need to react to typing, use a debounce function so it only fires once the user pauses.The same "store something for the user" idea shows up in my dark mode toggle with localStorage, where the try/catch matters for the same reason: some browsers block the feature completely, and the page should still work.
Add a click listener to a button, and inside an async handler call await navigator.clipboard.writeText("your text") in a try/catch. The page must be on HTTPS or localhost. The full button code is at the top of this post.
The page isn't a secure context. The Clipboard API is only available on https:// pages and on localhost. On plain http://, including your local IP address when testing on a phone, it's undefined. Switch to HTTPS or use the execCommand("copy") fallback.
Get the element and pass its textContent to writeText(): await navigator.clipboard.writeText(document.querySelector("#my-div").textContent). For an input or textarea, use .value instead.
Not with the modern Clipboard API. The only way on plain HTTP is the older document.execCommand("copy") method with a temporary textarea, which still works in current browsers but is deprecated. My copyText() function uses it automatically when it has to.
Yes, with navigator.clipboard.readText(), but reading is more restricted than writing. The browser asks the user for permission, or shows a small "Paste" prompt, before your page can see what's on the clipboard. For a "paste" button, call it inside a click handler and handle the case where the user says no.
For the complete list of methods and browser support, see MDN's Clipboard API documentation.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
git stash apply restores your changes and keeps the stash; git stash pop restores them and deletes it, unless there's a conflict. Tested examples, fixes and recovery.
Save Python lists as a CSV file with the built-in csv module: a header row, list of lists, one column, dicts, no blank lines, appending, and Excel-friendly files.
Save a Python dict as a readable JSON file with json.dump(indent=4), keep £ and é as they are, handle datetimes, overwrite safely and append the right way.