Playground
A small icon button that copies its value to the clipboard and confirms with a check mark. It also copies on plain http://, where browsers have no Clipboard API, and when nothing else works it selects the text for the user to copy. Every code block on this site uses it.
Examples
Basic
<sb-button>Launch</sb-button>
<code><sb-button>Launch</sb-button></code>
<sb-copy-button value="<sb-button>Launch</sb-button>"></sb-copy-button>
Copy a Datastar signal
Bind value to a signal with data-attr, and the button always copies the current value.
<div data-signals:_coords="'51.4779° N, 0.0015° W'">
<span data-text="$_coords"></span>
<sb-copy-button data-attr:value="$_coords" label="Copy coordinates"></sb-copy-button>
</div>
React to copies
The sb-copy event bubbles out of the shadow root, so data-on works on any ancestor.
<div data-signals:_copies="0" data-on:sb-copy="$_copies++">
<sb-copy-button value="ignition"></sb-copy-button>
<span data-text="'Copied ' + $_copies + ' times'"></span>
</div>
Plain HTTP and refused clipboards
The button tries three ways, in this order:
- The Clipboard API (
navigator.clipboard.writeText). Browsers offer it only in a secure context:https://pages andhttp://localhost. - A textarea in the button's own shadow root, holding the value, selected and copied with
document.execCommand('copy'). Without a Clipboard API this runs in the click's own task, while the browser still counts the click as a user action. When the API refuses (in an iframe without theclipboard-writepermission, or when the document isn't focused), it runs once the refusal arrives, and a browser that no longer counts the click by then refuses this too. The textarea is in the button's layer (a modal dialog, a popover), never in inert content; it is removed straight after the copy command, in the same task, and the focus goes back to the button. - A panel under the button with the text selected and focused, and a message that says which keys copy it: "Press Ctrl+C to copy", or ⌘C on Apple systems (
manual-label, where{key}stands for the keys). A copy of the whole text from the panel, with the keys or the system's copy menu on a phone, closes it and showscopied-label; a copy of only a part, or of nothing after a click into the field, leaves it open and reports nothing. Escape and a click elsewhere close it quietly. It opens in the top layer, so a code block'soverflow: hiddendoesn't cut it off.
sb-copy says which way worked in method: "clipboard", "execCommand" or "manual". sb-copy-error fires only when the first two ways failed, with { value, error }: the Clipboard API's error name ("NotAllowedError" when it refused) or "TypeError" where there is none. It is cancelable: a page that offers its own way calls preventDefault(), and the button shows the cross and failed-label instead of the panel.
<sb-copy-button value="go run ." data-on:sb-copy-error="evt.preventDefault(); console.warn('copy refused:', evt.detail.error)"></sb-copy-button>
A new label re-renders the button, which closes an open panel.
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size: the button is a
2remsquare inside a 1px border; setinline-sizeandblock-sizeon::part(button)for another (the border comes on top), and the icon stays half of it. - Fonts: the "Copied!" tip and the panel use your page's font. The panel's text field is at least 12pt, since iOS zooms in on a smaller field when it takes the focus.
- Colours: the button is
--sb-surface-raisedwith a--sb-borderedge and a--sb-text-2icon (on hover--sb-text-1, with a--sb-border-strongedge). After copying it turns--sb-ok; when copying failed and the page cancelledsb-copy-error,--sb-danger. The tip is--sb-surface-raisedwith a--sb-border-strongedge and--sb-text-1text; a failure fills it with--sb-dangerand--sb-text-on-danger. The panel is--sb-surface-raisedwith a--sb-border-strongedge and--sb-text-1text, and its text field has a--sb-borderedge. Corners are--sb-radius-sm, the focus ring--sb-focus-ring. In forced colors the panel gets a system-colour outline. - Parts:
button,tip,manual(the panel) andmanual-text(its text field). Your page's::part()rules win over the component's own, without!important. - States: the host has
:state(copied)or:state(failed)while the check mark or the cross shows, and:state(manual)while the panel is open, e.g.sb-copy-button:state(manual). They are CSS custom states, so a server morph cannot reset them.
<style>
.my-copy { --sb-ok: var(--sb-brand-light); }
.my-copy::part(button) { inline-size: 2.5rem; block-size: 2.5rem; border-radius: 50%; }
</style>
<sb-copy-button class="my-copy" value="go run ."></sb-copy-button>
Accessibility
The button's accessible name is label. The result ("Copied!" or "Copy failed") goes to a role="status" region next to the button, so screen readers announce it without moving focus, also after a copy from the panel. It sits outside the button on purpose: a button's children are presentational, so a live region inside one may never be read out. The same element is the visual tip (::part(tip)). Keyboard focus shows --sb-focus-ring, and in forced colors (Windows High Contrast), which drop that shadow, a system-colour outline.
When the panel opens, the focus moves to its text field with the whole text selected. The message ("Press Ctrl+C to copy") is the field's name, so a screen reader reads the instruction and then the text. Escape closes the panel, and inside a modal dialog or a drawer the first Escape closes only the panel. When the panel closes with the focus inside, the focus goes back to the button. The textarea of the second way holds the focus only for the copy itself, and then the button has it again. The button shows its focus ring if it had one when it was clicked, or if the panel was closed with a key (Escape, the copy shortcut), so a mouse click leaves none.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-copy-button value="npm install"></sb-copy-button>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/copy-button@2a9e7e7646d2/copy-button.bundle.min.js" integrity="sha384-nRdGL1zdr1a1jkn+PIL76KS9fFDB8Gt1TCprDC3c8qgHnPHzCYINVBU0svvsOIDk"></script>
<sb-copy-button value="npm install"></sb-copy-button>Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/copy-button@2a9e7e7646d2/copy-button.min.js": "sha384-aMkZmUDhQjFqYLWP9bVqgSU08IrkWqNopw1Svh6yC18xbgsJbTAV9ndgjpW/gj7Q"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/copy-button@2a9e7e7646d2/copy-button.min.js" integrity="sha384-aMkZmUDhQjFqYLWP9bVqgSU08IrkWqNopw1Svh6yC18xbgsJbTAV9ndgjpW/gj7Q"></script>
<sb-copy-button value="npm install"></sb-copy-button>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/copy-button@2a9e7e7646d2/copy-button.bundle.min.js": "sha384-nRdGL1zdr1a1jkn+PIL76KS9fFDB8Gt1TCprDC3c8qgHnPHzCYINVBU0svvsOIDk"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>
<sb-copy-button value="npm install"></sb-copy-button>Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-copy-button>copy-button.bundle.min.jsone file 2.7 kB brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/copy-button/copy-button.bundle.min.js"></script>
<sb-copy-button value="npm install"></sb-copy-button>Or copy the module files, keeping each component's folder, and load each component's module:
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/copy-button/copy-button.min.js"></script>
<sb-copy-button value="npm install"></sb-copy-button>Size
A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli |
|---|---|---|---|
copy-button.bundle.min.js | 7.2 kB | 3.1 kB | 2.7 kB |
As module files: one request, 2.6 kB minified with brotli
For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
copy-button.js | 13.0 kB | 5.0 kB | 4.3 kB | 2.6 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | "" | The text copied to the clipboard. |
label | string | "Copy to clipboard" | Accessible label of the button. A new label re-renders the button and closes an open panel. |
copied-label | string | "Copied!" | Shown and announced after copying. |
failed-label | string | "Copy failed" | Shown and announced when copying failed and the page cancelled sb-copy-error (otherwise the panel opens). |
manual-label | string | "Press {key} to copy" | The panel's message, and the name of its text field, when the browser can't copy: {key} becomes ⌘C on Apple systems, else Ctrl+C. |
reset-ms | number | 1600 | How long the copied or failed state lasts, in ms (at least 300). |
Events
| Name | Description |
|---|---|
sb-copy | After copying. detail: { value, method }: "clipboard" (the Clipboard API), "execCommand" (a hidden textarea, e.g. on plain http://) or "manual" (the user copied the whole text from the panel). |
sb-copy-error | Cancelable. Neither the Clipboard API nor the hidden textarea could copy. detail: { value, error } (the Clipboard API's error name: "NotAllowedError" when refused, "TypeError" where there is none, e.g. on plain http://). Not cancelled, a panel selects the text for the user to copy; preventDefault() shows the cross and failed-label instead. |