{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "announcement-bar",
  "type": "registry:block",
  "title": "Announcement Bar",
  "description": "A slim top-of-page bar in two modes: a marquee of messages that glides to a stop under the pointer, or one message at a time that rolls up on a spring with a countdown hairline and arrow-key paging. The close button collapses it on a spring and can remember the dismissal.",
  "docs": "Announcement Bar was added to public/vividsites/announcement-bar.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/announcement-bar",
  "categories": [
    "ui",
    "marketing-blocks"
  ],
  "files": [
    {
      "path": "components/vividsites/announcement-bar.html",
      "type": "registry:file",
      "target": "public/vividsites/announcement-bar.html",
      "content": "<!--\n  Announcement Bar — vividsites.app\n  A slim bar for the top of a page, in two modes:\n    data-mode=\"marquee\"  messages run past in a loop that glides to a stop under the pointer\n    data-mode=\"rotate\"   one message at a time; each rolls up on a spring, a hairline shows the\n                         time left, and prev / next buttons (or ← →) step through by hand\n  Both pause while hovered or focused and when off-screen. The close button collapses the bar\n  on a spring (height, then content lifts away) and, with data-key, remembers it in localStorage.\n\n  Markup: .vs-announcement-bar[data-mode] > .vs-announcement-bar-inner > (span.vs-announcement-bar-lead, optional) + .vs-announcement-bar-viewport > ul > li (messages)\n          + button.vs-announcement-bar-close\n  Options (data attributes): data-mode, data-interval=\"4200\" (rotate, ms), data-key=\"promo-nov\" (remember dismissal)\n  JS: el.vsAnnouncement.dismiss() / .restore()   Event: \"vs-announcement-bar:dismiss\"\n  CSS variables:\n    --vs-announcement-bar-bg       bar fill\n    --vs-announcement-bar-ink      text colour\n    --vs-announcement-bar-accent   dot / highlight colour\n    --vs-announcement-bar-size     type size (bar height follows it)\n    --vs-announcement-bar-speed    marquee speed in px/s\n    --vs-announcement-bar-line     bottom hairline\n  Reduced motion: the marquee stands still; rotations cross-fade instead of rolling.\n-->\n<style>\n.vs-announcement-bar {\n  --vs-announcement-bar-bg: #0e0e0d;\n  --vs-announcement-bar-ink: #f3f2ec;\n  --vs-announcement-bar-accent: #d4ff3a;\n  --vs-announcement-bar-size: .8125rem;\n  --vs-announcement-bar-speed: 56;\n  --vs-announcement-bar-line: rgba(255,255,255,.08);\n  --vs-announcement-bar-font: inherit;\n\n  position: relative; overflow: hidden; width: 100%;\n  font-family: var(--vs-announcement-bar-font); font-size: var(--vs-announcement-bar-size); line-height: 1;\n  color: var(--vs-announcement-bar-ink); background: var(--vs-announcement-bar-bg);\n  box-shadow: inset 0 -1px 0 var(--vs-announcement-bar-line);\n}\n.vs-announcement-bar, .vs-announcement-bar * { box-sizing: border-box; }\n.vs-announcement-bar[hidden] { display: none; }\n.vs-announcement-bar-inner { position: relative; display: flex; align-items: center; height: calc(var(--vs-announcement-bar-size) * 3.4); will-change: transform, opacity; }\n.vs-announcement-bar-viewport { position: relative; flex: 1; min-width: 0; height: 100%; overflow: hidden; }\n.vs-announcement-bar ul { margin: 0; padding: 0; list-style: none; }\n.vs-announcement-bar li { white-space: nowrap; }\n.vs-announcement-bar a { color: inherit; text-decoration: none; border-radius: 4px; outline: none; }\n.vs-announcement-bar a:focus-visible { box-shadow: 0 0 0 2px var(--vs-announcement-bar-accent); }\n.vs-announcement-bar-tag {\n  display: inline-flex; align-items: center; height: 1.85em; padding: 0 .65em; margin-right: .8em; border-radius: 999px; vertical-align: middle;\n  font-size: .85em; font-weight: 600; letter-spacing: .02em; color: var(--vs-announcement-bar-bg); background: var(--vs-announcement-bar-accent);\n}\n.vs-announcement-bar-go { display: inline-block; margin-left: .45em; transition: transform .45s cubic-bezier(.3,1.5,.5,1); }\n.vs-announcement-bar a:hover .vs-announcement-bar-go, .vs-announcement-bar a:focus-visible .vs-announcement-bar-go { transform: translateX(3px); }\n/* optional pinned label at the start of the bar, with a live dot */\n.vs-announcement-bar-lead {\n  position: relative; z-index: 1; display: flex; align-items: center; gap: .7em; flex: none; height: 100%; padding: 0 1.1em 0 1.25em;\n  font-weight: 700; letter-spacing: .06em; text-transform: uppercase; font-size: .85em;\n  box-shadow: 1px 0 0 var(--vs-announcement-bar-line);\n}\n.vs-announcement-bar-lead::before { content: \"\"; width: .55em; height: .55em; border-radius: 50%; background: var(--vs-announcement-bar-accent); }\n.vs-announcement-bar-lead::after {\n  content: \"\"; position: absolute; left: 1.25em; top: 50%; width: .55em; height: .55em; margin-top: -.275em; border-radius: 50%;\n  background: var(--vs-announcement-bar-accent); animation: vs-announcement-bar-pulse 1.8s cubic-bezier(.16,1,.3,1) infinite;\n}\n@keyframes vs-announcement-bar-pulse { 0% { transform: scale(1); opacity: .6; } 70%, 100% { transform: scale(3.2); opacity: 0; } }\n/* ── marquee ── */\n.vs-announcement-bar[data-mode=\"marquee\"] .vs-announcement-bar-viewport {\n  -webkit-mask-image: linear-gradient(90deg, transparent, #000 4%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 4%, #000 92%, transparent);\n}\n.vs-announcement-bar[data-mode=\"marquee\"] ul { display: flex; align-items: center; width: max-content; height: 100%; will-change: transform; }\n.vs-announcement-bar[data-mode=\"marquee\"] li { display: flex; align-items: center; height: 100%; }\n.vs-announcement-bar[data-mode=\"marquee\"] li::after { /* a four-point spark between messages */\n  content: \"\"; width: .8em; height: .8em; margin: 0 1.6em; flex: none; background: var(--vs-announcement-bar-accent);\n  -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M5 0C5.4 3.3 6.7 4.6 10 5 6.7 5.4 5.4 6.7 5 10 4.6 6.7 3.3 5.4 0 5 3.3 4.6 4.6 3.3 5 0z'/%3E%3C/svg%3E\") center / contain no-repeat;\n          mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M5 0C5.4 3.3 6.7 4.6 10 5 6.7 5.4 5.4 6.7 5 10 4.6 6.7 3.3 5.4 0 5 3.3 4.6 4.6 3.3 5 0z'/%3E%3C/svg%3E\") center / contain no-repeat;\n}\n.vs-announcement-bar[data-mode=\"marquee\"] a { opacity: .78; transition: opacity .3s cubic-bezier(.2,0,0,1); }\n.vs-announcement-bar[data-mode=\"marquee\"] a:hover, .vs-announcement-bar[data-mode=\"marquee\"] a:focus-visible { opacity: 1; }\n/* ── rotate ── */\n.vs-announcement-bar[data-mode=\"rotate\"] ul { position: relative; height: 100%; }\n.vs-announcement-bar[data-mode=\"rotate\"] li {\n  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 .5em;\n  opacity: 0; visibility: hidden; will-change: transform, opacity, filter;\n}\n.vs-announcement-bar[data-mode=\"rotate\"] li.is-on { opacity: 1; visibility: visible; }\n.vs-announcement-bar[data-mode=\"rotate\"] li > a, .vs-announcement-bar[data-mode=\"rotate\"] li > span { overflow: hidden; text-overflow: ellipsis; }\n.vs-announcement-bar-nav { display: flex; gap: .15em; padding-left: .6em; }\n.vs-announcement-bar-nav button, .vs-announcement-bar-close {\n  display: grid; place-items: center; flex: none; width: 2.4em; height: 2.4em; padding: 0; border: 0; border-radius: 999px;\n  background: transparent; color: inherit; cursor: pointer; opacity: .6; outline: none; -webkit-tap-highlight-color: transparent;\n  transition: opacity .2s cubic-bezier(.2,0,0,1), background-color .2s, transform .4s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-announcement-bar-nav button:hover, .vs-announcement-bar-close:hover { opacity: 1; background: color-mix(in srgb, currentColor 10%, transparent); }\n.vs-announcement-bar-nav button:active, .vs-announcement-bar-close:active { transform: scale(.88); transition-duration: .1s; }\n.vs-announcement-bar-nav button:focus-visible, .vs-announcement-bar-close:focus-visible { opacity: 1; box-shadow: 0 0 0 2px var(--vs-announcement-bar-accent); }\n.vs-announcement-bar-nav svg, .vs-announcement-bar-close svg { width: 1.1em; height: 1.1em; }\n.vs-announcement-bar-close { margin: 0 .5em 0 .3em; }\n.vs-announcement-bar-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; pointer-events: none; }\n.vs-announcement-bar-progress i { display: block; height: 100%; width: 100%; transform-origin: 0 50%; transform: scaleX(0); background: currentColor; opacity: .35; }\n@media (max-width: 600px) {\n  .vs-announcement-bar-nav { display: none; }\n  .vs-announcement-bar[data-mode=\"rotate\"] li { justify-content: flex-start; padding-left: 1em; }\n}\n@media (max-width: 600px) { .vs-announcement-bar-lead { padding: 0 .8em 0 1em; } .vs-announcement-bar-lead::after { left: 1em; } }\n@media (prefers-reduced-motion: reduce) {\n  .vs-announcement-bar-lead::after { animation: none; opacity: 0; }\n  .vs-announcement-bar[data-mode=\"marquee\"] ul { transform: none !important; }\n}\n</style>\n\n<div class=\"vs-announcement-bar\" data-mode=\"marquee\" role=\"region\" aria-label=\"Announcements\">\n  <div class=\"vs-announcement-bar-inner\">\n    <span class=\"vs-announcement-bar-lead\">Live</span>\n    <div class=\"vs-announcement-bar-viewport\">\n      <ul>\n        <li><a href=\"#\">Prices change on 1 November. Lock in this year’s rate<span class=\"vs-announcement-bar-go\" aria-hidden=\"true\">→</span></a></li>\n        <li><a href=\"#\">Now in Frankfurt and São Paulo regions</a></li>\n        <li><a href=\"#\">Live cursors in every workspace, on every plan</a></li>\n        <li><a href=\"#\">Build 4.2 is out: 38% faster cold starts<span class=\"vs-announcement-bar-go\" aria-hidden=\"true\">→</span></a></li>\n      </ul>\n    </div>\n    <button class=\"vs-announcement-bar-close\" type=\"button\" aria-label=\"Dismiss announcements\"><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M4 4l8 8M12 4l-8 8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg></button>\n  </div>\n</div>\n\n<div class=\"vs-announcement-bar\" data-mode=\"rotate\" data-interval=\"4200\" role=\"region\" aria-label=\"What’s new\" style=\"--vs-announcement-bar-bg:#d4ff3a;--vs-announcement-bar-ink:#11130b;--vs-announcement-bar-accent:#11130b;--vs-announcement-bar-line:rgba(0,0,0,.08)\">\n  <div class=\"vs-announcement-bar-inner\">\n    <div class=\"vs-announcement-bar-viewport\">\n      <ul>\n        <li><a href=\"#\"><span class=\"vs-announcement-bar-tag\">New</span>Realtime comments land in every plan today<span class=\"vs-announcement-bar-go\" aria-hidden=\"true\">→</span></a></li>\n        <li><a href=\"#\"><span class=\"vs-announcement-bar-tag\">Event</span>Field Day, 14 Nov: a free day of talks in Lisbon<span class=\"vs-announcement-bar-go\" aria-hidden=\"true\">→</span></a></li>\n        <li><a href=\"#\"><span class=\"vs-announcement-bar-tag\">Offer</span>Two months free when you switch to yearly<span class=\"vs-announcement-bar-go\" aria-hidden=\"true\">→</span></a></li>\n      </ul>\n    </div>\n    <button class=\"vs-announcement-bar-close\" type=\"button\" aria-label=\"Dismiss\"><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M4 4l8 8M12 4l-8 8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg></button>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const fine = matchMedia('(hover: hover) and (pointer: fine)');\n  const store = { get: k => { try { return localStorage.getItem(k); } catch (_) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (_) {} } };\n  // spring(from, to, {k, c}, frame, done) — semi-implicit Euler at 240 Hz\n  const spring = (from, to, o, frame, done) => {\n    let x = from, v = o.v || 0, last = performance.now(), id;\n    const run = now => { const dt = Math.min(.04, (now - last) / 1000); last = now; const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { v += (-o.k * (x - to) - o.c * v) * h; x += v * h; }\n      frame(x); if (Math.abs(v) < .01 && Math.abs(x - to) < .01) { frame(to); done && done(); } else id = requestAnimationFrame(run); };\n    id = requestAnimationFrame(run); return () => cancelAnimationFrame(id);\n  };\n  const icon = d => `<svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"${d}\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>`;\n\n  function mount(bar) {\n    if (bar.vsAnnouncement) return bar.vsAnnouncement;\n    const key = bar.dataset.key; if (key && store.get('vs-announcement-bar:' + key)) bar.hidden = true;\n    const inner = bar.querySelector('.vs-announcement-bar-inner'), vp = bar.querySelector('.vs-announcement-bar-viewport'), list = vp.querySelector('ul');\n    const mode = bar.dataset.mode === 'rotate' ? 'rotate' : 'marquee';\n    let hover = false, focus = false, visible = true, raf = 0, last = 0, stopH = null;\n    const paused = () => hover || focus || !visible || bar.hidden;\n    bar.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && fine.matches) { hover = true; wake(); } });\n    bar.addEventListener('pointerleave', () => { hover = false; wake(); });\n    bar.addEventListener('focusin', () => { focus = true; wake(); });\n    bar.addEventListener('focusout', e => { if (!bar.contains(e.relatedTarget)) { focus = false; wake(); } });\n    new IntersectionObserver(e => { visible = e[0].isIntersecting; wake(); }).observe(bar);\n\n    let tick;\n    if (mode === 'marquee') {\n      const originals = [...list.children]; let x = 0, set = 0, v = 0, speed = 56;\n      const fill = () => {\n        list.querySelectorAll('[data-vs-clone]').forEach(n => n.remove()); set = list.scrollWidth;\n        if (!set) return; const need = Math.ceil(vp.clientWidth * 2 / set) + 1;\n        for (let i = 1; i < need; i++) originals.forEach(li => { const c = li.cloneNode(true); c.dataset.vsClone = ''; c.setAttribute('aria-hidden', 'true'); c.querySelectorAll('a,button').forEach(a => a.tabIndex = -1); list.appendChild(c); });\n      };\n      const read = () => { const s = parseFloat(getComputedStyle(bar).getPropertyValue('--vs-announcement-bar-speed')); speed = isNaN(s) ? 56 : s; };\n      tick = dt => {\n        const target = paused() || reduce.matches ? 0 : speed;\n        v += (target - v) * (1 - Math.exp(-dt * (target ? 2.4 : 6)));\n        if (set) { x = (x - v * dt) % set; list.style.transform = `translate3d(${x.toFixed(2)}px,0,0)`; }\n        return Math.abs(v - target) > .05 || target !== 0;\n      };\n      new ResizeObserver(() => { fill(); wake(); }).observe(vp);\n      new MutationObserver(() => { read(); fill(); wake(); }).observe(bar, { attributes: true, attributeFilter: ['style'] });\n      read(); (document.fonts ? document.fonts.ready : Promise.resolve()).then(fill);\n    } else {\n      const items = [...list.children]; let i = 0, t = 0, stopRoll = [];\n      const interval = () => +bar.dataset.interval || 4200;\n      const prog = document.createElement('div'); prog.className = 'vs-announcement-bar-progress'; prog.innerHTML = '<i></i>'; prog.setAttribute('aria-hidden', 'true'); inner.appendChild(prog);\n      const nav = document.createElement('div'); nav.className = 'vs-announcement-bar-nav';\n      nav.innerHTML = `<button type=\"button\" aria-label=\"Previous message\">${icon('M10 3.5 5.5 8l4.5 4.5')}</button><button type=\"button\" aria-label=\"Next message\">${icon('M6 3.5 10.5 8 6 12.5')}</button>`;\n      vp.before(nav);\n      items.forEach((li, k) => { li.classList.toggle('is-on', k === 0); li.setAttribute('aria-hidden', k === 0 ? 'false' : 'true'); li.querySelectorAll('a,button').forEach(a => a.tabIndex = k === 0 ? 0 : -1); });\n      const go = dir => {\n        const from = items[i]; i = (i + dir + items.length) % items.length; const to = items[i]; t = 0;\n        stopRoll.forEach(s => s()); stopRoll = [];\n        items.forEach(li => { if (li !== from && li !== to) { li.classList.remove('is-on'); li.style.cssText = ''; } });\n        to.classList.add('is-on'); to.setAttribute('aria-hidden', 'false'); from.setAttribute('aria-hidden', 'true');\n        from.querySelectorAll('a,button').forEach(a => a.tabIndex = -1); to.querySelectorAll('a,button').forEach(a => a.tabIndex = 0);\n        if (focus && from.contains(document.activeElement)) to.querySelector('a,button')?.focus({ preventScroll: true });\n        if (reduce.matches) {\n          from.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 250, fill: 'forwards' }).onfinish = () => from.classList.remove('is-on');\n          to.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 250 }); return;\n        }\n        // the old line rolls away, the new one rises into place — both ride one spring\n        const h = inner.offsetHeight * .9 * dir;\n        stopRoll.push(spring(0, 1, { k: 260, c: 25 }, p => {\n          from.style.transform = `translateY(${(-h * p).toFixed(2)}px)`; from.style.opacity = Math.max(0, 1 - p * 1.6).toFixed(3); from.style.filter = `blur(${(p * 4).toFixed(2)}px)`;\n          to.style.transform = `translateY(${(h * (1 - p)).toFixed(2)}px)`; to.style.opacity = Math.min(1, p * 1.4).toFixed(3); to.style.filter = `blur(${(Math.max(0, 1 - p) * 4).toFixed(2)}px)`;\n        }, () => { from.classList.remove('is-on'); from.style.cssText = ''; to.style.cssText = ''; }));\n      };\n      const [prev, next] = nav.querySelectorAll('button');\n      prev.onclick = () => { go(-1); wake(); }; next.onclick = () => { go(1); wake(); };\n      bar.addEventListener('keydown', e => { if (e.key === 'ArrowRight') { go(1); e.preventDefault(); } if (e.key === 'ArrowLeft') { go(-1); e.preventDefault(); } });\n      const bartx = prog.firstChild;\n      tick = dt => {\n        if (!paused() && items.length > 1) { t += dt * 1000; if (t >= interval()) go(1); }\n        bartx.style.transform = `scaleX(${Math.min(1, t / interval()).toFixed(4)})`;\n        return !paused();\n      };\n    }\n    const loop = now => { const dt = Math.min(.05, (now - last) / 1000 || .016); last = now; raf = tick(dt) && visible ? requestAnimationFrame(loop) : 0; };\n    const wake = () => { if (!raf && visible && !bar.hidden) { last = performance.now(); raf = requestAnimationFrame(loop); } };\n\n    const api = {\n      dismiss() {\n        if (bar.hidden || bar.classList.contains('is-leaving')) return;\n        bar.classList.add('is-leaving');\n        const h = bar.offsetHeight, done = () => { bar.hidden = true; bar.classList.remove('is-leaving'); bar.style.height = bar.style.overflow = ''; inner.style.cssText = ''; };\n        if (key) store.set('vs-announcement-bar:' + key, '1');\n        bar.dispatchEvent(new CustomEvent('vs-announcement-bar:dismiss', { bubbles: true }));\n        if (reduce.matches) return done();\n        inner.animate([{ opacity: 1, transform: 'none', filter: 'blur(0)' }, { opacity: 0, transform: 'translateY(-35%)', filter: 'blur(3px)' }], { duration: 260, easing: 'cubic-bezier(.4,0,1,1)', fill: 'forwards' });\n        stopH && stopH();\n        stopH = spring(h, 0, { k: 300, c: 30 }, y => { bar.style.height = Math.max(0, y).toFixed(2) + 'px'; }, done);\n      },\n      restore() {\n        stopH && stopH(); if (key) store.set('vs-announcement-bar:' + key, null);\n        inner.getAnimations().forEach(a => a.cancel());\n        bar.hidden = false; bar.style.height = '0px'; const h = inner.offsetHeight;\n        inner.animate([{ opacity: 0, transform: 'translateY(-35%)', filter: 'blur(3px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 480, delay: 60, easing: 'cubic-bezier(.16,1,.3,1)', fill: 'backwards' });\n        stopH = spring(0, h, { k: 260, c: 22 }, y => { bar.style.height = Math.max(0, y).toFixed(2) + 'px'; }, () => { bar.style.height = ''; });\n        wake();\n      },\n    };\n    bar.querySelector('.vs-announcement-bar-close')?.addEventListener('click', () => api.dismiss());\n    bar.addEventListener('keydown', e => { if (e.key === 'Escape') api.dismiss(); });\n    bar.vsAnnouncement = api; wake(); return api;\n  }\n  const init = () => document.querySelectorAll('.vs-announcement-bar').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsAnnouncementBar = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Marketing blocks",
    "tags": [
      "announcement",
      "banner",
      "top bar",
      "ticker",
      "marquee",
      "dismiss",
      "promo",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#d4ff3a",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/announcement-bar/index.html",
    "thumbnail": "https://vividsites.app/api/component/announcement-bar/thumb.jpg",
    "video": "https://vividsites.app/api/component/announcement-bar/thumb.mp4",
    "page": "https://vividsites.app/c/announcement-bar"
  }
}