Swish UI
All componentsButtons and controls Free

Copy to clipboard button

The copy icon shrinks away as a check draws itself in, and a small Copied tooltip rises above the button.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./demo.module.css";

const COMMAND = "npm install @stylabs/motion";
type Status = { kind: "idle" | "copied" | "failed"; at: number };
const MESSAGE = { idle: "", copied: "Copied", failed: "Copy blocked, text selected" };

export default function Demo() {
  const code = useRef<HTMLElement>(null);
  const [status, setStatus] = useState<Status>({ kind: "idle", at: 0 });

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(COMMAND);
      setStatus({ kind: "copied", at: Date.now() });
    } catch {
      // No clipboard access: select the text so the user can copy it by hand.
      const range = document.createRange();
      if (code.current) range.selectNodeContents(code.current);
      getSelection()?.removeAllRanges();
      getSelection()?.addRange(range);
      setStatus({ kind: "failed", at: Date.now() });
    }
  };

  useEffect(() => {
    if (status.kind === "idle") return;
    const timer = setTimeout(() => setStatus({ kind: "idle", at: 0 }), status.kind === "copied" ? 1600 : 2600);
    return () => clearTimeout(timer);
  }, [status]);

  const copied = status.kind === "copied";

  return (
    <div className={styles.root}>
      <div className={styles.block}>
        <p className={styles.caption}>Install</p>
        <div className={styles.bar}>
          <code className={styles.code}>
            <span className={styles.prompt} aria-hidden="true">$ </span>
            <span ref={code}>{COMMAND}</span>
          </code>
          <span className={styles.anchor}>
            <button type="button" className={`${styles.button} ${copied ? styles.copied : ""}`} onClick={copy} aria-label="Copy install command">
              <svg className={styles.copyIcon} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                <rect x="5.5" y="5.5" width="8" height="8" rx="2" />
                <path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" />
              </svg>
              <svg className={styles.checkIcon} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                <path d="M3.5 8.5 6.5 11.5 12.5 4.5" pathLength="1" />
              </svg>
            </button>
            <span key={status.at} className={`${styles.tip} ${status.kind !== "idle" ? styles.show : ""}`} aria-hidden="true">
              {MESSAGE[status.kind]}
            </span>
          </span>
        </div>
        <span className={styles.sr} aria-live="polite">
          {copied ? "Copied to clipboard" : status.kind === "failed" ? "Copy failed. The command is selected, copy it manually." : ""}
        </span>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.block { width: min(100%, 400px); display: grid; gap: 8px; }
.caption { font-size: 13px; font-weight: 600; color: var(--muted); }
.bar {
  display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 16px;
  border-radius: 12px; background: var(--ink); color: var(--white);
}
.code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; font-size: 13px; padding: 8px 0; scrollbar-width: none; }
.code ::selection { background: var(--accent-text); color: var(--white); }
.prompt { color: var(--accent); }
.anchor { position: relative; flex: none; }
.button {
  position: relative; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 9px;
  background: color-mix(in srgb, var(--white) 8%, transparent); color: var(--white); cursor: pointer;
  transition: background-color .18s, transform .18s var(--out);
}
.button:hover { background: color-mix(in srgb, var(--white) 16%, transparent); }
.button:active { transform: scale(.92); }
.button:focus-visible { outline-color: var(--accent); }
.button svg { grid-area: 1 / 1; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; transition: opacity .2s, scale .3s var(--out); }
.checkIcon { opacity: 0; scale: .5; color: var(--accent); stroke-width: 2.2; }
.checkIcon path { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .35s var(--out) .08s; }
.copied .copyIcon { opacity: 0; scale: .5; }
.copied .checkIcon { opacity: 1; scale: 1; }
.copied .checkIcon path { stroke-dashoffset: 0; }
.tip {
  position: absolute; right: 0; bottom: calc(100% + 10px); padding: 6px 10px; border-radius: 8px;
  background: var(--white); color: var(--ink); border: 1px solid var(--line); font-size: 12px; font-weight: 600; white-space: nowrap;
  box-shadow: 0 8px 20px -12px var(--ink); opacity: 0; translate: 0 4px; pointer-events: none;
  transition: opacity .2s, translate .3s var(--out);
}
.tip::after { content: ""; position: absolute; right: 14px; top: 100%; border: 5px solid transparent; border-top-color: var(--white); }
.show { animation: tip .3s var(--out) forwards; }
@keyframes tip { to { opacity: 1; translate: 0 0; } }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  .show { animation: none; opacity: 1; translate: 0 0; }
}

Use it for

  • Install commands, API keys and code snippets
  • Share links and coupon codes that users paste elsewhere

Avoid

  • Long passages of text, where selecting is more natural
  • Secrets shown without a reveal step

How it works

The click calls navigator.clipboard.writeText inside try and catch. Success sets a copied state that cross-fades and scales the two stacked icons and animates the check stroke-dashoffset from 1 to 0. The tooltip is keyed by a timestamp so repeat clicks replay its rise. A timer returns to idle and is cleared on cleanup.

Accessibility

The icon-only button has an aria-label, and a polite live region announces the result because the tooltip is aria-hidden. If clipboard access is denied the command text is selected and the message says so, so the user can still copy it with the keyboard. Reduced motion shows the tooltip without the rise.

Found on