{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typewriter",
  "type": "registry:block",
  "title": "Humanised Typewriter",
  "description": "A typed line that cycles phrases like a person at a keyboard: uneven keystrokes, noticed typos, an accelerating backspace or a word-by-word selection delete. The caret glides between positions on a spring and only blinks when idle.",
  "docs": "Humanised Typewriter was added to public/vividsites/typewriter.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/typewriter",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/typewriter.html",
      "type": "registry:file",
      "target": "public/vividsites/typewriter.html",
      "content": "<!--\n  vs-typewriter: a typed line that cycles phrases like a person at a keyboard: uneven keystrokes,\n  pauses after punctuation, the odd typo noticed and fixed, and two ways of deleting (an accelerating\n  backspace, or selecting word by word and clearing the lot). The caret holds solid while typing,\n  only blinks when idle, and GLIDES between positions on a spring instead of jumping, so a deleted\n  word pulls it back smoothly.\n\n  Markup: a span with data-vs-typewriter holding one child span per phrase. Without JS the first\n  phrase shows. Screen readers get all phrases once, in a visually hidden list; the animation is aria-hidden.\n    data-vs-typewriter-delete = \"mixed\" (default: alternates) | \"backspace\" | \"select\"\n    data-vs-typewriter-typos  = chance of a typo per letter          (0.035; 0 turns them off)\n    data-vs-typewriter-hold   = ms a finished phrase stays on screen (2200)\n    data-vs-typewriter-speed  = typing speed multiplier               (1; 2 = twice as fast)\n    data-vs-typewriter-caret  = \"bar\" (default) | \"block\" | \"under\"\n  API: el.vsTypewriter.next() skips to the next phrase. Emits \"vs-typewriter:stroke\" events\n       ({ detail: { cps } }) if you want to show typing speed.\n  Pauses when scrolled off-screen or the tab is hidden.\n\n  Variables:\n    --vs-typewriter-caret       caret colour            (currentColor)\n    --vs-typewriter-caret-w     caret width             (.07em)\n    --vs-typewriter-selection   selected-text highlight (rgba(255,90,31,.22))\n    --vs-typewriter-blink       blink period            (1.05s)\n  Attributes are read live, so they can be changed while it runs.\n  prefers-reduced-motion: phrases swap every few seconds with no typing.\n-->\n<style>\n  .vs-typewriter {\n    --vs-typewriter-caret: currentColor;\n    --vs-typewriter-caret-w: .07em;\n    --vs-typewriter-selection: rgba(255, 90, 31, .22);\n    --vs-typewriter-blink: 1.05s;\n  }\n  /* before JS runs, only the first phrase shows */\n  .vs-typewriter:not(.is-live) > span:not(:first-child) { display: none; }\n  .vs-typewriter-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n  .vs-typewriter-out { white-space: pre-wrap; }\n  .vs-typewriter-sel { background: var(--vs-typewriter-selection); border-radius: .06em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }\n  .vs-typewriter-caret {\n    display: inline-block; width: var(--vs-typewriter-caret-w); height: .92em; margin: 0 .02em 0 .03em;\n    vertical-align: -.1em; background: var(--vs-typewriter-caret); border-radius: 1px;\n  }\n  .vs-typewriter[data-vs-typewriter-caret=\"block\"] .vs-typewriter-caret { width: .52em; height: 1em; vertical-align: -.16em; opacity: .85; border-radius: .06em; }\n  .vs-typewriter[data-vs-typewriter-caret=\"under\"] .vs-typewriter-caret { width: .56em; height: .08em; vertical-align: -.12em; margin-left: .04em; }\n  .vs-typewriter-caret { will-change: transform; }\n  .vs-typewriter.is-idle .vs-typewriter-caret { animation: vs-typewriter-blink var(--vs-typewriter-blink) cubic-bezier(.6,0,.4,1) infinite; }\n  @keyframes vs-typewriter-blink { 0%, 42% { opacity: 1; } 50%, 92% { opacity: 0; } 100% { opacity: 1; } }\n  @media (prefers-reduced-motion: reduce) { .vs-typewriter.is-idle .vs-typewriter-caret { animation: none; } }\n</style>\n<script>\n(() => {\n  if (window.VsTypewriter) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  // QWERTY neighbours, for believable typos\n  const rows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];\n  const near = (ch) => {\n    const lo = ch.toLowerCase();\n    for (let r = 0; r < 3; r++) { const i = rows[r].indexOf(lo); if (i < 0) continue;\n      const opts = [rows[r][i - 1], rows[r][i + 1], (rows[r + 1] || '')[i], (rows[r - 1] || '')[i]].filter(Boolean);\n      const o = opts[(Math.random() * opts.length) | 0];\n      return ch === lo ? o : o.toUpperCase();\n    }\n    return null;\n  };\n\n  function mount(root) {\n    const phrases = [...root.children].map((c) => c.textContent);\n    if (!phrases.length) return;\n    const sr = document.createElement('span'); sr.className = 'vs-typewriter-sr'; sr.textContent = phrases.join(', ');\n    const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true');\n    const out = document.createElement('span'); out.className = 'vs-typewriter-out';\n    const caret = document.createElement('span'); caret.className = 'vs-typewriter-caret';\n    const sel = document.createElement('span'); sel.className = 'vs-typewriter-sel';\n    vis.append(out, caret, sel); root.textContent = ''; root.append(sr, vis); root.classList.add('is-live', 'is-idle');\n\n    const typos = parseFloat(root.dataset.vsTypewriterTypos ?? '0.035');\n    const hold = () => parseFloat(root.dataset.vsTypewriterHold ?? '2200');\n    const speed = () => Math.max(.2, parseFloat(root.dataset.vsTypewriterSpeed || '1') || 1);\n    let visible = true, skip = null, idx = 0, round = 0, lastStroke = 0;\n\n    // caret glide (FLIP): after each change, offset the caret back to where it was and spring it home\n    let cx = 0, cv = 0, craf = 0, cl = null;\n    const paintCaret = () => { caret.style.transform = cx ? `translate3d(${cx.toFixed(2)}px,0,0)` : ''; };\n    const glide = () => {\n      const x = caret.offsetLeft, y = caret.offsetTop;\n      if (reduced || !cl || Math.abs(y - cl.y) > 2) { cx = 0; cv = 0; } else cx += cl.x - x;\n      cl = { x, y };\n      paintCaret();\n      if (!craf && cx) {\n        let last = performance.now();\n        const tick = (now) => {\n          const dt = Math.min(.032, (now - last) / 1000); last = now;\n          for (let h = 0; h < dt; h += 1 / 240) { const st = Math.min(1 / 240, dt - h); cv += (-2200 * cx - 90 * cv) * st; cx += cv * st; }\n          if (Math.abs(cx) < .1 && Math.abs(cv) < 1) { cx = cv = 0; craf = 0; } else craf = requestAnimationFrame(tick);\n          paintCaret();\n        };\n        craf = requestAnimationFrame(tick);\n      }\n    };\n\n    // a sleep that stalls while off-screen and can be cut short by next()\n    const sleep = (ms) => new Promise((res) => {\n      let left = ms, t = performance.now();\n      const step = () => {\n        if (skip) { res(); return; }\n        const now = performance.now(); if (visible && !document.hidden) left -= now - t; t = now;\n        left <= 0 ? res() : setTimeout(step, Math.min(left, 60));\n      };\n      setTimeout(step, Math.min(ms, 60));\n    });\n    const idle = (on) => root.classList.toggle('is-idle', on);\n    const stroke = () => {\n      const now = performance.now(), dt = now - lastStroke; lastStroke = now;\n      if (dt < 600) root.dispatchEvent(new CustomEvent('vs-typewriter:stroke', { detail: { cps: 1000 / dt } }));\n    };\n    const put = (s) => { out.textContent = s; stroke(); glide(); };\n\n    async function type(ph) {\n      idle(false);\n      let s = out.textContent;\n      for (let i = 0; i < ph.length && !skip; i++) {\n        const ch = ph[i];\n        if (typos > 0 && /[a-z]/i.test(ch) && i > 1 && Math.random() < typos) {\n          const wrong = near(ch);\n          if (wrong) {\n            put(s + wrong); await sleep(90 + Math.random() * 70);\n            if (Math.random() < .5 && ph[i + 1]) { put(s + wrong + ph[i + 1]); await sleep(110); put(s + wrong); await sleep(70); }\n            await sleep(180 + Math.random() * 220);               // notices\n            put(s); await sleep(110 + Math.random() * 60);\n          }\n        }\n        s += ch; put(s);\n        let d = 38 + Math.random() * 64;                           // uneven hands\n        if (ch === ' ') d += 30 + Math.random() * 50;              // word boundary\n        if (/[,;:]/.test(ch)) d += 160;\n        if (/[.!?—]/.test(ch)) d += 280;\n        if (Math.random() < .06) d += 180;                          // a small hesitation\n        await sleep(d / speed());\n      }\n      idle(true);\n    }\n    async function backspace() {\n      idle(false);\n      let s = out.textContent, n = 0;\n      while (s.length && !skip) {\n        s = s.slice(0, -1); put(s);\n        await sleep(Math.max(22, 95 * Math.pow(.86, n++)) / speed());   // key repeat that speeds up\n      }\n    }\n    async function select() {\n      idle(false);\n      let s = out.textContent;\n      // shift+option+left: grow the selection one word at a time\n      while (s.length && !skip) {\n        const m = s.match(/(\\s*\\S+\\s*)$/); const cut = m ? m[1].length : s.length;\n        sel.textContent = s.slice(-cut) + sel.textContent; s = s.slice(0, -cut); out.textContent = s; stroke(); glide();\n        await sleep((95 + Math.random() * 45) / speed());\n      }\n      await sleep(260);\n      sel.textContent = ''; stroke(); glide();\n    }\n\n    async function loop() {\n      if (reduced) {\n        out.textContent = phrases[0];\n        setInterval(() => { idx = (idx + 1) % phrases.length; out.textContent = phrases[idx]; }, 3200);\n        return;\n      }\n      await sleep(450);\n      for (;;) {\n        await type(phrases[idx].slice(out.textContent.length));\n        if (!skip) await sleep(hold());\n        const mode = root.dataset.vsTypewriterDelete || 'mixed';\n        const how = mode === 'mixed' ? (round % 2 ? 'select' : 'backspace') : mode;\n        skip = null;\n        await (how === 'select' ? select() : backspace());\n        skip = null; out.textContent = ''; sel.textContent = ''; glide();\n        idle(true); await sleep(380);\n        idx = (idx + 1) % phrases.length; round++;\n      }\n    }\n\n    new IntersectionObserver(([e]) => { visible = e.isIntersecting; }).observe(root);\n    root.vsTypewriter = { next: () => { skip = true; } };\n    loop();\n  }\n\n  window.VsTypewriter = { mount };\n  const init = () => document.querySelectorAll('[data-vs-typewriter]').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#e9edf2;color:#0e1726;padding:14vh 7vw;font:400 clamp(2.2rem,6vw,5rem)/1.04 'Space Grotesk',sans-serif;letter-spacing:-.04em\">\n  <span style=\"opacity:.45\">Made for</span><br>\n  <span class=\"vs-typewriter\" data-vs-typewriter style=\"--vs-typewriter-caret:#ff4d1f;--vs-typewriter-selection:rgba(255,77,31,.2);color:#ff4d1f\">\n    <span>field biologists.</span><span>sound recordists.</span><span>people who draw maps by hand.</span>\n  </span>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "typewriter",
      "typing",
      "caret",
      "rotating text",
      "hero",
      "text effect"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#ff4d1f",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/typewriter/index.html",
    "thumbnail": "https://vividsites.app/api/component/typewriter/thumb.jpg",
    "video": "https://vividsites.app/api/component/typewriter/thumb.mp4",
    "page": "https://vividsites.app/c/typewriter"
  }
}