Press / to search

    No results

    Search failed

    npm GitHub

    <copy-elemental>

    A real <button> that puts text on the clipboard and says so, in both of the ways a reader might be listening. Light DOM, no shadow root, nothing moved or wrapped.

    npm i book-of-elementals

    <code id="install">npm i book-of-elementals</code>
    
    <copy-elemental for="install">
      <button type="button">Copy</button>
    </copy-elemental>

    Why this needs script at all

    Two reasons, and the second is the one worth the element.

    The clipboard is JavaScript only. There is no attribute, no <input type="copy">, no CSS. navigator.clipboard.writeText() is the whole platform surface, so every copy button on the web is a click handler somebody wrote again.

    A swapped icon is not an announcement. Every copy button ticks, or turns green, or floats a "Copied!" tooltip โ€” all of which are nothing at all to a reader using a screen reader. They press the button and are told the same thing they were told before pressing it, which leaves them with no way to know whether the text is on their clipboard. WCAG 2.2 SC 4.1.3 Status Messages is the level-AA requirement that covers exactly this: a change of status the reader did not move focus to still has to reach them. This element's live region is that sentence, and it is the reason it exists rather than a copied snippet from a blog post.

    Prior art What it gives you The announcement
    @github/clipboard-copy for / value, and a clipboard-copy event to build your feedback from yours to write
    <sl-copy-button> icon swap and a tooltip, in a shadow root, on Lit not documented
    this the write, a data-state for CSS, and a live region built in

    Neither of those is a bad element โ€” GitHub's in particular is a hundred honest lines with no dependencies, and if you are writing your own feedback anyway it is the smaller thing to install. Reach for this one when you want the feedback to be finished, and in the light DOM with the page's own CSS on it.

    What gets copied

    Two attributes, and value wins:

    Markup On the clipboard
    <copy-elemental for="id"> the text of that element, as it is drawn
    <copy-elemental for="id"> where the id is a field what is in the field, not what is between its tags
    <copy-elemental value="โ€ฆ"> the attribute, character for character
    both the attribute
    neither nothing โ€” the button is not offered at all

    The text of an element is trimmed at one end. A code block written in markdown carries a newline before its first line and another after its last, and neither is code โ€” the trailing one pasted into a terminal runs the command the reader was still reading. Indentation at the start of a line is code, in Python and YAML especially, so nothing there is touched.

    A value is never trimmed. It is what the author typed, including the spaces.

    Feedback

    <p><code id="demo-source">git switch -c the-thing</code></p>
    
    <copy-elemental for="demo-source">
      <button type="button">Copy</button>
    </copy-elemental>
    
    <copy-elemental for="not-a-real-id">
      <button type="button">This one fails</button>
    </copy-elemental>

    Press either. The one on the right names an element that is not there, which is the failure this element refuses to paper over: writing an empty string would wipe whatever the reader already had on their clipboard and report it as a success.

    Both the icon and the words come from the same press:

    What happened On the element In the live region
    the text is on the clipboard data-state="copied" copied-text, default Copied
    nothing to copy, or it was refused data-state="error" error-text, default Copy failed
    two seconds later neither emptied

    The live region is a <span role="status" class="copy-elemental-status"> the element appends to itself at upgrade โ€” a live region only announces text arriving in one that was already in the document, so it cannot be built at the moment there is something to say. It is clipped out of sight by style.scss and never hidden with display: none, which would take it back out of the accessibility tree and undo the whole point.

    It empties after two seconds for the same reason: a region still holding "Copied" is a line the reader meets again later, with nothing behind it.

    Usage

    import 'book-of-elementals/copy';
    @use "book-of-elementals/copy/style.scss"; // structure
    @use "book-of-elementals/copy/theme.scss"; // the look, optional

    Or the single-element bundle โ€” no build step, no script to write:

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/copy.min.js"></script>
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/copy.min.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/copy-theme.min.css"
    />

    The button is the element's own direct child, so a button inside the block being copied โ€” or a second copy button below โ€” is never mistaken for the trigger. Give it whatever label or icon you like; its accessible name, its focus ring, Enter and Space are the button's, which is the point of using one.

    Copying a field

    <label>Your key <input id="api-key" value="sk-โ€ฆ" readonly /></label>
    
    <copy-elemental for="api-key">
      <button type="button">Copy key</button>
    </copy-elemental>

    A field is copied by its current value, so a reader who edited it copies what they see.

    Copying something that is not on the page

    <copy-elemental value="git remote add origin git@github.com:you/repo.git">
      <button type="button">Copy the remote</button>
    </copy-elemental>

    There is no from="el[href]" micro-syntax and no <a href> special case. One attribute beats a selector dialect nobody remembers, and pulling an attribute out of the page is one line:

    copy.value = link.href;

    Degrading

    A copy button that cannot copy is a button that lies: the reader presses it, sees no error, and walks away believing they have the text. So it is not offered at all until the element has upgraded and found something to write with.

    Missing What you get
    scripting is off no button. The text is still on the page, still selectable
    the script is still loading the button's box, invisible โ€” its space held so the page does not shift when it lands, the control withheld until it can copy
    the page is served over http no button โ€” navigator.clipboard only exists in a secure context
    for and value both unset no button, and data-unavailable on the element saying why
    the theme is not imported your own button, wired and announcing. style.scss draws nothing

    localhost is a secure context, so this only bites on a real http:// deployment.

    Note

    Put the text somewhere the reader can select it. The button is a shortcut past selecting it by hand, and a page where the only way to get a value is a button is a page that breaks for everyone in the table above.

    API

    Attributes

    Attribute Type Default Description
    for string โ€” id of the element to copy. Also read as data-for
    value string โ€” Literal text, exactly as written. Wins over for
    copied-text string Copied What the live region says on success
    error-text string Copy failed What it says when there was nothing to copy, or it failed

    Properties

    Property Type Description
    button HTMLButtonElement Read-only. The direct child that copies
    target Element Read-only. What for names, resolved on every press
    text string Read-only. What a press would put on the clipboard, right now
    value string The value attribute. Setting it is how you copy something computed

    Events

    Event Detail
    copy-done ok โ€” whether it landed. text โ€” what went on the clipboard
    document.addEventListener('copy-done', (e) => {
      if (e.detail.ok) analytics.track('copied', e.detail.text.length);
    });

    It bubbles, and it fires on the failures too โ€” that is what ok is for.

    Styling hooks

    copy-elemental[data-state="copied"] {
    } /* the two seconds after it landed */
    copy-elemental[data-state="error"] {
    } /* the two seconds after it did not */
    copy-elemental[data-unavailable] {
    } /* no clipboard, or nothing named to copy */
    copy-elemental:not(:defined) {
    } /* before upgrade */
    .copy-elemental-status {
    } /* the live region. Unclip it if you want the words on screen too */

    The look

    style.scss is structure only; theme.scss is the look and is optional. It draws a small bordered button with an Octicon in front of the label, and swaps that icon for a tick on [data-state="copied"].

    The failed state keeps the copy icon on purpose. A tick that means "that did not work" is the worst thing this element could draw, so failure is the colour and the words, never the shape.

    Property Default Description
    --copy-elemental-icon-size 1em The icon, both axes
    --copy-elemental-gap 0.4em Between the icon and the label
    --copy-elemental-padding-block 0.4em Above and below the label
    --copy-elemental-padding-inline 0.6em Either side of it
    --copy-elemental-radius 0.35rem Button corners
    --copy-elemental-surface Canvas What the button is painted on
    --copy-elemental-border-color currentcolor at 30% The rim around it
    --copy-elemental-hover currentcolor at 8% Fill under the pointer
    --copy-elemental-copied-color currentcolor towards green Icon, label and rim once it landed
    --copy-elemental-error-color currentcolor towards red The same three when it did not
    --copy-elemental-duration 150ms The cross-fade between them

    That is the table above, live. Turn the knobs in the Options tab until it looks the way you want, then copy the rule out of the bottom of the panel:

    <p><code id="options-source">turn me in the Options tab</code></p>
    
    <copy-elemental for="options-source">
      <button type="button">Copy</button>
    </copy-elemental>

    Under forced-colors the icon is repainted in the forced text colour โ€” a masked icon is a painted background, and that mode overrides author backgrounds, which would otherwise leave an icon-only button with nothing on it at all.

    Note

    Supplying your own icon? Set content: none on the button, or the theme's lands beside it:

    copy-elemental > button::before {
      content: none;
    }

    In the corner of a code block

    The look every docs site ends up building: an icon-only button pinned in the corner of the sample, and a small bubble that says it copied. Both are stylesheet, on the state the element already writes โ€” there is no second element here, and no option to turn on.

    <div class="snippet">
      <pre id="corner-source"><code>git switch -c the-thing</code></pre>
    
      <copy-elemental for="corner-source" copied-text="Copied!">
        <button type="button" aria-label="Copy the command"></button>
      </copy-elemental>
    </div>
    body {
      margin: 0;
      padding: 1rem;
      font:
        1rem/1.5 system-ui,
        sans-serif;
    }
    
    .snippet {
      position: relative;
    }
    
    /* room at the end for the button, so a long line runs under it rather than into it */
    .snippet pre {
      margin: 0;
      padding: 0.75rem 3rem 0.75rem 0.75rem;
      border-radius: 0.5rem;
      background: color-mix(in srgb, currentcolor 6%, Canvas);
      overflow-x: auto;
    }
    
    /* `display: block` first: the element is `display: contents` out of the box, which is no
       box to position and no box for the bubble to hang off */
    .snippet copy-elemental {
      display: block;
      position: absolute;
      inset-block-start: 0.5rem;
      inset-inline-end: 0.5rem;
    }
    
    /* the bubble is the live region, un-clipped for as long as it is holding words. Empty, it
       falls back to the 1px clipped span the stylesheet draws - still in the accessibility
       tree, which is where it has to be before any text lands in it */
    .snippet .copy-elemental-status:not(:empty) {
      inset-inline-end: calc(100% + 0.4rem);
      inset-block-start: 50%;
      translate: 0 -50%;
      width: auto;
      height: auto;
      margin: 0;
      overflow: visible;
      clip-path: none;
      padding: 0.15rem 0.45rem;
      border-radius: 0.3rem;
      /* the page's own two colours, swapped. `currentcolor` would be this rule's own `color`,
         which is the line below - white on white */
      background: CanvasText;
      color: Canvas;
      font-size: 0.75rem;
      pointer-events: none;
    }

    Four things in there are load-bearing:

    The bubble is the live region, not a copy of it. No ::after, no attr(copied-text), nothing that could come to say something different from what the reader hears โ€” the span the element already announces through is un-clipped and drawn. One node, so "seen" and "said" cannot drift, and the failure gets a bubble for free: Copy failed in the same place, without a second rule. Generated content would have cost a duplicate: Chromium exposes ::after text as a node of its own, which puts the word in the accessibility tree twice.

    :not(:empty), so the empty region keeps its clipped 1px box. A live region only announces text that arrives in one already in the accessibility tree. Un-clip it flat and an empty bubble sits on the page between presses; display: none it and the announcement goes with it. Styling only the state that has words is the version that is both.

    display: block before position. The element ships as display: contents, so dropping it around an existing button changes no layout at all โ€” but a box that does not exist cannot be positioned, and neither can anything hanging off it. Give it a box first.

    The button has no text, so aria-label is its whole name. The theme's icon is a masked background โ€” decorative by construction, and never a name. Nothing the bubble does touches that name: the region is a sibling of the button, not a child, so the button stays "Copy the command" throughout. A <span> inside the button would have renamed it to "Copy the command Copied!" for those two seconds, and generated content counts too.

    Nothing about the placement is the element's business. data-state and that region are the hooks; where the button sits, which side the bubble comes out of, and whether there is a bubble at all are the page's to decide.

    Copy, or something else?

    If all you need is the clipboard write and you are drawing the feedback yourself, @github/clipboard-copy is the smaller install and does that part well.