{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thinking-text",
  "type": "registry:block",
  "title": "Thinking Text",
  "description": "The line an AI answer shows while it reasons: a shimmering label that swaps to each step as it arrives, a live timer, and a step timeline that grows on a spring and folds away into \"Thought for 9s\" when done.",
  "docs": "Thinking Text was added to public/vividsites/thinking-text.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/thinking-text",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/thinking-text.html",
      "type": "registry:file",
      "target": "public/vividsites/thinking-text.html",
      "content": "<!--\nvs-thinking-text: the \"thinking\" line an AI answer shows while it reasons. The header label\n  shimmers, then swaps (blur-rise) to each step's title as it arrives; a live timer counts\n  seconds; a timeline of steps grows beneath on a spring. When done, the label settles to\n  \"Thought for 9s\", the shimmer stops and the steps fold away. Click the header to reopen.\n\n  Markup (declarative demo loop):\n    <div class=\"vs-thinking-text\" data-demo data-interval=\"1500\">\n      <ol hidden><li data-detail=\"optional second line\">Step title</li>…</ol>\n    </div>\n  Markup (driven by your model): <div class=\"vs-thinking-text\"></div>\n  Attributes:\n    data-demo           loops through the <ol> steps (for previews and empty states)\n    data-interval       ms between demo steps (default 1500)\n    data-label          the opening label (default \"Thinking\")\n    data-done-label     done label template, {s} = seconds (default \"Thought for {s}s\")\n    data-autocollapse   \"false\" keeps the steps open when done\n  JS:   const t = vsThinkingText.mount(el, { autoCollapse, doneLabel })\n        t.start(label?) · t.step(title, detail?) · t.done(label?) · t.toggle(open?) · t.reset()\n  Events: \"vs-thinking-text:done\" {seconds}, \"vs-thinking-text:toggle\" {open}\n  Theme with the CSS variables below. Reduced motion: no sweep, no springs.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Mulish:wght@400;600&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-thinking-text\" data-demo data-interval=\"1500\">\n  <ol hidden>\n    <li data-detail=\"A 38 m² garden studio, insulated in 2019, winter lows near −6 °C.\">Reading the question</li>\n    <li data-detail=\"Heat loss comes out around 2.4 kW at the design temperature.\">Estimating heat loss</li>\n    <li data-detail=\"Heat pump at a seasonal COP of 3.1 against a gas boiler at 89%.\">Comparing running costs</li>\n    <li data-detail=\"With the install grant, the pump pays for itself in year six.\">Checking the payback</li>\n  </ol>\n</div>\n\n<style>\n.vs-thinking-text {\n  --vs-thinking-text-ink: #1c1a17;                 /* text + finished nodes */\n  --vs-thinking-text-shine: #3355ff;               /* the colour of the sweeping band */\n  --vs-thinking-text-dim: color-mix(in srgb, var(--vs-thinking-text-ink) 44%, transparent); /* shimmer base */\n  --vs-thinking-text-accent: #3355ff;              /* the live step's node */\n  --vs-thinking-text-speed: 1.9s;                  /* one sweep of the shimmer */\n  --vs-thinking-text-size: 3.25rem;                /* the header label */\n  --vs-thinking-text-text: 1rem;                   /* the steps (everything below the header scales with it) */\n  --vs-thinking-text-display: \"Instrument Serif\", \"Iowan Old Style\", Georgia, serif;\n  --vs-thinking-text-font: \"Mulish\", ui-sans-serif, system-ui, sans-serif;\n\n  position: relative; width: 100%; max-width: 42em; color: var(--vs-thinking-text-ink); font-size: var(--vs-thinking-text-text);\n  font-family: var(--vs-thinking-text-font); -webkit-font-smoothing: antialiased;\n}\n.vs-thinking-text, .vs-thinking-text * { box-sizing: border-box; }\n\n/* header: [label][timer][chevron] */\n.vs-thinking-text-head {\n  all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: .9rem; max-width: 100%;\n  padding: .2rem .8rem .2rem .7rem; margin-left: -.7rem; border-radius: 1rem; cursor: pointer;\n  -webkit-tap-highlight-color: transparent; transition: background-color .18s cubic-bezier(.2,0,0,1);\n}\n.vs-thinking-text-head:hover { background: color-mix(in srgb, var(--vs-thinking-text-ink) 4.5%, transparent); }\n.vs-thinking-text-head:active { background: color-mix(in srgb, var(--vs-thinking-text-ink) 8%, transparent); }\n.vs-thinking-text-head:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--vs-thinking-text-accent) 70%, transparent); }\n.vs-thinking-text-head[aria-disabled=\"true\"] { cursor: default; background: none; }\n\n.vs-thinking-text-labels {\n  position: relative; display: block; flex: none; height: 1.15em;\n  font: 400 var(--vs-thinking-text-size)/1.15 var(--vs-thinking-text-display); letter-spacing: -.012em;\n}\n.vs-thinking-text-label {\n  position: absolute; left: 0; top: 0; white-space: nowrap; padding-right: .06em;\n  transition: opacity .42s cubic-bezier(.16,1,.3,1), transform .6s cubic-bezier(.16,1,.3,1), filter .5s cubic-bezier(.16,1,.3,1);\n  will-change: transform, opacity, filter; transition-delay: .07s;\n}\n.vs-thinking-text-label.is-in  { transition-delay: 0s; opacity: 0; transform: translate3d(0,.34em,0); filter: blur(7px); }\n.vs-thinking-text-label.is-out { opacity: 0; transform: translate3d(0,-.3em,0); filter: blur(7px); transition-duration: .3s, .45s, .35s; }\n.vs-thinking-text-label em { font-style: italic; }\n\n/* the shimmer: a dim base with a bright band swept across the glyphs */\n.vs-thinking-text-label.is-shimmer {\n  color: transparent;\n  background: linear-gradient(100deg, var(--vs-thinking-text-dim) 0 34%, var(--vs-thinking-text-shine) 50%, var(--vs-thinking-text-dim) 66% 100%);\n  background-size: 260% 100%; background-position: 100% 0;\n  -webkit-background-clip: text; background-clip: text;\n  animation: vs-thinking-text-sweep var(--vs-thinking-text-speed) cubic-bezier(.45,.05,.4,.95) infinite;\n}\n@keyframes vs-thinking-text-sweep { from { background-position: 100% 0; } to { background-position: -30% 0; } }\n\n.vs-thinking-text-time {\n  font: 500 .9375em/1 var(--vs-thinking-text-font); font-variant-numeric: tabular-nums; letter-spacing: .01em;\n  color: color-mix(in srgb, var(--vs-thinking-text-ink) 48%, transparent);\n  display: inline-block; flex: none; overflow: hidden; max-width: 3.5em; white-space: nowrap;\n  transition: opacity .3s cubic-bezier(.2,0,0,1), max-width .55s cubic-bezier(.16,1,.3,1), margin .55s cubic-bezier(.16,1,.3,1);\n}\n.vs-thinking-text[data-state=\"done\"] .vs-thinking-text-time,\n.vs-thinking-text[data-state=\"idle\"] .vs-thinking-text-time { opacity: 0; max-width: 0; margin-right: -.55rem; }\n.vs-thinking-text-chev {\n  width: .95rem; height: .95rem; flex: none; margin-left: -.35rem;\n  color: color-mix(in srgb, var(--vs-thinking-text-ink) 50%, transparent);\n  transition: transform .5s cubic-bezier(.3,1.4,.5,1), color .18s;\n}\n.vs-thinking-text-head:hover .vs-thinking-text-chev { color: var(--vs-thinking-text-ink); }\n.vs-thinking-text-head[aria-expanded=\"true\"] .vs-thinking-text-chev { transform: rotate(90deg); }\n\n/* body: height rides a spring (set by the script) */\n.vs-thinking-text-body { overflow: hidden; height: 0; }\n.vs-thinking-text-steps {\n  list-style: none; margin: 0; padding: 1.1em 0 .4em .1em; display: grid; gap: 0;\n  transition: opacity .35s cubic-bezier(.2,0,0,1);\n}\n.vs-thinking-text[data-open=\"false\"] .vs-thinking-text-steps { opacity: 0; }\n.vs-thinking-text-step {\n  position: relative; display: grid; grid-template-columns: 1.5em 1fr; column-gap: .85em; padding-bottom: 1.05em;\n}\n.vs-thinking-text-step:last-child { padding-bottom: 0; }\n/* the thread between nodes */\n.vs-thinking-text-step::before {\n  content: \"\"; position: absolute; left: calc(.75em - .5px); top: 1.45em; bottom: .15em; width: 1px;\n  background: color-mix(in srgb, var(--vs-thinking-text-ink) 16%, transparent);\n  transform-origin: top; transform: scaleY(0); transition: transform .7s cubic-bezier(.16,1,.3,1) .1s;\n}\n.vs-thinking-text-step[data-state=\"done\"]:not(:last-child)::before { transform: scaleY(1); }\n.vs-thinking-text-node { position: relative; width: 1.5em; height: 1.5em; display: grid; place-items: center; }\n.vs-thinking-text-node::before, .vs-thinking-text-node::after { content: \"\"; position: absolute; border-radius: 50%; }\n.vs-thinking-text-node::before {\n  width: .5em; height: .5em; background: var(--vs-thinking-text-ink);\n  transition: background-color .3s, transform .5s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-thinking-text-step[data-state=\"active\"] .vs-thinking-text-node::before { background: var(--vs-thinking-text-accent); transform: scale(1.12); }\n.vs-thinking-text-node::after {\n  inset: .2em; border: 1px solid var(--vs-thinking-text-accent); opacity: 0;\n}\n.vs-thinking-text-step[data-state=\"active\"] .vs-thinking-text-node::after { animation: vs-thinking-text-ping 1.6s cubic-bezier(.16,1,.3,1) infinite; }\n@keyframes vs-thinking-text-ping { 0% { opacity: .7; transform: scale(.45); } 100% { opacity: 0; transform: scale(1.35); } }\n\n.vs-thinking-text-title {\n  margin: 0; padding-top: .12em; font: 600 1.125em/1.35 var(--vs-thinking-text-font); letter-spacing: -.005em;\n  transition: opacity .5s cubic-bezier(.16,1,.3,1), transform .65s cubic-bezier(.16,1,.3,1), filter .5s cubic-bezier(.16,1,.3,1);\n}\n.vs-thinking-text-detail {\n  margin: .22em 0 0; font: 400 1.03125em/1.5 var(--vs-thinking-text-font); text-wrap: pretty;\n  color: color-mix(in srgb, var(--vs-thinking-text-ink) 58%, transparent);\n}\n.vs-thinking-text-detail span {\n  display: inline-block;\n  transition: opacity .45s cubic-bezier(.16,1,.3,1), transform .55s cubic-bezier(.16,1,.3,1), filter .45s cubic-bezier(.16,1,.3,1);\n  transition-delay: calc(var(--i) * 28ms + 140ms);\n}\n.vs-thinking-text-step.is-new .vs-thinking-text-title { opacity: 0; transform: translate3d(0,.5em,0); filter: blur(6px); }\n.vs-thinking-text-step.is-new .vs-thinking-text-detail span { opacity: 0; transform: translate3d(0,.35em,0); filter: blur(4px); }\n.vs-thinking-text-step.is-new .vs-thinking-text-node { opacity: 0; transform: scale(.4); }\n.vs-thinking-text-node { transition: opacity .4s cubic-bezier(.16,1,.3,1), transform .55s cubic-bezier(.3,1.5,.5,1); }\n\n.vs-thinking-text-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 600px) { .vs-thinking-text { --vs-thinking-text-size: 2.1rem; } .vs-thinking-text-head { gap: .7rem; } .vs-thinking-text[data-state=\"done\"] .vs-thinking-text-time, .vs-thinking-text[data-state=\"idle\"] .vs-thinking-text-time { margin-right: -.4rem; } }\n@media (prefers-reduced-motion: reduce) {\n  .vs-thinking-text-label.is-shimmer { animation: none; background-position: 50% 0; }\n  .vs-thinking-text *, .vs-thinking-text *::before { transition-duration: 0s !important; transition-delay: 0s !important; }\n  .vs-thinking-text-step[data-state=\"active\"] .vs-thinking-text-node::after { animation: none; }\n}\n@media (forced-colors: active) { .vs-thinking-text-label.is-shimmer { color: CanvasText; background: none; } }\n.vs-thinking-text.is-offscreen * { animation-play-state: paused !important; }\n</style>\n\n<script>\n(() => {\n  if (window.vsThinkingText) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  // one shared rAF ticker that sleeps when nothing moves\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const f of live) if (!f(dt)) live.delete(f);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = f => { live.add(f); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  // critically-damped-ish spring in px; interruptible (keeps velocity when retargeted)\n  function spring(x, k, c, out) {\n    const s = { x, v: 0, g: x };\n    const f = dt => {\n      if (reduce.matches) { s.x = s.g; s.v = 0; out(s.x); return false; }\n      const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { s.v += (-k * (s.x - s.g) - c * s.v) * h; s.x += s.v * h; }\n      const rest = Math.abs(s.v) < .02 && Math.abs(s.x - s.g) < .1;\n      if (rest) { s.x = s.g; s.v = 0; }\n      out(s.x); return !rest;\n    };\n    s.to = g => { s.g = g; wake(f); };\n    s.jump = g => { s.x = s.g = g; s.v = 0; out(g); };\n    return s;\n  }\n  const esc = t => t.replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n  let uid = 0;\n\n  function mount(el, opts = {}) {\n    if (el.__vsTT) return el.__vsTT;\n    const src = el.querySelector('ol');\n    const script = src ? [...src.children].map(li => ({ title: li.textContent.trim(), detail: li.dataset.detail || '' })) : [];\n    const id = 'vs-thinking-text-' + (++uid);\n    el.innerHTML =\n      `<button class=\"vs-thinking-text-head\" type=\"button\" aria-expanded=\"true\" aria-controls=\"${id}\">` +\n        '<span class=\"vs-thinking-text-labels\"></span>' +\n        '<span class=\"vs-thinking-text-time\" aria-hidden=\"true\">0s</span>' +\n        '<svg class=\"vs-thinking-text-chev\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M6 3.5 10.5 8 6 12.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>' +\n      '</button>' +\n      `<div class=\"vs-thinking-text-body\" id=\"${id}\"><ol class=\"vs-thinking-text-steps\"></ol></div>` +\n      '<span class=\"vs-thinking-text-sr\" aria-live=\"polite\"></span>';\n    const head = el.firstChild, labels = head.firstChild, time = labels.nextSibling;\n    const body = head.nextSibling, list = body.firstChild, sr = el.lastChild;\n    const cfg = () => ({\n      autoCollapse: (opts.autoCollapse ?? el.dataset.autocollapse) !== 'false' && opts.autoCollapse !== false,\n      doneLabel: opts.doneLabel || el.dataset.doneLabel || 'Thought for {s}s',\n    });\n\n    let state = 'idle', open = true, t0 = 0, timer = 0, seconds = 0;\n    const W = spring(0, 420, 41, v => { labels.style.width = v.toFixed(2) + 'px'; });\n    const H = spring(0, 190, 27, v => { body.style.height = v.toFixed(2) + 'px'; });\n    const fit = () => H.to(open ? list.offsetHeight : 0);\n    const refit = () => { const c = [...labels.children].filter(n => !n.classList.contains('is-out')).pop(); if (c) W.to(c.offsetWidth); };\n    document.fonts && document.fonts.ready.then(refit);\n    new ResizeObserver(fit).observe(list);\n\n    function label(text, shimmer) {\n      const prev = [...labels.children].filter(n => !n.classList.contains('is-out'));\n      const cur = prev[prev.length - 1];\n      if (cur && cur.textContent === text) { cur.classList.toggle('is-shimmer', shimmer); return; }\n      const n = document.createElement('span');\n      n.className = 'vs-thinking-text-label is-in' + (shimmer ? ' is-shimmer' : '');\n      n.textContent = text; labels.append(n);\n      const w = n.offsetWidth;\n      if (!labels.style.width) W.jump(w); else W.to(w);\n      requestAnimationFrame(() => requestAnimationFrame(() => n.classList.remove('is-in')));\n      prev.forEach(p => { p.classList.add('is-out'); setTimeout(() => p.remove(), 480); });\n    }\n    function setOpen(v, silent) {\n      open = v; el.dataset.open = String(v); head.setAttribute('aria-expanded', String(v)); fit();\n      if (!silent) el.dispatchEvent(new CustomEvent('vs-thinking-text:toggle', { bubbles: true, detail: { open: v } }));\n    }\n    const clock = () => { seconds = Math.floor((performance.now() - t0) / 1000); time.textContent = seconds + 's'; };\n\n    const api = {\n      get state() { return state; }, get seconds() { return seconds; },\n      start(text = el.dataset.label || 'Thinking') {\n        list.innerHTML = ''; state = 'thinking'; el.dataset.state = state; t0 = performance.now(); clock();\n        clearInterval(timer); timer = setInterval(clock, 250);\n        label(text, true); setOpen(true, true); sr.textContent = text;\n        return api;\n      },\n      step(title, detail = '') {\n        if (state !== 'thinking') api.start();\n        list.querySelectorAll('[data-state=\"active\"]').forEach(li => li.dataset.state = 'done');\n        const li = document.createElement('li');\n        li.className = 'vs-thinking-text-step is-new'; li.dataset.state = 'active';\n        const words = detail ? detail.split(/\\s+/).filter(Boolean) : [];\n        li.innerHTML = '<span class=\"vs-thinking-text-node\" aria-hidden=\"true\"></span><div>' +\n          `<p class=\"vs-thinking-text-title\">${esc(title)}</p>` +\n          (words.length ? '<p class=\"vs-thinking-text-detail\">' + words.map((w, i) => `<span style=\"--i:${i}\">${esc(w)}</span>`).join(' ') + '</p>' : '') + '</div>';\n        list.append(li);\n        requestAnimationFrame(() => requestAnimationFrame(() => li.classList.remove('is-new')));\n        label(title, true); sr.textContent = title;\n        return api;\n      },\n      done(text) {\n        clearInterval(timer); clock(); state = 'done'; el.dataset.state = state;\n        list.querySelectorAll('[data-state=\"active\"]').forEach(li => li.dataset.state = 'done');\n        const t = text || cfg().doneLabel.replace('{s}', Math.max(1, seconds));\n        label(t, false); sr.textContent = t;\n        if (cfg().autoCollapse) setTimeout(() => state === 'done' && setOpen(false), 650);\n        el.dispatchEvent(new CustomEvent('vs-thinking-text:done', { bubbles: true, detail: { seconds } }));\n        return api;\n      },\n      reset() {\n        clearInterval(timer); state = 'idle'; el.dataset.state = state;\n        label(el.dataset.label || 'Thinking', false); setOpen(false, true);\n        setTimeout(() => { if (state === 'idle') list.innerHTML = ''; }, 500);\n        return api;\n      },\n      toggle(v = !open) { setOpen(v); return api; },\n    };\n    head.addEventListener('click', () => { if (list.children.length) api.toggle(); });\n\n    // pause the shimmer (and the demo) off-screen\n    let visible = true;\n    new IntersectionObserver(([e]) => { visible = e.isIntersecting; el.classList.toggle('is-offscreen', !visible); }).observe(el);\n\n    // declarative demo: <div class=\"vs-thinking-text\" data-demo><ol hidden><li data-detail=\"…\">Title</li>…</ol></div>\n    if (el.hasAttribute('data-demo') && script.length) {\n      const wait = ms => new Promise(r => { const go = () => visible ? setTimeout(r, ms) : setTimeout(go, 300); go(); });\n      (async () => {\n        for (;;) {\n          const gap = +el.dataset.interval || 1500;\n          api.start(); await wait(gap * .7);\n          for (const s of script) { api.step(s.title, s.detail); await wait(gap); }\n          api.done(); await wait(gap * 2.4); api.reset(); await wait(gap * .6);\n        }\n      })();\n    } else { el.dataset.state = 'idle'; label(el.dataset.label || 'Thinking', false); setOpen(false, true); }\n    el.__vsTT = api;\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-thinking-text').forEach(el => mount(el));\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsThinkingText = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "ai",
      "thinking",
      "shimmer",
      "reasoning",
      "disclosure",
      "timeline",
      "loading"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#3355ff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/thinking-text/index.html",
    "thumbnail": "https://vividsites.app/api/component/thinking-text/thumb.jpg",
    "video": "https://vividsites.app/api/component/thinking-text/thumb.mp4",
    "page": "https://vividsites.app/c/thinking-text"
  }
}