Open demo

CSS code

HTML
<div><span class="lbl">Running total</span>
<section class="lv lv-card lv-total" data-live data-rate="3" aria-labelledby="lv-total-title">
  <div class="lv-top"><span class="lv-state" data-live-state>As of page load</span><button class="lv-btn" type="button" data-live-pause hidden>Pause updates</button></div>
  <p class="lv-num" data-ticker>18,402,117</p>
  <h3 id="lv-total-title">meals served by Second Helping food banks since 2009</h3>
  <p class="lv-foot">About 3 a second, based on this year's daily average across 46 kitchens.</p>
</section></div>

<div><span class="lbl">Goal with a countdown</span>
<section class="lv lv-card lv-goal" data-live data-rate="4" data-goal="500000" aria-labelledby="lv-goal-title">
  <div class="lv-top"><h3 id="lv-goal-title">Winter coat drive</h3><span class="lv-state" data-live-state>As of page load</span></div>
  <p class="lv-raised"><span class="lv-num" data-raised>$412,880</span><span>raised of the $500,000 goal</span></p>
  <div class="lv-track" aria-hidden="true"><i data-bar style="width:82.58%"></i></div>
  <ul class="lv-facts">
    <li><strong data-to-go>$87,120</strong>still to raise</li>
    <li><strong role="timer" data-countdown data-seconds="311530">3d 14h 32m 10s</strong>until the drive closes</li>
  </ul>
  <p class="lv-actions"><button class="lv-btn" type="button" data-live-pause hidden>Pause updates</button></p>
</section></div>

<div><span class="lbl">Since you opened this page</span>
<section class="lv lv-since" data-live data-rate="0.8" aria-label="Trees planted while you read">
  <p>Trees planted by Tallgrass volunteers since you opened this page: <strong data-ticker>0</strong></p>
  <button class="lv-btn" type="button" data-live-pause hidden>Pause updates</button>
</section></div>
CSS
/* Live counters: a running total, a fundraising goal with a countdown, and a since-you-arrived counter.
   The script recalculates values from the clock; nothing here animates except the small "Live" dot. */
.lv{--lv-ink:#13201a;--lv-muted:#4b5a52;--lv-line:#dfe7e2;--lv-accent:#13795b;--lv-accent-2:#0d5c45;--lv-soft:#e2f4ec;--lv-card:#fff;
  color:var(--lv-ink)}
.lv-card{background:var(--lv-card);border:1px solid var(--lv-line);border-radius:22px;padding:clamp(22px,3.5vw,34px);
  box-shadow:0 14px 34px -22px rgba(19,64,45,.35)}
.lv-num{font-weight:800;letter-spacing:-.03em;line-height:1.05;font-variant-numeric:tabular-nums;white-space:nowrap}
.lv-top{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.lv-state{display:inline-flex;align-items:center;gap:8px;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--lv-accent-2)}
.lv-state::before{content:"";width:9px;height:9px;border-radius:50%;background:#16a34a;box-shadow:0 0 0 0 rgba(22,163,74,.5);animation:lv-pulse 1.8s infinite}
[data-paused] .lv-state::before{background:#8a968f;animation:none}
.lv-btn{font:inherit;font-size:.82rem;font-weight:600;color:var(--lv-accent-2);background:var(--lv-card);border:1px solid #9fbcae;border-radius:999px;
  padding:6px 14px;cursor:pointer}
.lv-btn:hover{background:var(--lv-soft)}
.lv-btn:focus-visible{outline:2px solid var(--lv-accent);outline-offset:2px}
@keyframes lv-pulse{70%{box-shadow:0 0 0 8px rgba(22,163,74,0)}100%{box-shadow:0 0 0 0 rgba(22,163,74,0)}}

/* 1. Running total */
.lv-total .lv-num{margin-top:18px;font-size:clamp(2.6rem,8vw,4.6rem);color:var(--lv-accent-2)}
.lv-total h3{margin-top:6px;font-size:1.1rem;font-weight:600}
.lv-total p.lv-foot{margin-top:4px;font-size:.88rem;color:var(--lv-muted)}

/* 2. Goal with a countdown */
.lv-goal h3{font-size:1.15rem}
.lv-raised{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;margin-top:16px}
.lv-raised .lv-num{font-size:clamp(2.2rem,6vw,3.2rem)}
.lv-raised span:last-child{color:var(--lv-muted)}
.lv-track{height:12px;margin-top:14px;border-radius:99px;background:#e6ece8;overflow:hidden}
.lv-track i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#1fa37a,var(--lv-accent-2))}
.lv-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;list-style:none}
.lv-facts li{background:#f4f8f6;border-radius:14px;padding:12px 16px;font-size:.84rem;color:var(--lv-muted)}
.lv-actions{margin-top:16px}
.lv-facts strong{display:block;font-size:1.25rem;color:var(--lv-ink);font-variant-numeric:tabular-nums;white-space:nowrap}

/* 3. Since you opened this page */
.lv-since{display:flex;align-items:center;gap:16px 24px;flex-wrap:wrap;background:var(--lv-soft);border:1px solid #c5e6d6;border-radius:18px;padding:18px 22px}
.lv-since p{flex:1 1 320px;font-size:1.08rem;line-height:1.45}
.lv-since strong{font-variant-numeric:tabular-nums;color:var(--lv-accent-2);font-size:1.3em}

@media (max-width:480px){.lv-facts{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.lv-state::before{animation:none}.lv-btn{transition:none}}
JavaScript
// Live counters. Inside each [data-live] card: a running total ([data-ticker]), money raised and still to go
// ([data-raised], [data-to-go], with data-goal on the card) and a countdown ([data-countdown]). The HTML holds
// the values at page load, so without JavaScript they are still true for that moment. Values are worked out
// from the clock (data-rate = units per second), so they are right again after any pause. Updates stop while
// a card is off-screen or the tab is hidden, and the Pause button stops them on request. Numbers change in
// place with no animation, and none of them is a live region, so screen readers are not interrupted.
(() => {
  const lang = document.documentElement.lang || undefined, fmt = new Intl.NumberFormat(lang);
  const group = fmt.formatToParts(12345).find(p => p.type === 'group')?.value || ',';
  const pad = n => String(n).padStart(2, '0');
  const clock = ms => {
    const s = Math.ceil(ms / 1000);
    return s <= 0 ? 'Ended' : `${Math.floor(s / 86400)}d ${pad(Math.floor(s / 3600) % 24)}h ${pad(Math.floor(s / 60) % 60)}m ${pad(s % 60)}s`;
  };
  const cards = [...document.querySelectorAll('[data-live]')].map(card => {
    const t0 = Date.now(), rate = +card.dataset.rate || 0, goal = +card.dataset.goal || Infinity;
    const nums = [...card.querySelectorAll('[data-ticker],[data-raised],[data-to-go]')].map(el => {
      const [, pre, num, post] = el.textContent.trim().match(/^(\D*)([\d\D]*?\d)(\D*)$/) || [, '', '0', ''];
      return { el, pre, post, base: +num.split(group).join(''), sign: 'toGo' in el.dataset ? -1 : 1, cap: 'raised' in el.dataset };
    });
    const timers = [...card.querySelectorAll('[data-countdown]')].map(el =>
      ({ el, end: el.dataset.ends ? Date.parse(el.dataset.ends) : t0 + (+el.dataset.seconds || 0) * 1000 }));
    const bar = card.querySelector('[data-bar]'), btn = card.querySelector('[data-live-pause]'), state = card.querySelector('[data-live-state]');
    const c = { card, visible: false, paused: false };
    c.render = () => {
      const done = Math.floor(rate * (Date.now() - t0) / 1000);
      nums.forEach(n => {
        const v = n.sign < 0 ? Math.max(0, n.base - done) : n.cap ? Math.min(goal, n.base + done) : n.base + done;
        const text = n.pre + fmt.format(v) + n.post;
        if (n.el.textContent !== text) n.el.textContent = text;
        if (bar && n.cap) bar.style.width = (v / goal * 100).toFixed(2) + '%';
      });
      timers.forEach(t => { const text = clock(t.end - Date.now()); if (t.el.textContent !== text) t.el.textContent = text; });
    };
    if (state) state.textContent = 'Live';
    if (btn) {
      btn.hidden = false;
      btn.addEventListener('click', () => {
        c.paused = !c.paused;
        btn.textContent = c.paused ? 'Resume updates' : 'Pause updates';
        card.toggleAttribute('data-paused', c.paused);
        if (state) state.textContent = c.paused ? 'Paused' : 'Live';
        if (!c.paused) c.render();
      });
    }
    return c;
  });
  if (!cards.length) return;
  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver(list => list.forEach(e => {
      const c = cards.find(x => x.card === e.target);
      c.visible = e.isIntersecting;
      if (c.visible && !c.paused) c.render();
    }));
    cards.forEach(c => io.observe(c.card));
  } else cards.forEach(c => { c.visible = true; });
  setInterval(() => { if (!document.hidden) cards.forEach(c => { if (c.visible && !c.paused) c.render(); }); }, 250);
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Running total</span>
<section class="lv lv-total card" data-live data-rate="3" aria-labelledby="lv-total-title"><div class="card-body">
  <div class="d-flex flex-wrap align-items-center justify-content-between gap-2"><span class="lv-state" data-live-state>As of page load</span><button class="btn rounded-pill" type="button" data-live-pause hidden>Pause updates</button></div>
  <p class="lv-num mt-3" data-ticker>18,402,117</p>
  <h3 class="fw-semibold mt-2 mb-1" id="lv-total-title">meals served by Second Helping food banks since 2009</h3>
  <p class="text-body-secondary small">About 3 a second, based on this year's daily average across 46 kitchens.</p>
</div></section></div>

<div><span class="lbl">Goal with a countdown</span>
<section class="lv lv-goal card" data-live data-rate="4" data-goal="500000" aria-labelledby="lv-goal-title"><div class="card-body">
  <div class="d-flex flex-wrap align-items-center justify-content-between gap-2"><h3 class="h5 fw-bold mb-0" id="lv-goal-title">Winter coat drive</h3><span class="lv-state" data-live-state>As of page load</span></div>
  <p class="d-flex flex-wrap align-items-baseline column-gap-3 row-gap-1 mt-3"><span class="lv-num" data-raised>$412,880</span><span class="text-body-secondary">raised of the $500,000 goal</span></p>
  <div class="progress mt-3" aria-hidden="true"><div class="progress-bar" data-bar style="width:82.58%"></div></div>
  <ul class="list-unstyled row row-cols-1 row-cols-sm-2 g-2 mt-2 mb-0">
    <li class="col"><div class="lv-fact text-body-secondary"><strong data-to-go>$87,120</strong>still to raise</div></li>
    <li class="col"><div class="lv-fact text-body-secondary"><strong role="timer" data-countdown data-seconds="311530">3d 14h 32m 10s</strong>until the drive closes</div></li>
  </ul>
  <p class="mt-3"><button class="btn rounded-pill" type="button" data-live-pause hidden>Pause updates</button></p>
</div></section></div>

<div><span class="lbl">Since you opened this page</span>
<section class="lv lv-since d-flex flex-wrap align-items-center column-gap-4 row-gap-3" data-live data-rate="0.8" aria-label="Trees planted while you read">
  <p>Trees planted by Tallgrass volunteers since you opened this page: <strong data-ticker>0</strong></p>
  <button class="btn rounded-pill" type="button" data-live-pause hidden>Pause updates</button>
</section></div>
CSS
/* Live counters on Bootstrap: .card, .progress, .btn and grid utilities. The script recalculates the numbers
   from the clock; only the small "Live" dot animates, and it stops under reduced motion. */
.lv{--lv-accent:#13795b;--lv-accent-2:#0d5c45;--lv-soft:#e2f4ec;--bs-secondary-color:#4b5a52;color:#13201a}
.lv p{margin:0}
.lv.card{--bs-card-border-color:#dfe7e2;--bs-card-border-radius:22px;box-shadow:0 14px 34px -22px rgba(19,64,45,.35)}
.lv .card-body{padding:clamp(22px,3.5vw,34px)}
.lv-num{font-weight:800;letter-spacing:-.03em;line-height:1.05;font-variant-numeric:tabular-nums;white-space:nowrap}
.lv-state{display:inline-flex;align-items:center;gap:8px;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--lv-accent-2)}
.lv-state::before{content:"";width:9px;height:9px;border-radius:50%;background:#16a34a;animation:lv-pulse 1.8s infinite}
[data-paused] .lv-state::before{background:#8a968f;animation:none}
.lv .btn{--bs-btn-font-size:.82rem;--bs-btn-font-weight:600;--bs-btn-padding-x:14px;--bs-btn-padding-y:6px;--bs-btn-color:var(--lv-accent-2);
  --bs-btn-bg:#fff;--bs-btn-border-color:#9fbcae;--bs-btn-hover-color:var(--lv-accent-2);--bs-btn-hover-bg:var(--lv-soft);--bs-btn-hover-border-color:#9fbcae;
  --bs-btn-active-color:var(--lv-accent-2);--bs-btn-active-bg:var(--lv-soft);--bs-btn-active-border-color:var(--lv-accent);--bs-btn-focus-box-shadow:0 0 0 .2rem rgba(19,121,91,.35)}
.lv .btn:focus-visible{outline:2px solid var(--lv-accent);outline-offset:2px}
.lv-total .lv-num{font-size:clamp(2.6rem,8vw,4.6rem);color:var(--lv-accent-2)}
.lv-total h3{font-size:1.1rem}
.lv-goal .lv-num{font-size:clamp(2.2rem,6vw,3.2rem)}
.lv .progress{--bs-progress-height:12px;--bs-progress-bg:#e6ece8;--bs-progress-border-radius:99px}
.lv .progress-bar{border-radius:99px;background:linear-gradient(90deg,#1fa37a,var(--lv-accent-2))}
.lv-fact{background:#f4f8f6;border-radius:14px;padding:12px 16px;font-size:.84rem}
.lv-fact strong{display:block;font-size:1.25rem;color:#13201a;font-variant-numeric:tabular-nums;white-space:nowrap}
.lv-since{background:var(--lv-soft);border:1px solid #c5e6d6;border-radius:18px;padding:18px 22px}
.lv-since p{flex:1 1 320px;font-size:1.08rem;line-height:1.45}
.lv-since strong{font-variant-numeric:tabular-nums;color:var(--lv-accent-2);font-size:1.3em}
@keyframes lv-pulse{0%{box-shadow:0 0 0 0 rgba(22,163,74,.5)}70%,100%{box-shadow:0 0 0 8px rgba(22,163,74,0)}}
@media (prefers-reduced-motion:reduce){.lv-state::before{animation:none}.lv .btn{transition:none}}
JavaScript
// Live counters. Inside each [data-live] card: a running total ([data-ticker]), money raised and still to go
// ([data-raised], [data-to-go], with data-goal on the card) and a countdown ([data-countdown]). The HTML holds
// the values at page load, so without JavaScript they are still true for that moment. Values are worked out
// from the clock (data-rate = units per second), so they are right again after any pause. Updates stop while
// a card is off-screen or the tab is hidden, and the Pause button stops them on request. Numbers change in
// place with no animation, and none of them is a live region, so screen readers are not interrupted.
(() => {
  const lang = document.documentElement.lang || undefined, fmt = new Intl.NumberFormat(lang);
  const group = fmt.formatToParts(12345).find(p => p.type === 'group')?.value || ',';
  const pad = n => String(n).padStart(2, '0');
  const clock = ms => {
    const s = Math.ceil(ms / 1000);
    return s <= 0 ? 'Ended' : `${Math.floor(s / 86400)}d ${pad(Math.floor(s / 3600) % 24)}h ${pad(Math.floor(s / 60) % 60)}m ${pad(s % 60)}s`;
  };
  const cards = [...document.querySelectorAll('[data-live]')].map(card => {
    const t0 = Date.now(), rate = +card.dataset.rate || 0, goal = +card.dataset.goal || Infinity;
    const nums = [...card.querySelectorAll('[data-ticker],[data-raised],[data-to-go]')].map(el => {
      const [, pre, num, post] = el.textContent.trim().match(/^(\D*)([\d\D]*?\d)(\D*)$/) || [, '', '0', ''];
      return { el, pre, post, base: +num.split(group).join(''), sign: 'toGo' in el.dataset ? -1 : 1, cap: 'raised' in el.dataset };
    });
    const timers = [...card.querySelectorAll('[data-countdown]')].map(el =>
      ({ el, end: el.dataset.ends ? Date.parse(el.dataset.ends) : t0 + (+el.dataset.seconds || 0) * 1000 }));
    const bar = card.querySelector('[data-bar]'), btn = card.querySelector('[data-live-pause]'), state = card.querySelector('[data-live-state]');
    const c = { card, visible: false, paused: false };
    c.render = () => {
      const done = Math.floor(rate * (Date.now() - t0) / 1000);
      nums.forEach(n => {
        const v = n.sign < 0 ? Math.max(0, n.base - done) : n.cap ? Math.min(goal, n.base + done) : n.base + done;
        const text = n.pre + fmt.format(v) + n.post;
        if (n.el.textContent !== text) n.el.textContent = text;
        if (bar && n.cap) bar.style.width = (v / goal * 100).toFixed(2) + '%';
      });
      timers.forEach(t => { const text = clock(t.end - Date.now()); if (t.el.textContent !== text) t.el.textContent = text; });
    };
    if (state) state.textContent = 'Live';
    if (btn) {
      btn.hidden = false;
      btn.addEventListener('click', () => {
        c.paused = !c.paused;
        btn.textContent = c.paused ? 'Resume updates' : 'Pause updates';
        card.toggleAttribute('data-paused', c.paused);
        if (state) state.textContent = c.paused ? 'Paused' : 'Live';
        if (!c.paused) c.render();
      });
    }
    return c;
  });
  if (!cards.length) return;
  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver(list => list.forEach(e => {
      const c = cards.find(x => x.card === e.target);
      c.visible = e.isIntersecting;
      if (c.visible && !c.paused) c.render();
    }));
    cards.forEach(c => io.observe(c.card));
  } else cards.forEach(c => { c.visible = true; });
  setInterval(() => { if (!document.hidden) cards.forEach(c => { if (c.visible && !c.paused) c.render(); }); }, 250);
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4f5c55] uppercase">Running total</span>
<section class="group rounded-[22px] border border-lv-line bg-white p-[clamp(22px,3.5vw,34px)] shadow-[0_14px_34px_-22px_rgba(19,64,45,.35)]" data-live data-rate="3" aria-labelledby="lv-total-title">
  <div class="flex flex-wrap items-center justify-between gap-3"><span class="inline-flex items-center gap-2 text-[.78rem] font-bold tracking-[.08em] text-lv-deep uppercase before:size-[9px] before:rounded-full before:bg-[#16a34a] motion-safe:before:animate-lv-pulse group-data-[paused]:before:animate-none group-data-[paused]:before:bg-[#8a968f]" data-live-state>As of page load</span><button class="cursor-pointer rounded-full border border-[#9fbcae] bg-white px-3.5 py-1.5 text-[.82rem] font-semibold text-lv-deep hover:bg-lv-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lv-accent" type="button" data-live-pause hidden>Pause updates</button></div>
  <p class="mt-[18px] text-[clamp(2.6rem,8vw,4.6rem)] leading-[1.05] font-extrabold tracking-[-.03em] whitespace-nowrap text-lv-deep tabular-nums" data-ticker>18,402,117</p>
  <h3 class="mt-1.5 text-[1.1rem] font-semibold" id="lv-total-title">meals served by Second Helping food banks since 2009</h3>
  <p class="mt-1 text-[.88rem] text-lv-muted">About 3 a second, based on this year's daily average across 46 kitchens.</p>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4f5c55] uppercase">Goal with a countdown</span>
<section class="group rounded-[22px] border border-lv-line bg-white p-[clamp(22px,3.5vw,34px)] shadow-[0_14px_34px_-22px_rgba(19,64,45,.35)]" data-live data-rate="4" data-goal="500000" aria-labelledby="lv-goal-title">
  <div class="flex flex-wrap items-center justify-between gap-3"><h3 class="text-[1.15rem] font-bold" id="lv-goal-title">Winter coat drive</h3><span class="inline-flex items-center gap-2 text-[.78rem] font-bold tracking-[.08em] text-lv-deep uppercase before:size-[9px] before:rounded-full before:bg-[#16a34a] motion-safe:before:animate-lv-pulse group-data-[paused]:before:animate-none group-data-[paused]:before:bg-[#8a968f]" data-live-state>As of page load</span></div>
  <p class="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1"><span class="text-[clamp(2.2rem,6vw,3.2rem)] leading-[1.05] font-extrabold tracking-[-.03em] whitespace-nowrap tabular-nums" data-raised>$412,880</span><span class="text-lv-muted">raised of the $500,000 goal</span></p>
  <div class="mt-3.5 h-3 overflow-hidden rounded-full bg-[#e6ece8]" aria-hidden="true"><i class="block h-full rounded-full bg-linear-to-r from-[#1fa37a] to-lv-deep" data-bar style="width:82.58%"></i></div>
  <ul class="mt-[18px] grid grid-cols-1 gap-3 min-[481px]:grid-cols-2">
    <li class="rounded-[14px] bg-[#f4f8f6] px-4 py-3 text-[.84rem] text-lv-muted"><strong class="block text-[1.25rem] whitespace-nowrap text-lv-ink tabular-nums" data-to-go>$87,120</strong>still to raise</li>
    <li class="rounded-[14px] bg-[#f4f8f6] px-4 py-3 text-[.84rem] text-lv-muted"><strong class="block text-[1.25rem] whitespace-nowrap text-lv-ink tabular-nums" role="timer" data-countdown data-seconds="311530">3d 14h 32m 10s</strong>until the drive closes</li>
  </ul>
  <p class="mt-4"><button class="cursor-pointer rounded-full border border-[#9fbcae] bg-white px-3.5 py-1.5 text-[.82rem] font-semibold text-lv-deep hover:bg-lv-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lv-accent" type="button" data-live-pause hidden>Pause updates</button></p>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4f5c55] uppercase">Since you opened this page</span>
<section class="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-[18px] border border-[#c5e6d6] bg-lv-soft px-[22px] py-[18px]" data-live data-rate="0.8" aria-label="Trees planted while you read">
  <p class="flex-[1_1_320px] text-[1.08rem] leading-[1.45]">Trees planted by Tallgrass volunteers since you opened this page: <strong class="text-[1.3em] text-lv-deep tabular-nums" data-ticker>0</strong></p>
  <button class="cursor-pointer rounded-full border border-[#9fbcae] bg-white px-3.5 py-1.5 text-[.82rem] font-semibold text-lv-deep hover:bg-lv-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lv-accent" type="button" data-live-pause hidden>Pause updates</button>
</section></div>
CSS
/* Live counters: theme colours plus the pulse keyframes for the "Live" dot (used through motion-safe:). */
@theme {
  --color-lv-ink: #13201a; --color-lv-muted: #4b5a52; --color-lv-line: #dfe7e2; --color-lv-accent: #13795b; --color-lv-deep: #0d5c45; --color-lv-soft: #e2f4ec;
  --animate-lv-pulse: lv-pulse 1.8s infinite;
  @keyframes lv-pulse { 0% { box-shadow: 0 0 0 0 rgb(22 163 74 / .5); } 70%, 100% { box-shadow: 0 0 0 8px rgb(22 163 74 / 0); } }
}
JavaScript
// Live counters. Inside each [data-live] card: a running total ([data-ticker]), money raised and still to go
// ([data-raised], [data-to-go], with data-goal on the card) and a countdown ([data-countdown]). The HTML holds
// the values at page load, so without JavaScript they are still true for that moment. Values are worked out
// from the clock (data-rate = units per second), so they are right again after any pause. Updates stop while
// a card is off-screen or the tab is hidden, and the Pause button stops them on request. Numbers change in
// place with no animation, and none of them is a live region, so screen readers are not interrupted.
(() => {
  const lang = document.documentElement.lang || undefined, fmt = new Intl.NumberFormat(lang);
  const group = fmt.formatToParts(12345).find(p => p.type === 'group')?.value || ',';
  const pad = n => String(n).padStart(2, '0');
  const clock = ms => {
    const s = Math.ceil(ms / 1000);
    return s <= 0 ? 'Ended' : `${Math.floor(s / 86400)}d ${pad(Math.floor(s / 3600) % 24)}h ${pad(Math.floor(s / 60) % 60)}m ${pad(s % 60)}s`;
  };
  const cards = [...document.querySelectorAll('[data-live]')].map(card => {
    const t0 = Date.now(), rate = +card.dataset.rate || 0, goal = +card.dataset.goal || Infinity;
    const nums = [...card.querySelectorAll('[data-ticker],[data-raised],[data-to-go]')].map(el => {
      const [, pre, num, post] = el.textContent.trim().match(/^(\D*)([\d\D]*?\d)(\D*)$/) || [, '', '0', ''];
      return { el, pre, post, base: +num.split(group).join(''), sign: 'toGo' in el.dataset ? -1 : 1, cap: 'raised' in el.dataset };
    });
    const timers = [...card.querySelectorAll('[data-countdown]')].map(el =>
      ({ el, end: el.dataset.ends ? Date.parse(el.dataset.ends) : t0 + (+el.dataset.seconds || 0) * 1000 }));
    const bar = card.querySelector('[data-bar]'), btn = card.querySelector('[data-live-pause]'), state = card.querySelector('[data-live-state]');
    const c = { card, visible: false, paused: false };
    c.render = () => {
      const done = Math.floor(rate * (Date.now() - t0) / 1000);
      nums.forEach(n => {
        const v = n.sign < 0 ? Math.max(0, n.base - done) : n.cap ? Math.min(goal, n.base + done) : n.base + done;
        const text = n.pre + fmt.format(v) + n.post;
        if (n.el.textContent !== text) n.el.textContent = text;
        if (bar && n.cap) bar.style.width = (v / goal * 100).toFixed(2) + '%';
      });
      timers.forEach(t => { const text = clock(t.end - Date.now()); if (t.el.textContent !== text) t.el.textContent = text; });
    };
    if (state) state.textContent = 'Live';
    if (btn) {
      btn.hidden = false;
      btn.addEventListener('click', () => {
        c.paused = !c.paused;
        btn.textContent = c.paused ? 'Resume updates' : 'Pause updates';
        card.toggleAttribute('data-paused', c.paused);
        if (state) state.textContent = c.paused ? 'Paused' : 'Live';
        if (!c.paused) c.render();
      });
    }
    return c;
  });
  if (!cards.length) return;
  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver(list => list.forEach(e => {
      const c = cards.find(x => x.card === e.target);
      c.visible = e.isIntersecting;
      if (c.visible && !c.paused) c.render();
    }));
    cards.forEach(c => io.observe(c.card));
  } else cards.forEach(c => { c.visible = true; });
  setInterval(() => { if (!document.hidden) cards.forEach(c => { if (c.visible && !c.paused) c.render(); }); }, 250);
})();

About this set

Counters that keep changing while the page is open. Running total ticks a large figure upwards at a set rate, such as meals served since 2009 at about three a second. Goal with a countdown shows money raised against a target with a progress bar, the amount still to raise and a days, hours, minutes and seconds countdown to the closing date. Since you opened this page starts at 0 and counts trees planted while the visitor reads. The HTML holds each value at page load, so it is still true without JavaScript. A short vanilla script works every value out from the clock and a data-rate per second, so numbers are right again after any pause rather than drifting. Updates stop while a card is off-screen, using IntersectionObserver, and while the tab is hidden, and each card has a Pause updates button. Use a real start time with data-ends on the countdown and feed data-rate from your own figures.

What’s included

  • Running total, goal with countdown, and since-you-arrived counter
  • Values calculated from the clock, so they stay correct after a pause
  • Updates stop when the card is off-screen or the tab is hidden
  • Pause updates button on every card
  • Countdown takes data-seconds or a data-ends date

Accessibility

Each card has a Pause updates button, so the moving numbers can be stopped as WCAG 2.2.2 asks; it shows a focus outline and its label changes to Resume updates. The numbers update in place with no animation, the countdown has role timer, and none of them is a live region, so screen readers are not interrupted every second. Without JavaScript the buttons stay hidden and the status reads As of page load instead of Live. The pulsing dot stops under prefers-reduced-motion.

Customise it

Set data-rate (units per second) and data-goal on each [data-live] card, and data-seconds or data-ends on the countdown. Colours are --lv-accent, --lv-accent-2 and --lv-soft on .lv; in Bootstrap the button is .btn restyled through --bs-btn-* variables, and in Tailwind edit the --color-lv-* theme colours.