25 CSS Number Counter Animations03 / 25

Light JSMIT licensed

E-commerce Checkout Live Stock & Sales Counter

The scarcity block that lifts checkout conversion without lying to the shopper: a live "only N left" readout that ticks down as other carts claim units, a sold-today counter that climbs, and a stock bar that shifts hue from calm to urgent through a single color-mix() interpolation. Every change is announced politely once, never on every frame.

Published

Live Demo
Try it

The code

<section class="cnc-03" aria-label="Ecommerce live stock and sales counter demo">
  <div class="cnc-03__stage">
    <article class="cnc-03__card" data-left="ok" style="--cnc-03-cap:40">
      <div class="cnc-03__media">
        <img class="cnc-03__img" alt="Charcoal running shoe on a neutral background" loading="lazy" decoding="async"
          src="https://images.unsplash.com/photo-1542291026-7eec264c27ff?q=80&w=800&auto=format&fit=crop">
        <span class="cnc-03__badge">Best seller</span>
      </div>
      <div class="cnc-03__body">
        <p class="cnc-03__brand">Atlas Athletics</p>
        <h3 class="cnc-03__name">Meridian Runner — Slate</h3>
        <p class="cnc-03__price">$148.00 <s class="cnc-03__was">$185.00</s></p>
        <div class="cnc-03__stock">
          <div class="cnc-03__meter" role="meter" aria-valuemin="0" aria-valuemax="40" aria-valuenow="12" aria-label="Units remaining in stock">
            <span class="cnc-03__fill"></span>
          </div>
          <p class="cnc-03__line">
            <span class="cnc-03__pill"><span class="cnc-03__dot" aria-hidden="true"></span><b class="cnc-03__left" aria-hidden="true">12</b> left in stock</span>
            <span class="cnc-03__sold"><b class="cnc-03__soldn" aria-hidden="true">86</b> sold today</span>
          </p>
        </div>
        <button class="cnc-03__cta" type="button">Add to cart</button>
        <p class="cnc-03__ship">Free next-day delivery · 60-day returns</p>
        <p class="cnc-03__sr" role="status" aria-live="polite"></p>
      </div>
    </article>
    <p class="cnc-03__chip" aria-hidden="true">color-mix() ramp · :has() state · single custom property drives bar + hue</p>
  </div>
</section>
.cnc-03,
.cnc-03 *,
.cnc-03 *::before,
.cnc-03 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-03 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-03-bg: oklch(0.97 0.006 95);
  --cnc-03-card: oklch(1 0 0);
  --cnc-03-ink: oklch(0.2 0.015 265);
  --cnc-03-mut: oklch(0.55 0.015 265);
  --cnc-03-ok: oklch(0.72 0.16 155);
  --cnc-03-warn: oklch(0.68 0.2 35);
  background: var(--cnc-03-bg);
  color: var(--cnc-03-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-03__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(18px,4vw,48px);
}

.cnc-03__card {
  display: grid;
  grid-template-columns: minmax(0,0.9fr) minmax(0,1.1fr);
  width: min(100%,720px);
  border-radius: 24px;
  overflow: hidden;
  background: var(--cnc-03-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 26px 60px oklch(0.4 0.03 265/.16);
  transition: box-shadow .4s;
}

.cnc-03__card[data-left="low"] {
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 26px 60px color-mix(in oklch,var(--cnc-03-warn) 28%,transparent);
}

.cnc-03__media {
  position: relative;
  min-height: 260px;
  background: linear-gradient(145deg,oklch(0.9 0.03 95),oklch(0.82 0.04 60));
}

.cnc-03__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.cnc-03__badge {
  position: absolute;
  top: 14px;
  left: 14px;
  padding: 6px 11px;
  border-radius: 99px;
  background: oklch(0.2 0.015 265/.82);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: oklch(0.99 0 0);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.cnc-03__body {
  padding: clamp(20px,3vw,30px);
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.cnc-03__brand {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--cnc-03-mut);
}

.cnc-03__name {
  font-size: 20px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-03__price {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
}

.cnc-03__was {
  font-size: 14px;
  font-weight: 400;
  color: var(--cnc-03-mut);
  margin-left: 6px;
}

.cnc-03__stock {
  margin-top: 6px;
  display: grid;
  gap: 10px;
}

.cnc-03__meter {
  height: 8px;
  border-radius: 99px;
  background: oklch(0 0 0/.08);
  overflow: hidden;
}

.cnc-03__fill {
  display: block;
  height: 100%;
  border-radius: 99px;
  inline-size: calc(var(--cnc-03-left,12) / var(--cnc-03-cap) * 100%);
  background: color-mix(in oklch,var(--cnc-03-ok),var(--cnc-03-warn) calc(100% - (var(--cnc-03-left,12) / var(--cnc-03-cap) * 100%)));
  transition: inline-size .7s cubic-bezier(.16,1,.3,1),background .7s;
}

.cnc-03__line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 13.5px;
  color: var(--cnc-03-mut);
}

.cnc-03__pill {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 11px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--cnc-03-ok) 14%,transparent);
  color: color-mix(in oklch,var(--cnc-03-ok) 60%,black);
  font-weight: 640;
  transition: background .5s,color .5s;
}

.cnc-03__card[data-left="low"] .cnc-03__pill {
  background: color-mix(in oklch,var(--cnc-03-warn) 16%,transparent);
  color: color-mix(in oklch,var(--cnc-03-warn) 65%,black);
}

.cnc-03__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}

.cnc-03__card[data-left="low"] .cnc-03__dot {
  animation: cnc-03-pulse 1.5s ease-in-out infinite;
}

@keyframes cnc-03-pulse {
  0%,
    100% {
    opacity: 1;
    transform: scale(1);
  }

  50% {
    opacity: .35;
    transform: scale(1.35);
  }
}

.cnc-03__left,
.cnc-03__soldn {
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  font-weight: 750;
  color: var(--cnc-03-ink);
  display: inline-block;
}

.cnc-03__pill .cnc-03__left {
  color: inherit;
}

.cnc-03__bump {
  animation: cnc-03-bump .45s cubic-bezier(.16,1,.3,1);
}

@keyframes cnc-03-bump {
  0% {
    transform: translateY(0);
  }

  40% {
    transform: translateY(-4px) scale(1.08);
  }

  100% {
    transform: none;
  }
}

.cnc-03__cta {
  margin-top: 8px;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: var(--cnc-03-ink);
  color: oklch(0.99 0 0);
  font: inherit;
  font-size: 15px;
  font-weight: 660;
  cursor: pointer;
  transition: transform .2s,background .2s;
}

.cnc-03__cta:hover {
  background: color-mix(in oklch,var(--cnc-03-ink) 84%,var(--cnc-03-warn));
  transform: translateY(-1px);
}

.cnc-03__cta:active {
  transform: translateY(0) scale(.99);
}

.cnc-03__cta:focus-visible {
  outline: 3px solid var(--cnc-03-warn);
  outline-offset: 3px;
}

.cnc-03__ship {
  font-size: 12px;
  color: var(--cnc-03-mut);
}

.cnc-03__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-03__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-03-mut);
  padding: 7px 14px;
  border: 1px solid oklch(0 0 0/.12);
  border-radius: 99px;
  text-align: center;
}

@media (max-width: 640px) {
  .cnc-03__card {
    grid-template-columns: 1fr;
  }

  .cnc-03__media {
    min-height: 200px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cnc-03 * {
    animation: none !important;
    transition-duration: .01ms !important;
  }
}
(() => {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  document.querySelectorAll('.cnc-03__card').forEach((card) => {
    if (card.dataset.wired) return;
    card.dataset.wired = '1';
    const cap = +getComputedStyle(card).getPropertyValue('--cnc-03-cap') || 40;
    const leftEl = card.querySelector('.cnc-03__left');
    const soldEl = card.querySelector('.cnc-03__soldn');
    const meter = card.querySelector('.cnc-03__meter');
    const say = card.querySelector('.cnc-03__sr');
    let left = 12, sold = 86, sayTimer = 0;

    const tween = (el, from, to, ms) => {          // short rAF roll, integers only
      if (reduce || from === to) { el.textContent = to; return; }
      const t0 = performance.now();
      const step = (t) => {
        const p = Math.min(1, (t - t0) / ms);
        el.textContent = Math.round(from + (to - from) * (1 - Math.pow(1 - p, 3)));
        if (p < 1) requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    };

    const render = () => {
      card.style.setProperty('--cnc-03-left', left);
      card.dataset.left = left <= cap * 0.25 ? 'low' : 'ok';
      meter.setAttribute('aria-valuenow', left);
      leftEl.classList.remove('cnc-03__bump');
      void leftEl.offsetWidth;                      // restart the bump animation
      leftEl.classList.add('cnc-03__bump');
      clearTimeout(sayTimer);                       // announce once, after it settles
      sayTimer = setTimeout(() => { say.textContent = left + ' left in stock, ' + sold + ' sold today'; }, 700);
    };
    render();

    const tick = () => {
      if (document.visibilityState !== 'visible') return;
      if (left > 2 && Math.random() > 0.35) { const p = left; left -= 1; tween(leftEl, p, left, 500); }
      const ps = sold; sold += Math.random() > 0.5 ? 2 : 1; tween(soldEl, ps, sold, 600);
      render();
    };
    const id = setInterval(tick, 4200);
    card.querySelector('.cnc-03__cta').addEventListener('click', () => {
      if (left > 0) { const p = left; left -= 1; tween(leftEl, p, left, 400); const ps = sold; sold += 1; tween(soldEl, ps, sold, 400); render(); }
    });
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Number Counter Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: E-commerce Checkout Live Stock & Sales Counter
Source: https://codefronts.com/motion/css-number-counter-animations/e-commerce-checkout-live-stock-and-sales-counter/

The scarcity block that lifts checkout conversion without lying to the shopper: a live "only N left" readout that ticks down as other carts claim units, a sold-today counter that climbs, and a stock bar that shifts hue from calm to urgent through a single color-mix() interpolation. Every change is announced politely once, never on every frame.
## HTML
```html
<section class="cnc-03" aria-label="Ecommerce live stock and sales counter demo">
  <div class="cnc-03__stage">
    <article class="cnc-03__card" data-left="ok" style="--cnc-03-cap:40">
      <div class="cnc-03__media">
        <img class="cnc-03__img" alt="Charcoal running shoe on a neutral background" loading="lazy" decoding="async"
          src="https://images.unsplash.com/photo-1542291026-7eec264c27ff?q=80&w=800&auto=format&fit=crop">
        <span class="cnc-03__badge">Best seller</span>
      </div>
      <div class="cnc-03__body">
        <p class="cnc-03__brand">Atlas Athletics</p>
        <h3 class="cnc-03__name">Meridian Runner — Slate</h3>
        <p class="cnc-03__price">$148.00 <s class="cnc-03__was">$185.00</s></p>
        <div class="cnc-03__stock">
          <div class="cnc-03__meter" role="meter" aria-valuemin="0" aria-valuemax="40" aria-valuenow="12" aria-label="Units remaining in stock">
            <span class="cnc-03__fill"></span>
          </div>
          <p class="cnc-03__line">
            <span class="cnc-03__pill"><span class="cnc-03__dot" aria-hidden="true"></span><b class="cnc-03__left" aria-hidden="true">12</b> left in stock</span>
            <span class="cnc-03__sold"><b class="cnc-03__soldn" aria-hidden="true">86</b> sold today</span>
          </p>
        </div>
        <button class="cnc-03__cta" type="button">Add to cart</button>
        <p class="cnc-03__ship">Free next-day delivery · 60-day returns</p>
        <p class="cnc-03__sr" role="status" aria-live="polite"></p>
      </div>
    </article>
    <p class="cnc-03__chip" aria-hidden="true">color-mix() ramp · :has() state · single custom property drives bar + hue</p>
  </div>
</section>
```
## CSS
```css
.cnc-03,
.cnc-03 *,
.cnc-03 *::before,
.cnc-03 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-03 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-03-bg: oklch(0.97 0.006 95);
  --cnc-03-card: oklch(1 0 0);
  --cnc-03-ink: oklch(0.2 0.015 265);
  --cnc-03-mut: oklch(0.55 0.015 265);
  --cnc-03-ok: oklch(0.72 0.16 155);
  --cnc-03-warn: oklch(0.68 0.2 35);
  background: var(--cnc-03-bg);
  color: var(--cnc-03-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-03__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(18px,4vw,48px);
}

.cnc-03__card {
  display: grid;
  grid-template-columns: minmax(0,0.9fr) minmax(0,1.1fr);
  width: min(100%,720px);
  border-radius: 24px;
  overflow: hidden;
  background: var(--cnc-03-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 26px 60px oklch(0.4 0.03 265/.16);
  transition: box-shadow .4s;
}

.cnc-03__card[data-left="low"] {
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 26px 60px color-mix(in oklch,var(--cnc-03-warn) 28%,transparent);
}

.cnc-03__media {
  position: relative;
  min-height: 260px;
  background: linear-gradient(145deg,oklch(0.9 0.03 95),oklch(0.82 0.04 60));
}

.cnc-03__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.cnc-03__badge {
  position: absolute;
  top: 14px;
  left: 14px;
  padding: 6px 11px;
  border-radius: 99px;
  background: oklch(0.2 0.015 265/.82);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: oklch(0.99 0 0);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.cnc-03__body {
  padding: clamp(20px,3vw,30px);
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.cnc-03__brand {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--cnc-03-mut);
}

.cnc-03__name {
  font-size: 20px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-03__price {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
}

.cnc-03__was {
  font-size: 14px;
  font-weight: 400;
  color: var(--cnc-03-mut);
  margin-left: 6px;
}

.cnc-03__stock {
  margin-top: 6px;
  display: grid;
  gap: 10px;
}

.cnc-03__meter {
  height: 8px;
  border-radius: 99px;
  background: oklch(0 0 0/.08);
  overflow: hidden;
}

.cnc-03__fill {
  display: block;
  height: 100%;
  border-radius: 99px;
  inline-size: calc(var(--cnc-03-left,12) / var(--cnc-03-cap) * 100%);
  background: color-mix(in oklch,var(--cnc-03-ok),var(--cnc-03-warn) calc(100% - (var(--cnc-03-left,12) / var(--cnc-03-cap) * 100%)));
  transition: inline-size .7s cubic-bezier(.16,1,.3,1),background .7s;
}

.cnc-03__line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 13.5px;
  color: var(--cnc-03-mut);
}

.cnc-03__pill {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 11px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--cnc-03-ok) 14%,transparent);
  color: color-mix(in oklch,var(--cnc-03-ok) 60%,black);
  font-weight: 640;
  transition: background .5s,color .5s;
}

.cnc-03__card[data-left="low"] .cnc-03__pill {
  background: color-mix(in oklch,var(--cnc-03-warn) 16%,transparent);
  color: color-mix(in oklch,var(--cnc-03-warn) 65%,black);
}

.cnc-03__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}

.cnc-03__card[data-left="low"] .cnc-03__dot {
  animation: cnc-03-pulse 1.5s ease-in-out infinite;
}

@keyframes cnc-03-pulse {
  0%,
    100% {
    opacity: 1;
    transform: scale(1);
  }

  50% {
    opacity: .35;
    transform: scale(1.35);
  }
}

.cnc-03__left,
.cnc-03__soldn {
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  font-weight: 750;
  color: var(--cnc-03-ink);
  display: inline-block;
}

.cnc-03__pill .cnc-03__left {
  color: inherit;
}

.cnc-03__bump {
  animation: cnc-03-bump .45s cubic-bezier(.16,1,.3,1);
}

@keyframes cnc-03-bump {
  0% {
    transform: translateY(0);
  }

  40% {
    transform: translateY(-4px) scale(1.08);
  }

  100% {
    transform: none;
  }
}

.cnc-03__cta {
  margin-top: 8px;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: var(--cnc-03-ink);
  color: oklch(0.99 0 0);
  font: inherit;
  font-size: 15px;
  font-weight: 660;
  cursor: pointer;
  transition: transform .2s,background .2s;
}

.cnc-03__cta:hover {
  background: color-mix(in oklch,var(--cnc-03-ink) 84%,var(--cnc-03-warn));
  transform: translateY(-1px);
}

.cnc-03__cta:active {
  transform: translateY(0) scale(.99);
}

.cnc-03__cta:focus-visible {
  outline: 3px solid var(--cnc-03-warn);
  outline-offset: 3px;
}

.cnc-03__ship {
  font-size: 12px;
  color: var(--cnc-03-mut);
}

.cnc-03__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-03__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-03-mut);
  padding: 7px 14px;
  border: 1px solid oklch(0 0 0/.12);
  border-radius: 99px;
  text-align: center;
}

@media (max-width: 640px) {
  .cnc-03__card {
    grid-template-columns: 1fr;
  }

  .cnc-03__media {
    min-height: 200px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cnc-03 * {
    animation: none !important;
    transition-duration: .01ms !important;
  }
}
```

## JavaScript
```js
(() => {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  document.querySelectorAll('.cnc-03__card').forEach((card) => {
    if (card.dataset.wired) return;
    card.dataset.wired = '1';
    const cap = +getComputedStyle(card).getPropertyValue('--cnc-03-cap') || 40;
    const leftEl = card.querySelector('.cnc-03__left');
    const soldEl = card.querySelector('.cnc-03__soldn');
    const meter = card.querySelector('.cnc-03__meter');
    const say = card.querySelector('.cnc-03__sr');
    let left = 12, sold = 86, sayTimer = 0;

    const tween = (el, from, to, ms) => {          // short rAF roll, integers only
      if (reduce || from === to) { el.textContent = to; return; }
      const t0 = performance.now();
      const step = (t) => {
        const p = Math.min(1, (t - t0) / ms);
        el.textContent = Math.round(from + (to - from) * (1 - Math.pow(1 - p, 3)));
        if (p < 1) requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    };

    const render = () => {
      card.style.setProperty('--cnc-03-left', left);
      card.dataset.left = left <= cap * 0.25 ? 'low' : 'ok';
      meter.setAttribute('aria-valuenow', left);
      leftEl.classList.remove('cnc-03__bump');
      void leftEl.offsetWidth;                      // restart the bump animation
      leftEl.classList.add('cnc-03__bump');
      clearTimeout(sayTimer);                       // announce once, after it settles
      sayTimer = setTimeout(() => { say.textContent = left + ' left in stock, ' + sold + ' sold today'; }, 700);
    };
    render();

    const tick = () => {
      if (document.visibilityState !== 'visible') return;
      if (left > 2 && Math.random() > 0.35) { const p = left; left -= 1; tween(leftEl, p, left, 500); }
      const ps = sold; sold += Math.random() > 0.5 ? 2 : 1; tween(soldEl, ps, sold, 600);
      render();
    };
    const id = setInterval(tick, 4200);
    card.querySelector('.cnc-03__cta').addEventListener('click', () => {
      if (left > 0) { const p = left; left -= 1; tween(leftEl, p, left, 400); const ps = sold; sold += 1; tween(soldEl, ps, sold, 400); render(); }
    });
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
```

How this works

One number drives the entire component. JS writes a single custom property; CSS derives the bar width, the color, and the urgency state from it:

root.style.setProperty('--cnc-03-left', left);
/* CSS does the rest */
.bar { inline-size: calc(var(--cnc-03-left) / var(--cnc-03-cap) * 100%);
       background: color-mix(in oklch, var(--urgent), var(--calm)
                   calc(var(--cnc-03-left) / var(--cnc-03-cap) * 100%)); }

color-mix() with a calc()'d percentage is a continuous color ramp in one declaration — no breakpoint classes, no JS color math. As stock falls the mix leans toward the urgent hue automatically.

.card:has([data-left='low']) .pill { animation: cnc-03-pulse 1.6s infinite; }

:has() lets a state written on the number restyle its ancestors — the pill pulses, the card border warms — without touching a single extra class. The count-down itself uses a short rAF tween so the digits roll rather than jump, and an aria-live="polite" region announces only the settled sentence ("3 left in stock"), debounced, so assistive tech gets the meaning instead of the animation.

Make it yours

  • Server-truthful stock: replace the simulated interval with an SSE or WebSocket message; keep the same setProperty call so the visuals need no changes.
  • Thresholds: adjust the low/critical cut-offs in the JS map — retail teams usually use ≤10% for critical and ≤25% for low.
  • Add a size selector: give each variant its own --cnc-03-left and let :has() drive the shared bar; the CSS is already variant-agnostic.
  • Swap urgency for reassurance: flip the color-mix operands so a healthy stock level glows green instead of red-warning at the bottom.

Gotchas — read before shipping

  • Fake scarcity is a dark pattern and a legal risk in the EU/UK (Omnibus Directive). Wire this to real inventory or don't ship it.
  • aria-live on the digits themselves makes screen readers read every tween frame. Put the live region on a separate node and write to it only when the value settles.
  • Timers keep running in background tabs at throttled rates; use document.visibilityState or the Page Visibility API to pause the simulation and avoid a jarring jump on return.
  • Never animate width on a bar in a hot loop — use transform:scaleX() or, as here, an inline-size change at a low update frequency (once per stock change, not per frame).
  • role="status" already implies aria-live="polite"; declaring both is harmless but declaring aria-live="assertive" here will interrupt the user mid-sentence.

Browser support

ChromeSafariFirefoxEdge
111+16.4+121+111+

:has() needs Chrome 105 / Safari 15.4 / Firefox 121; color-mix() Chrome 111 / Safari 16.2 / Firefox 113. Both degrade to the static base colors, and the counter itself is plain DOM text so it works everywhere.

Search CodeFronts

Loading…