{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy button",
  "description": "A key that copies a command shown on LCD glass: the key sinks its 2px, the check draws in, and the command's glyphs decode back in a wave. A Device part.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/copy-button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/copy-button 2026.11.08. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Copy button: a key that copies a command, and the LCD line that shows it (proposals/device-studies.md, Copy button).\n * A Device part: it reads the key and field tokens every material sets, so it is only at home under\n * data-material=\"device\" (components.md, \"Copy button\").\n *\n * - Anatomy: a key (2.9em square: its face --litt-block, its skirt and rim --litt-key-lift) beside a line of LCD glass (--litt-field,\n *   --litt-field-ink, --litt-field-shade) that shows the command in the mono, after a prompt in --litt-field-ink-subtle. The key is\n *   the only control; the line is only a picture of what it copies.\n * - Press: the key sinks the whole --litt-press-depth (2px under Device) onto its skirt, and --litt-block-press replaces its lift,\n *   at once. Under Press and Flat the same depth is half a pixel and nothing, so it stays quiet there.\n * - Copied: the copy glyph crossfades to a check (opacity and scale on the responsive tier) and the check draws in, its\n *   stroke offset running from 1 to 0 after a short delay. The state is said in words (R5): the line reads \"Copied\" for\n *   the hold, and a polite status says it to a screen reader. Then it goes back by itself.\n * - The glitch wave: a copy clears the command from the line right to left, and it decodes back in left to right\n *   once the hold ends. Each character shows glyphs from the display's ROM (half-width katakana, digits and :=*+<>)\n *   for 140 to 340ms, swapping every 35 to 85ms at a flicker of 0.6 to 1.0, fading out or in as it settles. The\n *   whole wave takes at most 520ms. A hidden character keeps its width, so the line never moves. A second copy\n *   mid-wave reverses it from where it is. Every frame writes textContent, visibility and opacity on the spans and\n *   never React state, and the loop stops when every character has settled.\n * - Nothing moves on its own (R4): no rehearsal on mount, no sound in this wave. Reduced motion swaps the glyphs at once\n *   and draws nothing.\n * - A failed clipboard write selects the command on the line instead, so it can be copied by hand, and says so in\n *   the status.\n * - Accessibility: the key is a button named \"Copy command\" (or `label`); the command is real text for a screen reader\n *   and the glyph spans are hidden from it.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { animate } from \"motion/react\";\nimport { useMotionDial, useTransition } from \"../../lib/litt/motion\";\n\nexport type CopyButtonProps = {\n  /** What a press puts on the clipboard. */\n  text: string;\n  /** The key's name for a screen reader. */\n  label?: string;\n  /** What the line shows. Default: the text itself. Pass a shorter or prettier form when the text is long. */\n  command?: string;\n  className?: string;\n};\n\nconst HOLD_MS = 1800; // how long the key says Copied\nconst WAVE_MS = 520; // the longest a whole wave takes\nconst GLITCH_MIN = 140; // a character glitches 140 to 340ms\nconst GLITCH_SPREAD = 200;\nconst MAX_GAP_MS = 18; // the most one character waits for its neighbour\nconst CHECK_DELAY = 0.075; // seconds before the check starts to draw\nconst GLYPHS = [...\"ｱｲｳｴｵｶｷｸｹｺｻｼｽｾｿﾀﾁﾂﾃﾄﾅﾆﾇﾈﾉﾊﾋﾌﾍﾎﾏﾐﾑﾒﾓﾔﾕﾖﾗﾘﾙﾚﾛﾜﾝ0123456789:=*+<>\"];\n\n/* ---------------------------------------------------------------- the wave */\n\ntype Cell = {\n  el: HTMLSpanElement;\n  ch: string;\n  /** Whitespace takes no part in the wave. */\n  live: boolean;\n  /** Where the cell is heading: shown or hidden. */\n  target: boolean;\n  mode: \"idle\" | \"in\" | \"out\";\n  /** Progress through the glitch, 0 to 1, and the clock it is measured from. */\n  p: number;\n  p0: number;\n  t0: number;\n  dur: number;\n  next: number;\n  flick: number;\n  text: string;\n  vis: string;\n  op: string;\n};\n\ntype Wave = {\n  /** Clear the line, right to left. */\n  hide: () => void;\n  /** Decode the line back in, left to right. */\n  show: () => void;\n  destroy: () => void;\n};\n\nconst quantise = (a: number) => Math.round(a * 10) / 10;\n\n/** The wave over the spans in `line`. `edge` hears true when the line has finished clearing and false the moment it starts back. */\nfunction makeWave(line: HTMLElement, reduced: () => boolean, edge: (hidden: boolean) => void): Wave {\n  const cells: Cell[] = [...line.children].map((el) => {\n    const ch = el.textContent ?? \"\";\n    return { el: el as HTMLSpanElement, ch, live: ch.trim() !== \"\", target: true, mode: \"idle\", p: 1, p0: 0, t0: 0, dur: 0, next: 0, flick: 1, text: ch, vis: \"\", op: \"\" };\n  });\n  let frame = 0;\n\n  const write = (c: Cell, text: string, vis: string, op: string) => {\n    if (c.text !== text) c.el.textContent = c.text = text;\n    if (c.vis !== vis) c.el.style.visibility = c.vis = vis;\n    if (c.op !== op) c.el.style.opacity = c.op = op;\n  };\n  const settle = (c: Cell) => {\n    c.mode = \"idle\";\n    c.p = 1;\n    write(c, c.ch, c.target ? \"\" : \"hidden\", \"\");\n  };\n\n  const tick = (now: number) => {\n    frame = 0;\n    let active = false;\n    for (const c of cells) {\n      if (c.mode === \"idle\") continue;\n      active = true;\n      if (now < c.t0) continue; // still waiting its turn: it holds how it was\n      c.p = Math.min(1, c.p0 + (now - c.t0) / c.dur);\n      if (c.p >= 1) {\n        settle(c);\n        continue;\n      }\n      let text = c.text;\n      if (now >= c.next || !GLYPHS.includes(text)) {\n        text = GLYPHS[Math.floor(Math.random() * GLYPHS.length)]!;\n        c.next = now + 35 + Math.random() * 50;\n        c.flick = 0.6 + Math.random() * 0.4;\n      }\n      const alpha = c.flick * (c.mode === \"out\" ? 1 - 0.7 * c.p : 0.35 + 0.65 * c.p);\n      write(c, text, \"\", String(quantise(alpha)));\n    }\n    if (active) frame = requestAnimationFrame(tick);\n    else edge(cells.every((c) => !c.live || !c.target));\n  };\n\n  const run = (showing: boolean) => {\n    const now = performance.now();\n    const order = (showing ? cells : [...cells].reverse()).filter((c) => c.live);\n    const gap = order.length > 1 ? Math.min(MAX_GAP_MS, (WAVE_MS - GLITCH_MIN - GLITCH_SPREAD) / (order.length - 1)) : 0;\n    if (showing) edge(false);\n    let moved = false;\n    order.forEach((c, rank) => {\n      if (c.target === showing) return;\n      c.target = showing;\n      moved = true;\n      if (reduced()) return settle(c);\n      if (c.mode === \"idle\") {\n        c.p = 0;\n        c.p0 = 0;\n        c.t0 = now + rank * gap;\n        c.dur = GLITCH_MIN + Math.random() * GLITCH_SPREAD;\n        c.next = 0;\n      } else {\n        c.p0 = 1 - c.p; // reverse from where it is\n        c.t0 = now;\n      }\n      c.mode = showing ? \"in\" : \"out\";\n    });\n    if (!moved) return;\n    if (reduced()) edge(!showing);\n    else if (!frame) frame = requestAnimationFrame(tick);\n  };\n\n  return {\n    hide: () => run(false),\n    show: () => run(true),\n    destroy: () => cancelAnimationFrame(frame),\n  };\n}\n\n/* ---------------------------------------------------------------- the clipboard */\n\n/** The old route, for pages without the async clipboard API. It must run inside the click. */\nfunction copyBySelection(text: string): boolean {\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.position = \"fixed\";\n  area.style.opacity = \"0\";\n  document.body.appendChild(area);\n  area.select();\n  try {\n    return document.execCommand(\"copy\");\n  } catch {\n    return false;\n  } finally {\n    area.remove();\n  }\n}\n\n/* ---------------------------------------------------------------- the glyphs */\n\nconst glyph = \"col-start-1 row-start-1 size-4 transition-[opacity,scale] duration-(--litt-dur-responsive) ease-litt-responsive\";\n\n/** A key that copies `text`, beside an LCD line that shows the command and decodes it back in after the copy. */\nexport function CopyButton({ text, label = \"Copy command\", command, className = \"\" }: CopyButtonProps) {\n  const shown = command ?? text;\n  const [state, setState] = useState<\"idle\" | \"copied\" | \"failed\">(\"idle\");\n  const { reduced } = useMotionDial();\n  const move = useTransition(\"responsive\");\n\n  const line = useRef<HTMLSpanElement>(null);\n  const word = useRef<HTMLSpanElement>(null);\n  const check = useRef<SVGPathElement>(null);\n  const wave = useRef<Wave | null>(null);\n  const hold = useRef(0);\n  const reducedNow = useRef(reduced);\n  reducedNow.current = reduced;\n\n  /* The wave reads its spans from the DOM, so it is built after the spans are, and again if the command changes. */\n  useLayoutEffect(() => {\n    const el = line.current;\n    if (!el) return;\n    [...el.children].forEach((c, i) => {\n      c.textContent = [...shown][i] ?? \"\";\n      (c as HTMLElement).style.visibility = \"\";\n      (c as HTMLElement).style.opacity = \"\";\n    });\n    word.current?.removeAttribute(\"data-on\");\n    const w = makeWave(el, () => reducedNow.current, (hidden) => {\n      if (hidden) word.current?.setAttribute(\"data-on\", \"\");\n      else word.current?.removeAttribute(\"data-on\");\n    });\n    wave.current = w;\n    return () => {\n      w.destroy();\n      wave.current = null;\n    };\n  }, [shown]);\n\n  useEffect(() => () => window.clearTimeout(hold.current), []);\n\n  /* The check draws in when the key says Copied. */\n  useEffect(() => {\n    const path = check.current;\n    if (state !== \"copied\" || !path) return;\n    if (reduced) {\n      path.style.strokeDashoffset = \"0\";\n      return;\n    }\n    const run = animate(path, { strokeDashoffset: [1, 0] }, { ...move, delay: CHECK_DELAY });\n    return () => run.stop();\n  }, [state, reduced, move]);\n\n  const copied = () => {\n    setState(\"copied\");\n    wave.current?.hide();\n    window.clearTimeout(hold.current);\n    hold.current = window.setTimeout(() => {\n      setState(\"idle\");\n      wave.current?.show();\n    }, HOLD_MS);\n  };\n  const failed = () => {\n    const box = line.current;\n    if (box) window.getSelection()?.selectAllChildren(box);\n    setState(\"failed\");\n  };\n  const press = () => {\n    if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(copied, () => (copyBySelection(text) ? copied() : failed()));\n    else if (copyBySelection(text)) copied();\n    else failed();\n  };\n\n  const done = state === \"copied\";\n  return (\n    <span className={\"inline-flex w-full max-w-lg items-center gap-3 text-litt-body \" + className}>\n      <button\n        type=\"button\"\n        aria-label={label}\n        onClick={press}\n        className={\n          \"litt-target relative grid size-[2.9em] shrink-0 cursor-pointer select-none place-items-center rounded-litt-control bg-litt-block text-litt-fg shadow-(--litt-key-lift) forced-colors:border \" +\n          \"transition-[background-color,color,translate] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent] \" +\n          \"hover:bg-(--litt-block-hover) active:translate-y-(--litt-press-depth) active:shadow-(--litt-block-press) \" +\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\"\n        }\n      >\n        <span aria-hidden=\"true\" className=\"grid place-items-center\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.3} strokeLinejoin=\"round\" className={glyph + (done ? \" scale-95 opacity-0\" : \" scale-100 opacity-100\")}>\n            <rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1.5\" />\n            <path d=\"M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5\" />\n          </svg>\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={glyph + (done ? \" scale-100 opacity-100\" : \" scale-95 opacity-0\")}>\n            <path ref={check} d=\"m3 8.5 3.2 3L13 4.5\" pathLength={1} strokeDasharray={1} strokeDashoffset={1} />\n          </svg>\n        </span>\n      </button>\n\n      <span className=\"relative flex min-h-[2.9em] min-w-0 flex-1 items-start gap-2 rounded-litt-control bg-litt-field px-3 py-3 font-litt-mono text-litt-small font-normal text-litt-field-ink shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)]\">\n        <span aria-hidden=\"true\" className=\"select-none text-litt-field-ink-subtle\">\n          $\n        </span>\n        <span className=\"sr-only\">{shown}</span>\n        <span className=\"relative min-w-0 flex-1\">\n          <span ref={line} aria-hidden=\"true\" className=\"block break-all whitespace-pre-wrap\">\n            {[...shown].map((ch, i) => (\n              <span key={i} suppressHydrationWarning className=\"inline-block w-[1ch] text-center whitespace-pre\">\n                {ch}\n              </span>\n            ))}\n          </span>\n          <span\n            ref={word}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 left-0 opacity-0 transition-opacity duration-(--litt-dur-instant) ease-litt data-[on]:opacity-100\"\n          >\n            Copied\n          </span>\n        </span>\n      </span>\n\n      <span role=\"status\" className=\"sr-only\">\n        {state === \"copied\" ? \"Copied\" : state === \"failed\" ? \"Copy failed. The command is selected; copy it by hand.\" : \"\"}\n      </span>\n    </span>\n  );\n}\n",
      "target": "components/litt/copy-button.tsx"
    }
  ],
  "docs": "Needs data-material=\"device\" on <html>. Pass the text to copy; it says Copied for a moment and goes back. Under reduced motion the glyphs swap at once.",
  "meta": {
    "version": "2026.11.08",
    "released": "2026-11-08",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
