Number Card

  • 325+

    Projects completed

  • 150+

    Happy clients

  • 98%

    Client retention

  • 12,000+

    Commits this year

@use 'sprucecss/scss/spruce' as *;

.number-card-list {
  @include clear-list;
  display: grid;
  gap: spacer('l');
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));

  // The items sit side by side: clear the stack margins of the base list style.
  > li {
    margin-block: 0;
  }
}

.number-card {
  @include layout-stack('xs');
  border-block-end: config('border-width-base', $display) solid color('border');
  margin-block: 0;
  padding-block-end: spacer('l');

  &__data {
    color: color('primary');
    font-family: config('font-family-heading', $typography);
    font-size: responsive-font-size(4.5rem, 35);
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    line-height: 1;
  }

  &__caption {
    color: color('muted');
    font-weight: 600;
  }
}
<ul class="number-card-list">
    <li class="number-card">
        <p class="number-card__data">
            <span class="sr-only">325+</span>
            <span aria-hidden="true"><span data-countup="325">325</span>+</span>
        </p>
        <p class="number-card__caption">Projects completed</p>
    </li>
    <li class="number-card">
        <p class="number-card__data">
            <span class="sr-only">150+</span>
            <span aria-hidden="true"><span data-countup="150">150</span>+</span>
        </p>
        <p class="number-card__caption">Happy clients</p>
    </li>
    <li class="number-card">
        <p class="number-card__data">
            <span class="sr-only">98%</span>
            <span aria-hidden="true"><span data-countup="98">98</span>%</span>
        </p>
        <p class="number-card__caption">Client retention</p>
    </li>
    <li class="number-card">
        <p class="number-card__data">
            <span class="sr-only">12,000+</span>
            <span aria-hidden="true"><span data-countup="12000">12,000</span>+</span>
        </p>
        <p class="number-card__caption">Commits this year</p>
    </li>
</ul>
(() => {
  const counters = document.querySelectorAll('[data-countup]');
  const duration = 2000;
  const format = new Intl.NumberFormat(document.documentElement.lang || undefined);

  // With reduced motion, keep the final numbers that are already in the HTML.
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  const countUp = (element) => {
    const target = Number(element.dataset.countup);
    let start = null;

    const step = (timestamp) => {
      start ??= timestamp;
      const progress = Math.min((timestamp - start) / duration, 1);
      const eased = 1 - (1 - progress) ** 3;

      element.textContent = format.format(Math.round(target * eased));

      if (progress < 1) requestAnimationFrame(step);
    };

    requestAnimationFrame(step);
  };

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        countUp(entry.target);
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.5 });

  counters.forEach((counter) => {
    counter.textContent = format.format(0);
    observer.observe(counter);
  });
})();

Number Card

Highlight your numbers (projects, clients, uptime) with large figures that count up when they scroll into view.

Anatomy

  • .number-card-list is a responsive list (auto-fit columns, min. 12rem).
  • .number-card has the figure (__data) and its label (__caption).
  • The animated number is a span with the target in data-countup; the final value is in the HTML too.

Accessibility

  • Screen readers read a visually hidden copy of the final value ("12,000+"); the animated digits are hidden from them, so they never hear "0" or a half-counted number.
  • With reduced motion, there's no animation at all: the final numbers stay as they are in the HTML.
  • Without JavaScript, the final numbers show as well.

Technical Details

  • An IntersectionObserver starts each counter when half of it is visible, once. It counts with requestAnimationFrame and an ease-out curve; change duration in the script (2s by default).
  • The numbers are formatted with Intl.NumberFormat in the page language (12,000 or 12 000), and tabular-nums keeps the width steady while counting.
  • The size is fluid with responsive-font-size().

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.