the home site for me: also iteration 3 or 4 of my site
1+++ 2title = "Adding a copy code button" 3date = 2025-03-14 4slug = "adding-a-copy-button" 5description = "continuing the chain :)" 6 7[taxonomies] 8tags = ["accessibility"] 9+++ 10 11It took me a little over a month but I finally continued the chain of adding copy code buttons to your code blocks. It started with Salma Alam-Naylor’s [post](https://whitep4nth3r.com/blog/how-to-build-a-copy-code-snippet-button/) which I saw on Hacker News but then [David Bushell](https://dbushell.com/2025/02/14/copy-code-button/) also posted on it and [Ragman](https://www.ragman.net/musings/copy_code/) made a bluesky post (sky? bloop? atproto bloop? honestly not sure what a more interesting name would be) and it's been saved in my mind since then that I should add it. 12 13<!-- more --> 14 15What finally pushed me over the edge was seeing the [Duckquill](https://duckquill.daudix.one) theme and its fancy code blocks. I cloned the theme (`git clone https://codeberg.org/daudix/duckquill.git`) and figured out that the actual copy code was some reasonably simple js in `static/copy-button.js`. I copied that file and messed with it a bit as well as the css (`sass/_pre-container.scss` and some icon stuff in `sass/_icon.scss`) to make it work with my theme and style. 16 17A quick hash for cache busting and import later it all worked! 18 19> templates/head.html 20```html 21{% set jsHash = get_hash(path="js/copy-button.js", sha_type=256, 22base64=true) %} 23<script 24 src="{{ get_url(path='js/copy-button.js?' ~ jsHash, trailing_slash=false) | safe }}" 25 defer 26></script> 27``` 28 29The one thing I expanded on was the ability to specify a file name / comment for the code block. When js is disabled a markdown `>` blockquote on the line before the code block will create a header tab for the code block. I snipped the header tab idea from [chevyray.dev](https://chevyray.dev) and I grew to quite like it so I didn't want to abandon it over a copy button. 30 31Here is my code should you want to use it: 32 33> static/js/copy-button.js 34```js 35// Based on https://www.roboleary.net/2022/01/13/copy-code-to-clipboard-blog.html 36document.addEventListener("DOMContentLoaded", () => { 37 const blocks = document.querySelectorAll("pre[class^='language-']"); 38 39 for (const block of blocks) { 40 if (navigator.clipboard) { 41 // Code block header title 42 const title = document.createElement("span"); 43 const lang = block.getAttribute("data-lang"); 44 const comment = 45 block.previousElementSibling && 46 (block.previousElementSibling.tagName === "blockquote" || 47 block.previousElementSibling.nodeName === "BLOCKQUOTE") 48 ? block.previousElementSibling 49 : null; 50 if (comment) block.previousElementSibling.remove(); 51 title.innerHTML = 52 lang + (comment ? ` (${comment.textContent.trim()})` : ""); 53 54 // Copy button icon 55 const icon = document.createElement("i"); 56 icon.classList.add("icon"); 57 58 // Copy button 59 const button = document.createElement("button"); 60 const copyCodeText = "Copy code"; // Use hardcoded text instead of getElementById 61 button.setAttribute("title", copyCodeText); 62 button.appendChild(icon); 63 64 // Code block header 65 const header = document.createElement("div"); 66 header.classList.add("header"); 67 header.appendChild(title); 68 header.appendChild(button); 69 70 // Container that holds header and the code block itself 71 const container = document.createElement("div"); 72 container.classList.add("pre-container"); 73 container.appendChild(header); 74 75 // Move code block into the container 76 block.parentNode.insertBefore(container, block); 77 container.appendChild(block); 78 79 button.addEventListener("click", async () => { 80 await copyCode(block, header, button); // Pass the button here 81 }); 82 } 83 } 84 85 async function copyCode(block, header, button) { 86 const code = block.querySelector("code"); 87 const text = code.innerText; 88 89 await navigator.clipboard.writeText(text); 90 91 header.classList.add("active"); 92 button.setAttribute("disabled", true); 93 94 header.addEventListener( 95 "animationend", 96 () => { 97 header.classList.remove("active"); 98 button.removeAttribute("disabled"); 99 }, 100 { once: true }, 101 ); 102 } 103}); 104``` 105 106and the css: 107 108> sass/css/_copy-button.scss 109```scss 110i.icon { 111 display: inline-block; 112 mask-size: cover; 113 background-color: currentColor; 114 width: 1rem; 115 height: 1rem; 116 font-style: normal; 117 font-variant: normal; 118 line-height: 0; 119 text-rendering: auto; 120} 121 122.pre-container { 123 --icon-copy: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' height='16' width='16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 3c0-1.645 1.355-3 3-3h5c1.645 0 3 1.355 3 3 0 .55-.45 1-1 1s-1-.45-1-1c0-.57-.43-1-1-1H3c-.57 0-1 .43-1 1v5c0 .57.43 1 1 1 .55 0 1 .45 1 1s-.45 1-1 1c-1.645 0-3-1.355-3-3zm5 5c0-1.645 1.355-3 3-3h5c1.645 0 3 1.355 3 3v5c0 1.645-1.355 3-3 3H8c-1.645 0-3-1.355-3-3zm2 0v5c0 .57.43 1 1 1h5c.57 0 1-.43 1-1V8c0-.57-.43-1-1-1H8c-.57 0-1 .43-1 1m0 0'/%3E%3C/svg%3E"); 124 --icon-done: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath d='M7.883 0q-.486.008-.965.074a7.98 7.98 0 0 0-4.602 2.293 8.01 8.01 0 0 0-1.23 9.664 8.015 8.015 0 0 0 9.02 3.684 8 8 0 0 0 5.89-7.75 1 1 0 1 0-2 .008 5.986 5.986 0 0 1-4.418 5.816 5.996 5.996 0 0 1-6.762-2.766 5.99 5.99 0 0 1 .922-7.25 5.99 5.99 0 0 1 7.239-.984 1 1 0 0 0 1.363-.371c.273-.48.11-1.09-.371-1.367A8 8 0 0 0 9.492.14 8 8 0 0 0 7.882 0m7.15 1.998-.1.002a1 1 0 0 0-.687.34L7.95 9.535 5.707 7.29A1 1 0 0 0 4 8a1 1 0 0 0 .293.707l3 3c.195.195.465.3.742.293.277-.012.535-.133.719-.344l7-8A1 1 0 0 0 16 2.934a1 1 0 0 0-.34-.688 1 1 0 0 0-.627-.248'/%3E%3C/svg%3E"); 125 126 margin: 1rem 0 1rem; 127 border-radius: 0.75rem; 128 129 .header { 130 display: flex; 131 justify-content: space-between; 132 align-items: center; 133 border-radius: 0.2em 0.2em 0 0; 134 background-color: var(--accent); 135 background-size: 200%; 136 padding: 0.25rem; 137 height: 2.5rem; 138 139 span { 140 margin-inline-start: 0.75rem; 141 color: var(--purple-gray); 142 font-weight: bold; 143 line-height: 1; 144 } 145 146 button { 147 appearance: none; 148 transition: 200ms; 149 cursor: pointer; 150 border: none; 151 border-radius: 0.4rem; 152 background-color: transparent; 153 padding: 0.5rem; 154 color: var(--purple-gray); 155 line-height: 0; 156 157 &:hover { 158 background-color: color-mix( 159 in oklab, 160 var(--accent) 80%, 161 var(--purple-gray) 162 ); 163 } 164 165 &:focus { 166 background-color: color-mix( 167 in oklab, 168 var(--accent) 80%, 169 var(--purple-gray) 170 ); 171 } 172 173 &:active { 174 transform: scale(0.9); 175 } 176 177 &:disabled { 178 cursor: not-allowed; 179 180 &:active { 181 transform: none; 182 } 183 } 184 185 .icon { 186 -webkit-mask-image: var(--icon-copy); 187 mask-image: var(--icon-copy); 188 transition: 200ms; 189 190 :root[dir*="rtl"] & { 191 transform: scaleX(-1); 192 } 193 } 194 } 195 196 &.active { 197 button { 198 animation: active-copy 0.3s; 199 200 color: var(--purple-gray); 201 202 .icon { 203 -webkit-mask-image: var(--icon-done); 204 mask-image: var(--icon-done); 205 } 206 } 207 208 @keyframes active-copy { 209 50% { 210 transform: scale(0.9); 211 } 212 100% { 213 transform: none; 214 } 215 } 216 } 217 } 218 219 pre { 220 margin: 0; 221 box-shadow: none; 222 border-radius: 0 0 0.2em 0.2em; 223 } 224} 225```