How to add a copy-code button to Eleventy using Shiki
Copy Button for Code Fences
This document explains how the copy button was added to fenced code blocks in this Eleventy site, and how to change the icon later if you want a different look. This copy button doesn't require any extra packages—it's all plain vanilla JavaScript.
What the feature does
Each code fence now gets:
- a small copy button in the upper-right corner
- the original source code stored on the
<pre>element asdata-copy-source - a click handler that copies the raw fence content to the clipboard
- a small wiggle animation after the button is clicked
The clipboard text comes from the original fence source (stored as a value of the data-copy-source attribute), not from the rendered Shiki HTML. This bloats the HTML a bit, but the tradeoff is that it provides easier and much cleaner copy results.
Files involved
The feature lives in four places:
.eleventy.js- adds the Shiki transformer and stores the raw source textsrc/scripts/code-copy.js- handles the clipboard copy and click animationsrc/css-dev/main.css- positions and animates the buttonsrc/_includes/footer.njk- loads the script site-wide
Step 1: Add a Shiki transformer
The code fence renderer is configured in .eleventy.js through eleventyConfig.amendLibrary("md", ...). The key change is a Shiki transformer passed into codeToHtml().
The transformer does three things in its pre() hook:
- adds a
copyable-codeclass to the<pre>wrapper - stores the original fence source in
data-copy-source - prepends a button element that contains the copy icon and provides the copy action
Every fenced block renders its own button without needing a separate DOM scan after page load. The raw source is available inside the transformer as this.source. That is the exact code from the code fence text before Shiki turns it into highlighted HTML, so the clipboard receives the original code and not the markup.
Step 2: Add the client-side copy handler
src/scripts/code-copy.js listens for clicks on .code-copy-button.
When the user clicks the button, the script:
- Finds the closest
pre[data-copy-source] - Reads the raw code from
dataset.copySource - Copies it with
navigator.clipboard.writeText()when available - Falls back to a hidden
<textarea>plusdocument.execCommand("copy")when needed - Adds an
is-wigglingclass so the animation plays - Removes the class after a short delay
Clipboard fallback
The modern Clipboard API is used first. If the browser blocks it or does not support it, the script falls back to the classic hidden-textarea approach. That makes the feature more resilient across browsers.
Step 3: Style the button
The button styling lives in src/css-dev/main.css.
The main CSS rules do the following:
- make the
<pre>wrapperposition: relative - absolutely position the button in the upper-right corner
- give the button a circular shape and a subtle shadow
- keep the icon small and centered
- define the wiggle animation with
@keyframes code-copy-wiggle
The effect is intentionally small so it feels like part of the code block rather than a separate control.
Step 4: Load the script on the page
The script is loaded from src/_includes/footer.njk:
<script type="module" src="/scripts/code-copy.js"></script>
Placing it in the footer keeps the markup simple and makes sure the handler is available on every page that renders code fences.
How the icon is defined
The copy icon is defined directly in .eleventy.js inside createClipboardIconNode().
It is an inline SVG node with a path element that draws the clipboard shape.
That means the icon is not coming from a package, an image file, or an icon font. It is part of the rendered button itself.
If you want a different SVG, replace the path data inside createClipboardIconNode() with the new icon's viewBox, path, and any stroke/fill values it needs. The button will keep using currentColor, so the icon color still follows the button text color unless you change the SVG properties.
How to change the icon
To change the icon, edit the SVG node in .eleventy.js.
The function to update
Look for this helper:
const createClipboardIconNode = () => ({
type: "element",
tagName: "svg",
properties: {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": 1.8,
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
focusable: "false",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "...",
},
children: [],
},
],
});
What you can change
You can update any of these parts:
viewBox- adjust this if your new SVG uses a different coordinate systemstroke-width- make the outline thicker or thinnerd- replace the actual icon path datafill- switch to a filled icon if you preferstroke- change the icon color behavior
If the new SVG has a different size or alignment, update the button icon rule in src/css-dev/main.css as well:
.tech-post-wrapper pre.copyable-code .code-copy-button svg {
width: 1rem;
height: 1rem;
}
That keeps the icon visually balanced inside the button.
Example: replace the path
If you want a simpler icon, you can swap the path data for another SVG path. For example, a checkmark or a plus sign would only require changing the d value.
Example: use a filled icon
If the new icon should be filled instead of outlined, change the SVG properties and shape accordingly:
properties: {
viewBox: "0 0 24 24",
fill: "currentColor",
stroke: "none",
"aria-hidden": "true",
focusable: "false",
}
Then replace the child path with the new filled icon path.
If you want a different size
The icon size is controlled by CSS in src/css-dev/main.css:
.tech-post-wrapper pre.copyable-code .code-copy-button svg {
width: 1rem;
height: 1rem;
}
You can change those values if the icon should be larger or smaller.
If you want to change the button appearance
The button itself is styled in src/css-dev/main.css under .tech-post-wrapper pre.copyable-code .code-copy-button.
Useful tweaks include:
- changing
widthandheight - changing
border-radiusfor a less circular button - adjusting
box-shadowfor more or less depth - changing
opacityfor a softer or more visible button - changing
topandrightto reposition it
Troubleshooting
The button shows up, but click does nothing
Check that src/scripts/code-copy.js is loaded in the footer and that the browser allows clipboard access.
The button copies HTML instead of source text
That usually means the source is not being read from data-copy-source. The transformer in .eleventy.js should add that attribute to the <pre> element.
The wiggle is too subtle
Increase the rotation or add more translation in @keyframes code-copy-wiggle in src/css-dev/main.css.
The icon does not change
Make sure you edited createClipboardIconNode() in .eleventy.js, then reload the page and rebuild the site if needed.
Summary
The feature is built without third-party clipboard packages. Shiki injects the button and raw source, a small vanilla script handles the clipboard action, and CSS handles placement plus the wiggle animation. To change the icon, update the inline SVG helper in .eleventy.js.