
Elegant Copy Buttons with Pure CSS and JS
Sometimes the best tools are the simplest. This minimal "Copy to Clipboard" component demonstrates how far you can go with just standard web technologies. No build tools, no frameworks, no external dependencies—just a few lines of CSS and JavaScript. It works anywhere HTML works, and it enhances any DOM element with a CSS class ending in -allow2copy
by making its content instantly copyable.
⚠️ The implementation shown here requires the suffix `-allow2copy` due to limitations
of the markdown parsing (I can only give the code block one css class and not multiple
ones). The code shown here can be simplified without this limitation.
The implementation uses a simple CSS attribute selector and ::after
pseudo-element to inject a small UI [copy]
to the element.
When the element is clicked,
- the text content is copied to the clipboard using the native
navigator.clipboard.writeText()
API - the hint changes briefly to
[copied]
, providing user feedback without requiring a modal or alert.
By scoping the behavior to classes ending in -allow2copy
, the component stays flexible. You can define your own class naming conventions and apply the behavior precisely where needed. There's no framework to configure, no components to register, and no side-effects to manage. It’s declarative, expressive, and self-contained.
A bit of CSS add the [copy]
Button
This little bit of CSS:
- adds the
[copy]
hint, - positions it correctly, and
- changes it to
[copied]
when the classcopied
is added to the parent node by the JS code.
* :has(> [class$="-allow2copy"]) {
position: relative;
}
[class$="-allow2copy"]::after {
content: "[copy]";
position: absolute;
right: 0;
top: 0;
margin: 0.5em;
}
.copied > [class$="-allow2copy"]::after {
content: "[copied]";
}
Try it out!
Nine Lines of JS to copy the Content
The JavaScript is just one document.querySelectorAll
call and a single event listener per node. It leverages closures to handle the brief UI state transition cleanly. There’s no need for libraries like jQuery or React to achieve what the platform already provides—elegantly and efficiently.
document.querySelectorAll('[class$="-allow2copy"]').forEach($el =>
$el.addEventListener('click', async ($el) => {
const $node = $el.target;
const content = $node.textContent;
try {
await navigator.clipboard.writeText(content);
} catch (error) {
console.error(error.message);
}
$node.parentNode.classList.add('copied');
setTimeout(($n => () => $n.classList.remove('copied'))($node.parentNode), 1000);
})
);
This is the kind of code that feels good to write: fast to load, easy to understand, and simple to maintain. It’s a strong argument for using the platform as-is, keeping enhancements as close to the DOM as possible, and avoiding the over-engineering trap.
All together
<style>
* :has(> [class$="-allow2copy"]) {
position: relative;
}
[class$="-allow2copy"]::after {
content: "[copy]";
position: absolute;
right: 0;
top: 0;
margin: 0.5em;
}
.copied > [class$="-allow2copy"]::after {
content: "[copied]";
}
</style>
<script>
document.querySelectorAll('[class$="-allow2copy"]').forEach($el =>
$el.addEventListener('click', async ($el) => {
const $node = $el.target;
const content = $node.textContent;
try {
await navigator.clipboard.writeText(content);
} catch (error) {
console.error(error.message);
}
$node.parentNode.classList.add('copied');
setTimeout(($n => () => $n.classList.remove('copied'))($node.parentNode), 1000);
})
);
</script>
Embedding it using PicoSSG
The file you are looking at is a index.html.md.njk
file (see the repo), which is processed by PicoSSG to generate the HTML file you are reading.
Step 1 is to process the nunjucks template just for this piece of code {% include '_allow2copy.html' %}
,
step 2 is the markdown processing so all the easy to read and write text becomes HTML, the last
step 3 is surrounding it with the HTML template of the blog post, see the layout: blog/_post.njk
at the top of the file.
All of the described code to make the copy button work, I have put in a file _allow2copy.html
and use it in this blog post like this:
{% include '_allow2copy.html' %}
By having it prefixed with _
it is excluded from being generated as a file in the output directory, it just gets included in the blog post and you can use the [copy]
button.
Try it out and let me know.