{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cta-banner",
  "type": "registry:block",
  "title": "CTA Banner",
  "description": "A call-to-action card with a comet of light running round its border and a blurred halo that follows it outside the card; hovering speeds the comet up. The primary button is magnetic on a spring, squashes when pressed and goes busy then done when clicked.",
  "docs": "CTA Banner was added to public/vividsites/cta-banner.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/cta-banner",
  "categories": [
    "ui",
    "marketing-blocks"
  ],
  "files": [
    {
      "path": "components/vividsites/cta-banner.html",
      "type": "registry:file",
      "target": "public/vividsites/cta-banner.html",
      "content": "<!--\n  CTA Banner — vividsites.app\n  A call-to-action card whose border is a comet of light running round its edge, with a soft halo\n  that follows the comet outside the card. The primary button is magnetic: it leans toward the\n  pointer on a spring, its label drifts further for parallax, it squashes when pressed and goes\n  busy → done when clicked. Hover the card and the comet speeds up; leave and it eases back.\n\n  Markup: .vs-cta-banner > .vs-cta-banner-card > h2 + p + .vs-cta-banner-actions > button.vs-cta-banner-btn + a.vs-cta-banner-link\n  Button: data-done=\"Label shown after the click\" (omit to skip the busy → done sequence)\n  CSS variables:\n    --vs-cta-banner-c1 / -c2 / -c3   the three colours of the comet (head → tail)\n    --vs-cta-banner-turn             seconds per lap at rest\n    --vs-cta-banner-border           border width\n    --vs-cta-banner-line             the resting border the comet runs along\n    --vs-cta-banner-halo             0–1 strength of the outer halo\n    --vs-cta-banner-spread           how far the halo reaches past the card (leave this much room around it)\n    --vs-cta-banner-bg               card fill\n    --vs-cta-banner-ink              headline colour\n    --vs-cta-banner-muted            body colour\n    --vs-cta-banner-radius           card radius\n    --vs-cta-banner-btn / -btn-ink   primary button fill and label\n    --vs-cta-banner-pull             0–1 magnet strength\n  Reduced motion: the comet stops, the magnet is off; everything still works.\n-->\n<style>\n.vs-cta-banner {\n  --vs-cta-banner-c1: #ffd27a;\n  --vs-cta-banner-c2: #ff6a3d;\n  --vs-cta-banner-c3: #b8325c;\n  --vs-cta-banner-turn: 6;\n  --vs-cta-banner-border: 1.5px;\n  --vs-cta-banner-line: rgba(255,255,255,.09);\n  --vs-cta-banner-halo: .5;\n  --vs-cta-banner-spread: 36px;\n  --vs-cta-banner-bg: #140d0b;\n  --vs-cta-banner-ink: #fbf1e6;\n  --vs-cta-banner-muted: rgba(251,241,230,.6);\n  --vs-cta-banner-radius: 28px;\n  --vs-cta-banner-btn: #ffd27a;\n  --vs-cta-banner-btn-ink: #1a0f0a;\n  --vs-cta-banner-pull: .35;\n  --vs-cta-banner-font: inherit;\n  --vs-cta-banner-display: inherit;\n\n  position: relative; isolation: isolate; border-radius: var(--vs-cta-banner-radius);\n  font-family: var(--vs-cta-banner-font); color: var(--vs-cta-banner-ink);\n}\n.vs-cta-banner, .vs-cta-banner * { box-sizing: border-box; }\n/* the comet: an oversized conic square, rotated on the compositor, seen only through the border ring */\n.vs-cta-banner-ring, .vs-cta-banner-halo { position: absolute; overflow: hidden; pointer-events: none; }\n.vs-cta-banner-ring {\n  inset: 0; z-index: 1; border-radius: inherit; padding: var(--vs-cta-banner-border);\n  background: var(--vs-cta-banner-line);\n  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;\n          mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);\n}\n/* the halo: the same comet, blurred, seen in a band around the card */\n.vs-cta-banner-halo {\n  inset: calc(var(--vs-cta-banner-spread) * -1); z-index: -1; border-radius: calc(var(--vs-cta-banner-radius) + var(--vs-cta-banner-spread));\n  filter: blur(26px); opacity: var(--vs-cta-banner-halo);\n}\n.vs-cta-banner-ring > i, .vs-cta-banner-halo > i {\n  --d: var(--vs-cta-banner-d, 200vmax);\n  position: absolute; left: 50%; top: 50%; width: var(--d); height: var(--d); margin: calc(var(--d) / -2) 0 0 calc(var(--d) / -2);\n  background: conic-gradient(from 0deg, transparent 0 60%, var(--vs-cta-banner-c3) 76%, var(--vs-cta-banner-c2) 91%, var(--vs-cta-banner-c1) 98.4%, transparent 98.9%);\n  will-change: transform;\n}\n.vs-cta-banner-card {\n  position: relative; display: grid; justify-items: center; text-align: center; gap: 1.25rem;\n  padding: clamp(2.5rem, 6vw, 4.5rem) clamp(1.5rem, 5vw, 4rem); border-radius: inherit; overflow: hidden;\n  background:\n    radial-gradient(70% 90% at 50% 0%, color-mix(in srgb, var(--vs-cta-banner-c2) 16%, transparent), transparent 70%),\n    var(--vs-cta-banner-bg);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 30px 80px -30px rgba(0,0,0,.6);\n}\n.vs-cta-banner-card::before { /* hairline grid that fades from the top */\n  content: \"\"; position: absolute; inset: 0; pointer-events: none; opacity: .5;\n  background-image: linear-gradient(var(--vs-cta-banner-line) 1px, transparent 1px), linear-gradient(90deg, var(--vs-cta-banner-line) 1px, transparent 1px);\n  background-size: 44px 44px; background-position: 50% -1px;\n  -webkit-mask-image: radial-gradient(80% 70% at 50% 0%, #000, transparent 75%); mask-image: radial-gradient(80% 70% at 50% 0%, #000, transparent 75%);\n}\n.vs-cta-banner-card > * { position: relative; }\n.vs-cta-banner-card h2 {\n  margin: 0; max-width: 17ch; font-family: var(--vs-cta-banner-display); font-weight: 700;\n  font-size: clamp(2rem, 4.6vw, 3.6rem); line-height: 1.02; letter-spacing: -.035em; text-wrap: balance;\n}\n.vs-cta-banner-card p { margin: 0; max-width: 44ch; font-size: clamp(.95rem, 1.4vw, 1.0625rem); line-height: 1.55; color: var(--vs-cta-banner-muted); text-wrap: pretty; }\n.vs-cta-banner-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .75rem 1.5rem; margin-top: .75rem; }\n/* magnetic button */\n.vs-cta-banner-btn {\n  position: relative; display: inline-flex; border: 0; padding: 0; background: none; cursor: pointer; font: inherit; color: var(--vs-cta-banner-btn-ink);\n  border-radius: 999px; outline: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation;\n}\n.vs-cta-banner-btn-body {\n  position: relative; display: inline-flex; align-items: center; justify-content: center; overflow: hidden;\n  min-height: 3.25rem; padding: 0 1.5rem 0 1.65rem; border-radius: inherit; background: var(--vs-cta-banner-btn);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.55), inset 0 -1px 0 rgba(0,0,0,.18), 0 1px 1px rgba(0,0,0,.2),\n    0 8px 20px -8px color-mix(in srgb, var(--vs-cta-banner-btn) 70%, transparent), 0 0 0 0 transparent;\n  will-change: transform; transition: box-shadow .3s cubic-bezier(.2,0,0,1), background-color .3s;\n}\n.vs-cta-banner-btn-label { display: inline-flex; align-items: center; gap: .55em; font-weight: 600; font-size: 1rem; letter-spacing: -.01em; white-space: nowrap; will-change: transform; transition: opacity .25s, filter .25s, transform .25s; }\n.vs-cta-banner-btn-label svg { width: 1.05em; height: 1.05em; transition: transform .5s cubic-bezier(.3,1.5,.5,1); }\n.vs-cta-banner-btn:hover .vs-cta-banner-btn-label svg { transform: translateX(3px); }\n.vs-cta-banner-btn:focus-visible .vs-cta-banner-btn-body { box-shadow: inset 0 1px 0 rgba(255,255,255,.55), 0 0 0 2px var(--vs-cta-banner-bg), 0 0 0 4px var(--vs-cta-banner-btn); }\n.vs-cta-banner-btn.is-down .vs-cta-banner-btn-body { box-shadow: inset 0 2px 6px rgba(0,0,0,.22), 0 1px 1px rgba(0,0,0,.2); }\n.vs-cta-banner-btn-alt { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 600; opacity: 0; transform: translateY(8px); filter: blur(4px); transition: opacity .3s, transform .45s cubic-bezier(.3,1.4,.5,1), filter .3s; pointer-events: none; }\n.vs-cta-banner-btn-alt svg.spin { width: 1.25rem; height: 1.25rem; animation: vs-cta-banner-spin .8s linear infinite; }\n.vs-cta-banner-btn-alt svg.spin circle { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-dasharray: 16 40; }\n@keyframes vs-cta-banner-spin { to { transform: rotate(360deg); } }\n.vs-cta-banner-btn[aria-busy=\"true\"] { cursor: progress; }\n.vs-cta-banner-btn[aria-busy=\"true\"] .vs-cta-banner-btn-label, .vs-cta-banner-btn.is-done .vs-cta-banner-btn-label { opacity: 0; filter: blur(4px); transform: translateY(-8px); }\n.vs-cta-banner-btn[aria-busy=\"true\"] .vs-cta-banner-btn-alt[data-busy], .vs-cta-banner-btn.is-done .vs-cta-banner-btn-alt[data-done] { opacity: 1; transform: none; filter: none; }\n.vs-cta-banner-btn:disabled { cursor: not-allowed; opacity: .45; }\n.vs-cta-banner-link {\n  display: inline-flex; align-items: center; gap: .4em; padding: .5rem .25rem; border-radius: 8px;\n  color: var(--vs-cta-banner-ink); text-decoration: none; font-weight: 500; font-size: 1rem; outline: none;\n  background: linear-gradient(currentColor, currentColor) 0 calc(100% - .3rem) / 0% 1px no-repeat; transition: background-size .45s cubic-bezier(.2,0,0,1), opacity .2s;\n  opacity: .82;\n}\n.vs-cta-banner-link:hover, .vs-cta-banner-link:focus-visible { background-size: 100% 1px; opacity: 1; }\n.vs-cta-banner-link:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--vs-cta-banner-ink) 50%, transparent); }\n.vs-cta-banner-link:active { opacity: .6; }\n@media (max-width: 600px) { .vs-cta-banner { --vs-cta-banner-spread: 14px; } }\n@media (prefers-reduced-motion: reduce) {\n  .vs-cta-banner-btn-alt svg.spin { animation-duration: 2.4s; }\n}\n</style>\n\n<section class=\"vs-cta-banner\" aria-labelledby=\"vs-cta-banner-title\">\n  <div class=\"vs-cta-banner-halo\" aria-hidden=\"true\"><i></i><i></i></div>\n  <div class=\"vs-cta-banner-card\">\n    <h2 id=\"vs-cta-banner-title\">Ship the site you keep describing to people.</h2>\n    <p>Start free on a real domain. No card, no sales call, and your first three projects stay free for good.</p>\n    <div class=\"vs-cta-banner-actions\">\n      <button class=\"vs-cta-banner-btn\" type=\"button\" data-done=\"Workspace ready\">\n        Start building\n        <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M3 8h9M8.5 4 12.5 8l-4 4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n      </button>\n      <a class=\"vs-cta-banner-link\" href=\"#\">Talk to a human</a>\n    </div>\n  </div>\n  <div class=\"vs-cta-banner-ring\" aria-hidden=\"true\"><i></i><i></i></div>\n</section>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const fine = matchMedia('(hover: hover) and (pointer: fine)');\n  const spr = (x, k, c) => ({ x, v: 0, g: x, k, c });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; };\n  function mount(root) {\n    if (root.__vsCta) return root.__vsCta;\n    const combs = [...root.querySelectorAll('.vs-cta-banner-ring > i, .vs-cta-banner-halo > i')];\n    // size the rotating squares to the card's diagonal (plus the halo band) so nothing is wasted\n    new ResizeObserver(() => root.style.setProperty('--vs-cta-banner-d', Math.ceil(Math.hypot(root.offsetWidth, root.offsetHeight) + 80) + 'px')).observe(root);\n    const btn = root.querySelector('.vs-cta-banner-btn');\n    let body, label;\n    if (btn) {\n      body = document.createElement('span'); body.className = 'vs-cta-banner-btn-body';\n      label = document.createElement('span'); label.className = 'vs-cta-banner-btn-label';\n      while (btn.firstChild) label.appendChild(btn.firstChild);\n      body.appendChild(label);\n      body.insertAdjacentHTML('beforeend', '<span class=\"vs-cta-banner-btn-alt\" data-busy aria-hidden=\"true\"><svg class=\"spin\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/></svg></span>');\n      if (btn.dataset.done) {\n        const d = document.createElement('span'); d.className = 'vs-cta-banner-btn-alt'; d.dataset.done = ''; d.setAttribute('aria-hidden', 'true');\n        d.innerHTML = '<span style=\"display:inline-flex;align-items:center;gap:.5em\"><svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\"><path d=\"m3.5 8.5 3 3 6-7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></span>';\n        d.firstChild.append(btn.dataset.done); body.appendChild(d);\n      }\n      btn.appendChild(body);\n      const live = document.createElement('span'); live.setAttribute('role', 'status');\n      live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap';\n      btn.after(live); btn.__live = live;\n    }\n    const S = { ang: 0, spd: spr(1, 60, 14), x: spr(0, 170, 13), y: spr(0, 170, 13), lx: spr(0, 210, 15), ly: spr(0, 210, 15), sc: spr(1, 420, 20) };\n    let visible = true, raf = 0, last = 0, down = false;\n    const num = n => { const v = parseFloat(getComputedStyle(root).getPropertyValue(n)); return isNaN(v) ? null : v; };\n    let turn = 6, pull = .35;\n    const read = () => { turn = num('--vs-cta-banner-turn') ?? 6; pull = num('--vs-cta-banner-pull') ?? .35; };\n    const tick = now => {\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now;\n      const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n      for (const k of ['spd', 'x', 'y', 'lx', 'ly', 'sc']) for (let i = 0; i < n; i++) step(S[k], h);\n      if (!reduce.matches && turn > 0) S.ang = (S.ang + 360 / turn * S.spd.x * dt) % 360;\n      if (reduce.matches) for (const k of ['x', 'y', 'lx', 'ly']) { S[k].x = 0; S[k].v = 0; }\n      const a = S.ang.toFixed(2);\n      combs.forEach((c, i) => c.style.transform = `rotate(${i % 2 ? +a + 180 : a}deg)`);\n      if (body) {\n        body.style.transform = `translate3d(${S.x.x.toFixed(2)}px,${S.y.x.toFixed(2)}px,0) scale(${S.sc.x.toFixed(4)})`;\n        label.style.transform = `translate3d(${S.lx.x.toFixed(2)}px,${S.ly.x.toFixed(2)}px,0)`;\n      }\n      raf = visible ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = () => { if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    new IntersectionObserver(e => { visible = e[0].isIntersecting; if (visible) wake(); }).observe(root);\n    new MutationObserver(read).observe(root, { attributes: true, attributeFilter: ['style', 'class'] });\n    root.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') S.spd.g = 2.6; });\n    root.addEventListener('pointerleave', () => { S.spd.g = 1; release(); });\n    const release = () => { S.x.g = S.y.g = S.lx.g = S.ly.g = 0; };\n    if (btn) {\n      const inert = () => btn.disabled || btn.getAttribute('aria-busy') === 'true';\n      root.addEventListener('pointermove', e => {\n        if (reduce.matches || inert() || (e.pointerType === 'mouse' && !fine.matches) || (e.pointerType !== 'mouse' && !down)) return;\n        const r = btn.getBoundingClientRect(), mx = r.left + r.width / 2, my = r.top + r.height / 2;\n        const edge = Math.hypot(Math.max(r.left - e.clientX, 0, e.clientX - r.right), Math.max(r.top - e.clientY, 0, e.clientY - r.bottom));\n        let f = 1 - Math.min(1, edge / 90); f = f * f * (3 - 2 * f);\n        const cap = r.height * .55;\n        const dx = cap * Math.tanh((e.clientX - mx) * pull * f / cap), dy = cap * Math.tanh((e.clientY - my) * pull * f / cap);\n        S.x.g = dx; S.y.g = dy; S.lx.g = dx * .45; S.ly.g = dy * .45;\n      }, { passive: true });\n      btn.addEventListener('pointerdown', () => { if (inert()) return; down = true; btn.classList.add('is-down'); S.sc.g = .95; });\n      const up = e => { if (!down) return; down = false; btn.classList.remove('is-down'); S.sc.g = 1; S.sc.v += 1.6; if (e.pointerType !== 'mouse') release(); };\n      btn.addEventListener('pointerup', up); btn.addEventListener('pointercancel', up);\n      btn.addEventListener('keydown', e => { if (e.key === ' ' || e.key === 'Enter') S.sc.g = .95; });\n      btn.addEventListener('keyup', () => { S.sc.g = 1; S.sc.v += 1.6; });\n      btn.addEventListener('blur', () => { S.sc.g = 1; });\n      btn.addEventListener('click', () => {\n        if (inert() || !btn.dataset.done || btn.classList.contains('is-done')) return;\n        btn.setAttribute('aria-busy', 'true'); release();\n        setTimeout(() => {\n          btn.removeAttribute('aria-busy'); btn.classList.add('is-done'); S.sc.v += 2.2; btn.__live.textContent = btn.dataset.done;\n          btn.dispatchEvent(new CustomEvent('vs-cta-banner:done', { bubbles: true }));\n          setTimeout(() => { btn.classList.remove('is-done'); btn.__live.textContent = ''; }, 2200);\n        }, 1100);\n      });\n    }\n    read(); wake();\n    const api = { replay: () => { S.spd.v += 20; } };\n    root.__vsCta = api; return api;\n  }\n  const init = () => document.querySelectorAll('.vs-cta-banner').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsCtaBanner = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Marketing blocks",
    "tags": [
      "cta",
      "banner",
      "gradient border",
      "conic",
      "magnetic button",
      "glow",
      "spring",
      "call to action"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff6a3d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/cta-banner/index.html",
    "thumbnail": "https://vividsites.app/api/component/cta-banner/thumb.jpg",
    "video": "https://vividsites.app/api/component/cta-banner/thumb.mp4",
    "page": "https://vividsites.app/c/cta-banner"
  }
}