Skip to content

Copy Code Snippet

A dark code block for install commands: npm, pnpm, yarn and bun tabs, syntax-tinted tokens, line highlights, a copy button that really writes to the clipboard and only confirms when it did, and long lines that scroll under edge fades. No dependencies.

DOM + CSS0 deps1 file · 20 KB TSXreduced-motion aware

Freeminimaleditorial
Preview

Props

Package manager tabs
Line numbers
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";

type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
type Tok = { t: string; c: string; i?: boolean };

const CSN_MANAGERS: readonly PackageManager[] = ["npm", "pnpm", "yarn", "bun"];
const CSN_SHELL = new Set(["bash", "sh", "shell", "zsh", "terminal", "console"]);
const CSN_HASH = new Set(["py", "python", "yaml", "yml", "toml", "rb", "ruby", "env"]);
const CSN_SUBS = new Set(["add", "init", "install", "i", "run", "dlx", "exec", "create", "remove", "build", "dev", "start", "test", "update"]);
const CSN_KEYWORDS = new Set([
  "const", "let", "var", "function", "return", "import", "from", "export", "default", "if", "else", "for", "while",
  "async", "await", "new", "class", "extends", "type", "interface", "true", "false", "null", "undefined", "def", "in", "of",
]);

const CSN_COLOR = {
  cmd: "#c4b5fd",
  sub: "#f4f4f5",
  flag: "#7dd3fc",
  pkg: "#fcd34d",
  str: "#86efac",
  com: "#6b7280",
  num: "#fdba74",
  fn: "#7dd3fc",
  key: "#93c5fd",
  dim: "#71717a",
  base: "#e4e4e7",
};

/** npm-flavoured lines rewritten for another package manager. */
function csnConvert(line: string, pm: PackageManager): string {
  if (pm === "npm") return line;
  const m = line.match(/^(\s*)(npx|npm)\s+(.*)$/);
  if (!m) return line;
  const ind = m[1] ?? "";
  const bin = m[2] ?? "";
  const rest = m[3] ?? "";
  if (bin === "npx") return ind + (pm === "bun" ? "bunx" : pm + " dlx") + " " + rest;
  const add = rest.match(/^(?:install|i|add)(?:\s+(.*))?$/);
  if (add) return add[1] ? ind + pm + " add " + add[1] : ind + pm + " install";
  const rm = rest.match(/^(?:uninstall|remove|rm)\s+(.*)$/);
  if (rm) return ind + pm + " remove " + (rm[1] ?? "");
  return ind + pm + " " + rest;
}

function csnShell(line: string): Tok[] {
  const out: Tok[] = [];
  const re = /(#.*$)|("[^"]*"|'[^']*')|(\s+)|(&&|\|\||[|;<>]+)|([^\s"'|;&<>]+)|(.)/g;
  let expectCmd = true;
  let afterCmd = false;
  let m: RegExpExecArray | null;
  while ((m = re.exec(line)) !== null) {
    const [all, com, str, ws, op, word] = m;
    if (com) out.push({ t: com, c: CSN_COLOR.com, i: true });
    else if (str) out.push({ t: str, c: CSN_COLOR.str });
    else if (ws) out.push({ t: ws, c: CSN_COLOR.base });
    else if (op) {
      out.push({ t: op, c: CSN_COLOR.dim });
      expectCmd = !/[<>]/.test(op);
      afterCmd = false;
    } else if (word) {
      if (expectCmd && /^[A-Za-z_]+=/.test(word)) {
        out.push({ t: word, c: CSN_COLOR.dim });
      } else if (expectCmd) {
        out.push({ t: word, c: CSN_COLOR.cmd });
        expectCmd = false;
        afterCmd = true;
      } else if (word.startsWith("-")) {
        out.push({ t: word, c: CSN_COLOR.flag });
      } else {
        if (afterCmd && CSN_SUBS.has(word)) out.push({ t: word, c: CSN_COLOR.sub });
        else if (/[@/]/.test(word)) {
          const at = word.lastIndexOf("@");
          if (at > 0) {
            out.push({ t: word.slice(0, at), c: CSN_COLOR.pkg });
            out.push({ t: word.slice(at), c: CSN_COLOR.dim });
          } else out.push({ t: word, c: CSN_COLOR.pkg });
        } else if (/^\d/.test(word)) out.push({ t: word, c: CSN_COLOR.num });
        else out.push({ t: word, c: CSN_COLOR.base });
        afterCmd = false;
      }
    } else out.push({ t: all, c: CSN_COLOR.base });
  }
  return out;
}

function csnGeneric(line: string, hash: boolean): Tok[] {
  const out: Tok[] = [];
  const re = /(\/\/.*$)|(#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)|(\s+)|(.)/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(line)) !== null) {
    const [all, slash, pound, str, num, id] = m;
    const rest = line.slice(m.index + all.length);
    if (slash) out.push({ t: slash, c: CSN_COLOR.com, i: true });
    else if (pound) out.push(hash ? { t: pound, c: CSN_COLOR.com, i: true } : { t: pound, c: CSN_COLOR.base });
    else if (str) out.push({ t: str, c: /^\s*:/.test(rest) ? CSN_COLOR.key : CSN_COLOR.str });
    else if (num) out.push({ t: num, c: CSN_COLOR.num });
    else if (id) {
      if (CSN_KEYWORDS.has(id)) out.push({ t: id, c: CSN_COLOR.cmd });
      else if (rest.startsWith("(")) out.push({ t: id, c: CSN_COLOR.fn });
      else out.push({ t: id, c: CSN_COLOR.base });
    } else out.push({ t: all, c: /^[^\w\s]$/.test(all) ? CSN_COLOR.dim : CSN_COLOR.base });
  }
  return out;
}

async function csnWrite(text: string): Promise<boolean> {
  try {
    if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch {
    // Permission denied or insecure context: fall through to the legacy path.
  }
  if (typeof document === "undefined") return false;
  const prev = document.activeElement as HTMLElement | null;
  const ta = document.createElement("textarea");
  ta.value = text;
  ta.setAttribute("readonly", "");
  ta.setAttribute("aria-hidden", "true");
  ta.style.cssText = "position:fixed;top:-1000px;left:0;opacity:0;pointer-events:none;";
  document.body.appendChild(ta);
  let ok = false;
  try {
    ta.select();
    ta.setSelectionRange(0, text.length);
    ok = document.execCommand("copy");
  } catch {
    ok = false;
  }
  document.body.removeChild(ta);
  if (prev && typeof prev.focus === "function") prev.focus({ preventScroll: true });
  return ok;
}

const CSN_CSS = [
  ".csn{container-type:inline-size;position:relative;color:#e4e4e7;font-family:inherit;-webkit-tap-highlight-color:transparent}",
  ".csn ::selection{background:rgba(125,211,252,.28)}",
  ".csn-glow,.csn-ring{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .35s ease;border-radius:inherit}",
  ".csn-glow{background:radial-gradient(260px circle at var(--mx,50%) var(--my,0%),rgba(125,211,252,.09),transparent 70%)}",
  ".csn-ring{padding:1px;background:radial-gradient(180px circle at var(--mx,50%) var(--my,0%),rgba(186,230,253,.55),transparent 70%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}",
  ".csn:hover .csn-glow,.csn:hover .csn-ring,.csn:focus-within .csn-glow,.csn:focus-within .csn-ring{opacity:1}",
  ".csn-head{position:relative;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 8px 8px 8px;border-bottom:1px solid rgba(255,255,255,.07)}",
  ".csn-tabs{position:relative;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;padding:3px;border-radius:10px;background:rgba(255,255,255,.04);box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}",
  ".csn-pill{position:absolute;top:3px;bottom:3px;left:3px;border-radius:7px;background:rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 1px 2px rgba(0,0,0,.4);transition:transform .34s cubic-bezier(.34,1.32,.64,1)}",
  ".csn-t{position:relative;z-index:1;min-height:32px;padding:0 12px;border:0;border-radius:7px;background:transparent;color:#8b8d96;font:inherit;font-size:12.5px;font-weight:500;letter-spacing:.01em;cursor:pointer;transition:color .2s ease}",
  ".csn-t:hover{color:#d4d4d8}",
  ".csn-t[aria-selected=true]{color:#fff}",
  ".csn-lang{padding:3px 8px;border-radius:999px;background:rgba(255,255,255,.06);color:#9a9ca6;font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase}",
  ".csn-right{display:flex;align-items:center;gap:10px}",
  ".csn-copy{position:relative;display:inline-flex;align-items:center;gap:7px;min-height:32px;padding:0 12px 0 10px;border:1px solid rgba(255,255,255,.1);border-radius:9px;background:rgba(255,255,255,.05);color:#d4d4d8;font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;transition:background .2s ease,border-color .25s ease,color .25s ease,transform .15s ease}",
  ".csn-copy:hover{background:rgba(255,255,255,.1);color:#fff}",
  ".csn-copy:active{transform:scale(.95)}",
  ".csn-copy[data-status=copied]{background:rgba(16,185,129,.14);border-color:rgba(52,211,153,.4);color:#6ee7b7}",
  ".csn-copy[data-status=failed]{background:rgba(244,63,94,.14);border-color:rgba(251,113,133,.4);color:#fda4af}",
  ".csn button:focus-visible{outline:2px solid #7dd3fc;outline-offset:2px}",
  ".csn-t:focus-visible{border-radius:7px}",
  ".csn-copy:focus-visible{border-radius:9px}",
  ".csn-stack{display:grid}",
  ".csn-stack>*{grid-area:1/1;transition:opacity .22s ease,transform .3s cubic-bezier(.2,.8,.2,1),filter .22s ease}",
  ".csn-icons{width:16px;height:16px;place-items:center}",
  ".csn-stack>[data-on=false]{opacity:0;transform:translateY(5px) scale(.7);filter:blur(2px)}",
  ".csn-labels>[data-on=false]{transform:translateY(7px)}",
  ".csn-stack>[data-on=true]{opacity:1;transform:none;filter:none}",
  ".csn-check{stroke-dasharray:22;stroke-dashoffset:22;transition:stroke-dashoffset .32s cubic-bezier(.65,0,.35,1) .08s}",
  ".csn-stack>[data-on=true] .csn-check{stroke-dashoffset:0}",
  ".csn-scroll{overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none;-webkit-mask-image:linear-gradient(90deg,transparent 0,#000 var(--fl,0px),#000 calc(100% - var(--fr,0px)),transparent 100%);mask-image:linear-gradient(90deg,transparent 0,#000 var(--fl,0px),#000 calc(100% - var(--fr,0px)),transparent 100%)}",
  ".csn-scroll::-webkit-scrollbar{display:none}",
  ".csn-scroll:focus-visible{outline:2px solid #7dd3fc;outline-offset:-2px;border-radius:0}",
  ".csn-code{margin:0;padding:14px 0;width:max-content;min-width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace;font-size:13px;line-height:1.75;tab-size:2;animation:csn-in .28s cubic-bezier(.2,.8,.2,1) both}",
  ".csn-line{display:flex;align-items:baseline;padding:0 18px;white-space:pre;border-left:2px solid transparent;margin-left:0}",
  ".csn-line[data-hl=true]{background:linear-gradient(90deg,rgba(125,211,252,.12),rgba(125,211,252,.03));border-left-color:#7dd3fc}",
  ".csn-gut{flex:none;user-select:none;-webkit-user-select:none;color:#52525b;text-align:right;min-width:2ch;margin-right:16px}",
  ".csn-sweep{position:absolute;top:0;left:0;width:45%;height:1px;pointer-events:none;background:linear-gradient(90deg,transparent,#6ee7b7,transparent);box-shadow:0 0 14px 1px rgba(110,231,183,.55);animation:csn-sweep .85s cubic-bezier(.4,0,.2,1) both}",
  ".csn-wash{position:absolute;left:0;right:0;top:0;height:70%;pointer-events:none;background:linear-gradient(180deg,rgba(16,185,129,.12),transparent);animation:csn-wash 1s ease-out both}",
  "@keyframes csn-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}",
  "@keyframes csn-sweep{from{transform:translateX(-100%);opacity:1}80%{opacity:1}to{transform:translateX(230%);opacity:0}}",
  "@keyframes csn-wash{from{opacity:1}to{opacity:0}}",
  "@media (pointer:coarse){.csn-t,.csn-copy{min-height:40px}.csn-t{padding:0 13px}}",
  "@container (max-width:380px){.csn-lang{display:none}.csn-t{padding:0 10px}}",
  "@media (prefers-reduced-motion:reduce){.csn *,.csn *::before{animation:none!important;transition-duration:.01ms!important;transition-delay:0s!important}.csn-sweep,.csn-wash{display:none}.csn-stack>[data-on=false]{transform:none;filter:none}}",
].join("\n");

export function CopyCodeSnippet({
  code = "npx shadcn@latest add viberdy-dev/ui/copy-code-snippet",
  language = "bash",
  packageTabs = true,
  defaultTab = "npm",
  lineNumbers,
  highlight,
  className = "",
  onCopy,
}: {
  /** The snippet. Line breaks are kept exactly as given. */
  code?: string;
  /** Label pill and tokenizer hint (bash, ts, json, python ...). */
  language?: string;
  /** Show npm / pnpm / yarn / bun tabs when the snippet contains npx or npm lines. */
  packageTabs?: boolean;
  defaultTab?: PackageManager;
  /** Gutter numbers. Left undefined: on for multi-line code that is not a shell command. */
  lineNumbers?: boolean;
  /** 1-based line numbers to mark. */
  highlight?: number[];
  className?: string;
  onCopy?: (text: string) => void;
}) {
  const uid = useId();
  const scroller = useRef<HTMLDivElement>(null);
  const codeEl = useRef<HTMLPreElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const timer = useRef(0);
  const [pm, setPm] = useState<PackageManager>(defaultTab);
  const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
  const [pulse, setPulse] = useState(0);

  const lang = language.toLowerCase();
  const shell = CSN_SHELL.has(lang);
  const raw = code.replace(/\r\n/g, "\n").replace(/\n+$/, "");
  const hasTabs = packageTabs && shell && /^\s*(npx|npm)\s/m.test(raw);
  const active: PackageManager = hasTabs ? pm : "npm";
  const lines = raw.split("\n").map((l) => (hasTabs ? csnConvert(l, active) : l));
  const text = lines.join("\n");
  const marked = new Set(highlight ?? []);
  const numbered = lineNumbers ?? (!shell && lines.length > 1);
  const tabIndex = Math.max(0, CSN_MANAGERS.indexOf(active));
  const panelId = "csn-panel-" + uid.replace(/[^a-zA-Z0-9]/g, "");

  useEffect(() => {
    return () => window.clearTimeout(timer.current);
  }, []);

  // Edge fades follow how much code is hidden on each side.
  useEffect(() => {
    const el = scroller.current;
    if (!el) return;
    const update = () => {
      const max = el.scrollWidth - el.clientWidth;
      el.style.setProperty("--fl", Math.min(36, Math.max(0, el.scrollLeft)) + "px");
      el.style.setProperty("--fr", Math.min(36, Math.max(0, max - el.scrollLeft)) + "px");
      el.tabIndex = max > 1 ? 0 : -1;
    };
    update();
    el.addEventListener("scroll", update, { passive: true });
    let ro: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(update);
      ro.observe(el);
      if (el.firstElementChild) ro.observe(el.firstElementChild);
    }
    return () => {
      el.removeEventListener("scroll", update);
      ro?.disconnect();
    };
  }, [text, active]);

  async function copy() {
    const ok = await csnWrite(text);
    window.clearTimeout(timer.current);
    if (ok) {
      setStatus("copied");
      setPulse((n) => n + 1);
      onCopy?.(text);
    } else {
      setStatus("failed");
      const sel = window.getSelection();
      if (sel && codeEl.current) {
        const range = document.createRange();
        range.selectNodeContents(codeEl.current);
        sel.removeAllRanges();
        sel.addRange(range);
      }
    }
    timer.current = window.setTimeout(() => setStatus("idle"), ok ? 1900 : 2600);
  }

  function onMove(e: PointerEvent<HTMLDivElement>) {
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const k = r.width > 0 ? el.offsetWidth / r.width : 1;
    el.style.setProperty("--mx", (e.clientX - r.left) * k + "px");
    el.style.setProperty("--my", (e.clientY - r.top) * k + "px");
  }

  function onTabKey(e: KeyboardEvent<HTMLDivElement>) {
    let next = tabIndex;
    if (e.key === "ArrowRight") next = (tabIndex + 1) % CSN_MANAGERS.length;
    else if (e.key === "ArrowLeft") next = (tabIndex - 1 + CSN_MANAGERS.length) % CSN_MANAGERS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = CSN_MANAGERS.length - 1;
    else return;
    e.preventDefault();
    const target = CSN_MANAGERS[next];
    if (target) setPm(target);
    tabRefs.current[next]?.focus();
  }

  const label = hasTabs ? "Copy " + active + " command" : "Copy code";

  return (
    <>
      <style>{CSN_CSS}</style>
      <div
        onPointerMove={onMove}
        className={"csn w-full max-w-md overflow-hidden rounded-2xl " + className}
        style={{
          background: "linear-gradient(180deg,#101116,#0a0b0e)",
          boxShadow:
            "0 0 0 1px rgba(255,255,255,.09), inset 0 1px 0 rgba(255,255,255,.07), 0 1px 2px rgba(0,0,0,.25), 0 28px 60px -28px rgba(0,0,0,.65)",
        }}
      >
        <div aria-hidden className="csn-glow" />
        <div aria-hidden className="csn-ring" />
        {pulse > 0 && status === "copied" ? (
          <div key={pulse} aria-hidden>
            <div className="csn-wash" />
            <div className="csn-sweep" />
          </div>
        ) : null}

        <div className="csn-head">
          {hasTabs ? (
            <div role="tablist" aria-label="Package manager" className="csn-tabs" onKeyDown={onTabKey}>
              <span
                aria-hidden
                className="csn-pill"
                style={{
                  width: "calc((100% - 6px) / " + CSN_MANAGERS.length + ")",
                  transform: "translateX(" + tabIndex * 100 + "%)",
                }}
              />
              {CSN_MANAGERS.map((m, i) => (
                <button
                  key={m}
                  ref={(el) => {
                    tabRefs.current[i] = el;
                  }}
                  type="button"
                  role="tab"
                  id={panelId + "-t" + m}
                  aria-selected={m === active}
                  aria-controls={panelId}
                  tabIndex={m === active ? 0 : -1}
                  className="csn-t"
                  onClick={() => setPm(m)}
                >
                  {m}
                </button>
              ))}
            </div>
          ) : (
            <span className="csn-lang">{language}</span>
          )}
          <div className="csn-right">
            {hasTabs ? <span className="csn-lang">{language}</span> : null}
            <button type="button" className="csn-copy" data-status={status} aria-label={label} onClick={copy}>
              <span aria-hidden className="csn-stack csn-icons">
                <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" data-on={status === "idle"}>
                  <rect x="5.5" y="5.5" width="8" height="8" rx="2" />
                  <path d="M10.5 3.5v-.2A1.8 1.8 0 0 0 8.7 1.5H3.8A1.8 1.8 0 0 0 2 3.3v4.9a1.8 1.8 0 0 0 1.8 1.8h.2" />
                </svg>
                <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" data-on={status === "copied"}>
                  <path className="csn-check" pathLength={22} d="M3 8.4l3.2 3.2L13 4.6" />
                </svg>
                <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" data-on={status === "failed"}>
                  <path d="M4 4l8 8M12 4l-8 8" />
                </svg>
              </span>
              <span aria-hidden className="csn-stack csn-labels">
                <span data-on={status === "idle"}>Copy</span>
                <span data-on={status === "copied"}>Copied</span>
                <span data-on={status === "failed"}>Failed</span>
              </span>
            </button>
          </div>
        </div>

        <div id={panelId} role={hasTabs ? "tabpanel" : undefined} aria-labelledby={hasTabs ? panelId + "-t" + active : undefined}>
          <div ref={scroller} className="csn-scroll" role="region" aria-label={language + " code"}>
            <pre ref={codeEl} key={active} className="csn-code">
              {lines.map((line, i) => (
                <span key={i} className="csn-line" data-hl={marked.has(i + 1)}>
                  {numbered ? (
                    <span aria-hidden className="csn-gut">
                      {i + 1}
                    </span>
                  ) : shell && line.trim() !== "" && !line.trim().startsWith("#") ? (
                    <span aria-hidden className="csn-gut" style={{ minWidth: "1ch", marginRight: 10 }}>
                      $
                    </span>
                  ) : null}
                  <code style={{ font: "inherit" }}>
                    {(shell ? csnShell(line) : csnGeneric(line, CSN_HASH.has(lang))).map((tok, j) => (
                      <span key={j} style={{ color: tok.c, fontStyle: tok.i ? "italic" : undefined }}>
                        {tok.t}
                      </span>
                    ))}
                    {line === "" ? " " : null}
                  </code>
                </span>
              ))}
            </pre>
          </div>
        </div>

        <span className="sr-only" role="status" aria-live="polite">
          {status === "copied" ? "Copied to clipboard" : status === "failed" ? "Copy failed. The code is selected, press Ctrl+C." : ""}
        </span>
      </div>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

A copy button earns trust by being honest. Clicking it calls navigator.clipboard.writeText; if that is missing or rejected (an insecure page, a denied permission, an embedded frame) it falls back to a hidden textarea and execCommand, and only when one of the two succeeds does the button morph to a drawn check with an emerald sweep along the top edge. If both fail it says Failed and selects the code so Ctrl+C works. Paste an npx or npm command and the block grows npm, pnpm, yarn and bun tabs: the same lines are rewritten (npx becomes pnpm dlx, yarn dlx or bunx; npm install becomes add) and the copy button always copies the command in the tab you are looking at. The tabs slide a pill with a slight overshoot and the code swaps with a short rise. Tokens are tinted by a small built-in tokenizer: commands, flags, packages, strings, comments and numbers for shell, plus keywords, calls and JSON keys for other languages, with no highlighter library. Lines you list in highlight get a left rule and a wash. Long lines scroll sideways under edge fades that appear only on the side with more code, instead of clipping. A faint light follows the pointer along the border.

Curator’s note

Copy Code Snippet, rebuilt in October. The first version flipped to Copied even when the browser refused the copy; this one only confirms when the text really reached the clipboard, and needs nothing but React.

Pairs well with

Typewriter Text

text effects
Pro