{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "newsletter",
  "type": "registry:block",
  "title": "Newsletter Signup",
  "description": "A signup card that celebrates without confetti: the form lifts away word by word and a rubber postmark slams onto the card on a spring, the card dips under the blow, ink rings spread and the subscriber number counts up. Invalid addresses get a head-shake and a spoken error, and it takes an async handler for your list.",
  "docs": "Newsletter Signup was added to public/vividsites/newsletter.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/newsletter",
  "categories": [
    "ui",
    "marketing-blocks"
  ],
  "files": [
    {
      "path": "components/vividsites/newsletter.html",
      "type": "registry:file",
      "target": "public/vividsites/newsletter.html",
      "content": "<!--\n  Newsletter — vividsites.app\n  A signup card that celebrates without confetti. Submit a valid address and the button goes\n  busy, the form lifts away word by word, and a rubber postmark slams onto the card on a spring:\n  it overshoots, the card dips under the impact, a ring of ink spreads, and the subscriber number\n  counts up inside the stamp. A bad address gets a quick head-shake and a message that is read\n  out to screen readers. \"Use a different address\" puts everything back.\n\n  Markup: form.vs-newsletter > .vs-newsletter-front (heading, copy, .vs-newsletter-field > input + button, .vs-newsletter-note)\n                              + .vs-newsletter-done (stamp, heading, copy, reset button)\n  Hook your list: form.vsNewsletter.handler = async (email) => { await fetch(...) }  (throw to show an error)\n  Options: data-count=\"38213\" on the form — the subscriber number the stamp counts up to (+1)\n  CSS variables:\n    --vs-newsletter-accent    stamp, button and focus colour\n    --vs-newsletter-on-accent button label colour\n    --vs-newsletter-card      card fill\n    --vs-newsletter-ink       headline and body colour\n    --vs-newsletter-muted     secondary copy\n    --vs-newsletter-field     input fill\n    --vs-newsletter-line      hairlines\n    --vs-newsletter-radius    card radius\n    --vs-newsletter-display   headline font (the body inherits)\n  Reduced motion: no slam, no shake; the success state simply cross-fades in.\n-->\n<style>\n.vs-newsletter {\n  --vs-newsletter-accent: #e0492f;\n  --vs-newsletter-on-accent: #fff8f0;\n  --vs-newsletter-card: #fbf6ee;\n  --vs-newsletter-ink: #231a14;\n  --vs-newsletter-muted: rgba(35,26,20,.62);\n  --vs-newsletter-field: #fffdf9;\n  --vs-newsletter-line: rgba(35,26,20,.12);\n  --vs-newsletter-radius: 26px;\n  --vs-newsletter-font: inherit;\n  --vs-newsletter-display: inherit;\n\n  position: relative; display: grid; width: 100%; max-width: 36rem; margin: 0;\n  padding: clamp(1.75rem, 5vw, 2.75rem); border-radius: var(--vs-newsletter-radius);\n  font-family: var(--vs-newsletter-font); color: var(--vs-newsletter-ink); background: var(--vs-newsletter-card);\n  box-shadow: inset 0 0 0 1px var(--vs-newsletter-line), inset 0 1px 0 rgba(255,255,255,.7),\n    0 1px 2px rgba(40,20,10,.06), 0 12px 28px -12px rgba(40,20,10,.22), 0 40px 80px -40px rgba(40,20,10,.35);\n  will-change: transform;\n}\n.vs-newsletter, .vs-newsletter * { box-sizing: border-box; }\n.vs-newsletter-front, .vs-newsletter-done { grid-area: 1 / 1; display: grid; align-content: start; gap: 1rem; min-width: 0; }\n.vs-newsletter-done { visibility: hidden; justify-items: start; }\n.vs-newsletter.is-done .vs-newsletter-front { visibility: hidden; }\n.vs-newsletter.is-done .vs-newsletter-done { visibility: visible; }\n.vs-newsletter h2 {\n  margin: 0; font-family: var(--vs-newsletter-display); font-weight: 400; font-size: clamp(1.9rem, 4.4vw, 2.6rem);\n  line-height: 1.05; letter-spacing: -.02em; text-wrap: balance;\n}\n.vs-newsletter p { margin: 0; font-size: 1rem; line-height: 1.55; color: var(--vs-newsletter-muted); max-width: 40ch; text-wrap: pretty; }\n.vs-newsletter [data-w] { display: inline-block; will-change: transform, opacity, filter; }\n.vs-newsletter-meta { display: flex; gap: .6rem; align-items: center; font-size: .8125rem; font-weight: 600; letter-spacing: .01em; color: var(--vs-newsletter-accent); }\n.vs-newsletter-meta::before { content: \"\"; width: .5rem; height: .5rem; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 18%, transparent); }\n/* the field: input and button in one pill */\n.vs-newsletter-field {\n  position: relative; display: flex; align-items: center; gap: .375rem; margin-top: .5rem; padding: .375rem;\n  border-radius: 999px; background: var(--vs-newsletter-field);\n  box-shadow: inset 0 0 0 1px var(--vs-newsletter-line), inset 0 1px 2px rgba(40,20,10,.06), 0 1px 0 rgba(255,255,255,.8);\n  transition: box-shadow .25s cubic-bezier(.2,0,0,1);\n}\n.vs-newsletter-field:focus-within { box-shadow: inset 0 0 0 1.5px var(--vs-newsletter-accent), 0 0 0 4px color-mix(in srgb, var(--vs-newsletter-accent) 16%, transparent); }\n.vs-newsletter.is-error .vs-newsletter-field { box-shadow: inset 0 0 0 1.5px #c0262d, 0 0 0 4px rgba(192,38,45,.14); }\n.vs-newsletter-field input {\n  flex: 1; min-width: 0; height: 2.75rem; padding: 0 .5rem 0 1rem; border: 0; outline: none; background: none;\n  font: inherit; font-size: 1rem; color: var(--vs-newsletter-ink);\n}\n.vs-newsletter-field input::placeholder { color: var(--vs-newsletter-muted); opacity: .75; }\n.vs-newsletter-btn {\n  position: relative; flex: none; display: inline-grid; place-items: center; height: 2.75rem; padding: 0 1.25rem; border: 0; border-radius: 999px;\n  background: var(--vs-newsletter-accent); color: var(--vs-newsletter-on-accent); font: inherit; font-size: .9375rem; font-weight: 600; letter-spacing: -.005em;\n  cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.28), inset 0 -1px 0 rgba(0,0,0,.14), 0 1px 2px rgba(0,0,0,.14), 0 6px 14px -6px color-mix(in srgb, var(--vs-newsletter-accent) 80%, transparent);\n  transition: transform .45s cubic-bezier(.3,1.5,.5,1), filter .2s, box-shadow .2s;\n}\n.vs-newsletter-btn > * { grid-area: 1 / 1; transition: opacity .25s, transform .4s cubic-bezier(.3,1.4,.5,1), filter .25s; }\n.vs-newsletter-btn:hover { filter: brightness(1.06); }\n.vs-newsletter-btn:active { transform: scale(.95); transition-duration: .1s; }\n.vs-newsletter-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-newsletter-field), 0 0 0 4px var(--vs-newsletter-accent); }\n.vs-newsletter-btn .spin { width: 1.15rem; height: 1.15rem; opacity: 0; transform: scale(.6); }\n.vs-newsletter-btn .spin circle { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-dasharray: 15 40; transform-origin: 50% 50%; animation: vs-newsletter-spin .8s linear infinite; }\n@keyframes vs-newsletter-spin { to { transform: rotate(360deg); } }\n.vs-newsletter.is-busy .vs-newsletter-btn { cursor: progress; }\n.vs-newsletter.is-busy .vs-newsletter-btn .lab { opacity: 0; transform: translateY(-6px); filter: blur(3px); }\n.vs-newsletter.is-busy .vs-newsletter-btn .spin { opacity: 1; transform: none; }\n.vs-newsletter-note { font-size: .8125rem !important; min-height: 1.3em; max-width: none !important; }\n.vs-newsletter.is-error .vs-newsletter-note { color: #c0262d; }\n/* the postmark */\n.vs-newsletter-stamp, .vs-newsletter-fx > i {\n  position: absolute; right: clamp(1rem, 4vw, 2rem); top: clamp(1rem, 4vw, 1.75rem); width: clamp(7.5rem, 22vw, 9.5rem); aspect-ratio: 1;\n}\n.vs-newsletter-stamp { color: var(--vs-newsletter-accent); pointer-events: none; opacity: 0; will-change: transform, opacity; }\n/* ink rings live on the paper, so they are clipped to the card */\n.vs-newsletter-fx { position: absolute; inset: 0; overflow: hidden; border-radius: inherit; pointer-events: none; }\n.vs-newsletter-stamp svg { display: block; width: 100%; height: 100%; overflow: visible; }\n.vs-newsletter-stamp text { font-family: var(--vs-newsletter-font); fill: currentColor; }\n.vs-newsletter-fx > i { border-radius: 50%; border: 2px solid var(--vs-newsletter-accent); opacity: 0; }\n.vs-newsletter-done h2:focus { outline: none; }\n.vs-newsletter-done h2 { max-width: 11ch; margin-top: clamp(2.5rem, 6vw, 3.5rem); }\n.vs-newsletter-done p { max-width: 30ch; }\n.vs-newsletter-done strong { color: var(--vs-newsletter-ink); font-weight: 600; word-break: break-all; }\n.vs-newsletter-reset {\n  margin-top: .5rem; padding: .5rem .1rem; border: 0; background: none; cursor: pointer; font: inherit; font-size: .9375rem; font-weight: 600;\n  color: var(--vs-newsletter-ink); border-radius: 6px; outline: none;\n  background: linear-gradient(currentColor, currentColor) 0 calc(100% - .35rem) / 100% 1px no-repeat;\n  transition: background-size .4s cubic-bezier(.2,0,0,1), opacity .2s;\n}\n.vs-newsletter-reset:hover { background-size: 0% 1px; background-position: 100% calc(100% - .35rem); }\n.vs-newsletter-reset:focus-visible { box-shadow: 0 0 0 2px var(--vs-newsletter-accent); }\n.vs-newsletter-reset:active { opacity: .6; }\n.vs-newsletter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (max-width: 480px) {\n  .vs-newsletter-field { flex-wrap: wrap; border-radius: 18px; }\n  .vs-newsletter-field input { flex-basis: 100%; height: 3rem; }\n  .vs-newsletter-btn { flex: 1; height: 3rem; border-radius: 13px; }\n  .vs-newsletter-done h2 { margin-top: 6.5rem; }\n}\n</style>\n\n<form class=\"vs-newsletter\" novalidate data-count=\"38213\" aria-labelledby=\"vs-newsletter-title\">\n  <div class=\"vs-newsletter-front\">\n    <span class=\"vs-newsletter-meta\">Every Tuesday · 7 minute read</span>\n    <h2 id=\"vs-newsletter-title\">The Tuesday Letter</h2>\n    <p>One long read on how good websites get made: the type, the motion, the decisions nobody writes down. No sponsors, ever.</p>\n    <div class=\"vs-newsletter-field\">\n      <label class=\"vs-newsletter-sr\" for=\"vs-newsletter-email\">Email address</label>\n      <input id=\"vs-newsletter-email\" type=\"email\" name=\"email\" autocomplete=\"email\" inputmode=\"email\" placeholder=\"you@studio.com\" required aria-describedby=\"vs-newsletter-note\">\n      <button class=\"vs-newsletter-btn\" type=\"submit\"><span class=\"lab\">Subscribe</span><svg class=\"spin\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"9\"/></svg></button>\n    </div>\n    <p class=\"vs-newsletter-note\" id=\"vs-newsletter-note\" aria-live=\"polite\">38,213 readers. Leave in one click, whenever.</p>\n  </div>\n  <div class=\"vs-newsletter-done\" aria-live=\"polite\">\n    <h2 tabindex=\"-1\">You’re on the list.</h2>\n    <p>The first letter reaches <strong data-email></strong> on Tuesday morning.</p>\n    <button class=\"vs-newsletter-reset\" type=\"button\">Use a different address</button>\n  </div>\n  <div class=\"vs-newsletter-fx\" aria-hidden=\"true\"><i></i><i></i></div>\n  <div class=\"vs-newsletter-stamp\" aria-hidden=\"true\">\n    <svg viewBox=\"0 0 120 120\">\n      <defs>\n        <filter id=\"vs-newsletter-ink\" x=\"-10%\" y=\"-10%\" width=\"120%\" height=\"120%\">\n          <feTurbulence type=\"fractalNoise\" baseFrequency=\"1.1\" numOctaves=\"2\" seed=\"4\" result=\"n\"/>\n          <feDisplacementMap in=\"SourceGraphic\" in2=\"n\" scale=\"2.2\"/>\n          <feComponentTransfer><feFuncA type=\"table\" tableValues=\"0 .8 1 1\"/></feComponentTransfer>\n        </filter>\n        <path id=\"vs-newsletter-arc\" d=\"M60 60m-43 0a43 43 0 1 1 86 0a43 43 0 1 1-86 0\"/>\n      </defs>\n      <g filter=\"url(#vs-newsletter-ink)\">\n        <circle cx=\"60\" cy=\"60\" r=\"56\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\"/>\n        <circle cx=\"60\" cy=\"60\" r=\"33\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/>\n        <text font-size=\"8.2\" font-weight=\"700\" letter-spacing=\"1.9\"><textPath href=\"#vs-newsletter-arc\">THE TUESDAY LETTER · SUBSCRIBER · </textPath></text>\n        <text x=\"60\" y=\"55\" text-anchor=\"middle\" font-size=\"8\" font-weight=\"700\" letter-spacing=\"1.5\">No.</text>\n        <text class=\"vs-newsletter-num\" x=\"60\" y=\"71\" text-anchor=\"middle\" font-size=\"15\" font-weight=\"800\" letter-spacing=\".5\">38,214</text>\n      </g>\n    </svg>\n  </div>\n</form>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const valid = v => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/.test(v);\n  // a tiny spring driver: spring(from, to, {k, c, v}, onFrame, onDone)\n  function spring(from, to, o, frame, done) {\n    let x = from, v = o.v || 0, last = performance.now(), id;\n    const run = now => {\n      const dt = Math.min(.04, (now - last) / 1000); last = now;\n      const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { v += (-(o.k || 200) * (x - to) - (o.c || 20) * v) * h; x += v * h; }\n      frame(x, v);\n      if (Math.abs(v) < .002 && Math.abs(x - to) < .0005) { frame(to, 0); done && done(); return; }\n      id = requestAnimationFrame(run);\n    };\n    id = requestAnimationFrame(run);\n    return () => cancelAnimationFrame(id);\n  }\n  // split an element's text into word spans once\n  const words = el => {\n    if (!el.__w) {\n      const walk = n => [...n.childNodes].forEach(c => {\n        if (c.nodeType === 3) { const f = document.createDocumentFragment(); c.textContent.split(/(\\s+)/).forEach(t => { if (!t) return; if (/^\\s+$/.test(t)) f.append(t); else { const s = document.createElement('span'); s.dataset.w = ''; s.textContent = t; f.append(s); } }); c.replaceWith(f); }\n        else if (c.nodeType === 1 && !c.dataset.w) walk(c);\n      });\n      walk(el); el.__w = [...el.querySelectorAll('[data-w]')];\n    }\n    return el.__w;\n  };\n  const rise = (els, dir, stagger = 35, base = 0) => els.map((w, i) => w.animate(\n    dir > 0 ? [{ opacity: 0, transform: 'translateY(.45em)', filter: 'blur(6px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }]\n            : [{ opacity: 1, transform: 'none', filter: 'blur(0)' }, { opacity: 0, transform: 'translateY(-.35em)', filter: 'blur(6px)' }],\n    { duration: dir > 0 ? 620 : 320, delay: base + i * stagger, easing: dir > 0 ? 'cubic-bezier(.16,1,.3,1)' : 'cubic-bezier(.4,0,1,1)', fill: 'both' }).finished);\n\n  function mount(form) {\n    if (form.vsNewsletter) return form.vsNewsletter;\n    const input = form.querySelector('input[type=\"email\"]'), note = form.querySelector('.vs-newsletter-note');\n    const front = form.querySelector('.vs-newsletter-front'), done = form.querySelector('.vs-newsletter-done');\n    const stamp = form.querySelector('.vs-newsletter-stamp'), rings = [...form.querySelectorAll('.vs-newsletter-fx > i')];\n    const num = form.querySelector('.vs-newsletter-num'), reset = form.querySelector('.vs-newsletter-reset');\n    const noteText = note.textContent; let busy = false, stops = [];\n    const api = { handler: () => new Promise(r => setTimeout(r, 900)) };\n\n    const shake = () => {\n      if (reduce.matches) return;\n      const f = form.querySelector('.vs-newsletter-field');\n      stops.push(spring(0, 0, { k: 900, c: 18, v: 900 }, x => { f.style.transform = `translateX(${x.toFixed(2)}px)`; }, () => { f.style.transform = ''; }));\n    };\n    const error = msg => { form.classList.add('is-error'); note.textContent = msg; input.setAttribute('aria-invalid', 'true'); shake(); input.focus(); };\n    input.addEventListener('input', () => { if (form.classList.contains('is-error')) { form.classList.remove('is-error'); note.textContent = noteText; input.removeAttribute('aria-invalid'); } });\n\n    async function celebrate(email) {\n      form.classList.remove('is-busy');\n      done.querySelector('[data-email]').textContent = email;\n      const target = (+form.dataset.count || 0) + 1;\n      if (reduce.matches) {\n        form.classList.add('is-done'); stamp.style.opacity = 1; stamp.style.transform = 'rotate(-9deg)'; num.textContent = target.toLocaleString('en-US');\n        done.querySelector('h2').focus({ preventScroll: true }); return;\n      }\n      // 1. the form lifts away, word by word\n      await Promise.all(rise([front.querySelector('.vs-newsletter-meta')].concat([...front.querySelectorAll('h2, p:not(.vs-newsletter-note)')].flatMap(words)).concat([form.querySelector('.vs-newsletter-field'), note]), -1, 14));\n      form.classList.add('is-done');\n      front.getAnimations({ subtree: true }).forEach(a => a.cancel());\n      // 2. the stamp drops from above the card and slams down\n      stamp.style.opacity = 1;\n      let landed = false; const drop = innerWidth < 600 ? 1.5 : 2.5;\n      const count0 = target - 60; num.textContent = count0.toLocaleString('en-US');\n      stops.push(spring(0, 1, { k: 330, c: 19 }, (t) => {\n        const s = drop - (drop - 1) * t, r = -26 + 17 * t;\n        stamp.style.transform = `rotate(${r.toFixed(2)}deg) scale(${s.toFixed(4)})`;\n        stamp.style.opacity = Math.min(1, t * 2.4).toFixed(3);\n        if (!landed && t >= .985) { landed = true; impact(); }\n      }));\n      function impact() {\n        // the card dips under the blow, ink rings spread, the number counts up\n        stops.push(spring(0, 0, { k: 520, c: 16, v: 90 }, y => { form.style.transform = `translateY(${y.toFixed(2)}px) scale(${(1 - Math.abs(y) * .0012).toFixed(4)})`; }, () => { form.style.transform = ''; }));\n        rings.forEach((ring, i) => ring.animate([{ opacity: .55, transform: 'scale(.92)' }, { opacity: 0, transform: `scale(${1.5 + i * .35})` }], { duration: 900 + i * 250, delay: i * 90, easing: 'cubic-bezier(.16,1,.3,1)' }));\n        const t0 = performance.now();\n        const count = now => { const k = Math.min(1, (now - t0) / 900), e = 1 - Math.pow(1 - k, 4); num.textContent = Math.round(count0 + (target - count0) * e).toLocaleString('en-US'); if (k < 1) requestAnimationFrame(count); };\n        requestAnimationFrame(count);\n        form.dispatchEvent(new CustomEvent('vs-newsletter:done', { bubbles: true, detail: { email } }));\n      }\n      // 3. the success copy rises in behind it\n      rise([...done.querySelectorAll('h2, p, .vs-newsletter-reset')].flatMap(el => el.matches('button') ? [el] : words(el)), 1, 40, 180);\n      done.querySelector('h2').focus({ preventScroll: true });\n    }\n\n    form.addEventListener('submit', async e => {\n      e.preventDefault(); if (busy) return;\n      const email = input.value.trim();\n      if (!valid(email)) return error(email ? 'That address is missing something. Try name@domain.com.' : 'Add your email and you’re in.');\n      busy = true; form.classList.add('is-busy'); input.readOnly = true; form.setAttribute('aria-busy', 'true');\n      try { await api.handler(email); await celebrate(email); }\n      catch (err) { form.classList.remove('is-busy'); error((err && err.message) || 'Something went wrong. Try again in a moment.'); }\n      finally { busy = false; input.readOnly = false; form.removeAttribute('aria-busy'); }\n    });\n\n    api.reset = () => {\n      stops.forEach(s => s()); stops = [];\n      form.getAnimations({ subtree: true }).forEach(a => a.cancel());\n      form.classList.remove('is-done', 'is-busy', 'is-error'); form.style.transform = '';\n      stamp.style.opacity = 0; stamp.style.transform = ''; note.textContent = noteText; input.value = '';\n      if (!reduce.matches) rise([front.querySelector('.vs-newsletter-meta')].concat([...front.querySelectorAll('h2, p:not(.vs-newsletter-note)')].flatMap(words)).concat([form.querySelector('.vs-newsletter-field'), note]), 1, 12);\n    };\n    reset.addEventListener('click', () => { api.reset(); input.focus({ preventScroll: true }); });\n    api.celebrate = (email = 'you@studio.com') => { api.reset(); form.getAnimations({ subtree: true }).forEach(a => a.finish()); return celebrate(email); };\n    form.vsNewsletter = api; return api;\n  }\n  const init = () => document.querySelectorAll('.vs-newsletter').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsNewsletter = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Marketing blocks",
    "tags": [
      "newsletter",
      "signup",
      "email",
      "form",
      "success state",
      "stamp",
      "spring",
      "validation"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#e0492f",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/newsletter/index.html",
    "thumbnail": "https://vividsites.app/api/component/newsletter/thumb.jpg",
    "video": "https://vividsites.app/api/component/newsletter/thumb.mp4",
    "page": "https://vividsites.app/c/newsletter"
  }
}