DesignBlocs UI Library

Impact Stats

A muted statement above a four-column band of figures that count up as the section arrives, each ruled by a hairline that draws itself down. Folds to a 2×2 divided by a single cross.

  • Stats
  • No dependencies
  • MIT
  • Google Sans Flex · SIL Open Font License 1.1
Preview
Open ↗
Impact Stats — previewLoading

Copy the code

The whole component, in one file. Yours from the moment you paste it.

Install it

Straight into a project with a shadcn setup — no account, no limit.

npx shadcn@latest add https://designblocs.com/r/impact-stats.json

Hand it to an agent

A prompt for Claude Code, Cursor or v0 that adapts the component to your codebase rather than pasting it in raw.

src/components/ui-library/blocks/ImpactStats.tsx

/**
 * ImpactStats — a statement, then four figures that count up as they arrive.
 *
 * The figures are the point, so they are given the room: a muted statement on
 * the left at the top, then a wide four-column band where each column is ruled
 * by a hairline that draws itself downward, the number counts, and the caption
 * rises in behind it. At narrow widths the band folds to a 2×2 divided by a
 * single cross rather than four stacked rows, which keeps it a *set* of figures
 * instead of a list.
 *
 * The count is deliberate rather than decorative: it starts when the section is
 * actually looked at, runs once, and under `prefers-reduced-motion` the numbers
 * are simply there. The accessible name of each figure is the final value from
 * the first paint, so a screen reader never reads a number mid-count.
 *
 * SELF-CONTAINED: imports React and nothing else. Every style is either a
 * complete Tailwind class or lives in the scoped block below.
 */
import { useEffect, useRef, useState } from "react";

export interface ImpactStat {
  /** The final figure, written as it should read: "10,989,916". */
  value: string;
  /** The small label above the number. */
  category: string;
  /** The sentence under the number. */
  label: string;
}

export interface ImpactStatsProps {
  /** The opening clause, carried in full ink against the muted remainder. */
  lead?: string;
  statement?: string;
  stats?: ImpactStat[];
  tone?: "light" | "dark";
}

const DEFAULTS = {
  lead: "Progress, measured.",
  statement:
    "A growing record of solutions delivered, lives reached, and water generated and preserved.",
  stats: [
    { value: "252", category: "Solutions", label: "Solutions delivered" },
    { value: "10,989,916", category: "People reached", label: "Lives benefited to date" },
    { value: "21,749,220", category: "Water generated", label: "Litres of water generated to date" },
    { value: "86,996,880", category: "Water preserved", label: "Litres of water saved to date" },
  ] as ImpactStat[],
  tone: "light" as const,
};

const STYLES = `
[data-block="ImpactStats"],
[data-block="ImpactStats"] :is(h2, p, span) {
  font-family: "Google Sans Flex", "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
}
[data-block="ImpactStats"] {
  --is-ink: #10171c;
  --is-muted: rgb(16 23 28 / 48%);
  --is-rule: rgb(16 23 28 / 22%);
  --is-rule-soft: rgb(16 23 28 / 18%);
  --is-ground: linear-gradient(180deg, #f3f8f8 0%, #fff 76%);
  --is-gutter: 48px;
  --is-ease: cubic-bezier(0.22, 1, 0.36, 1);

  position: relative;
  display: flex;
  flex-direction: column;
  isolation: isolate;
  overflow: hidden;
  padding: 134px var(--is-gutter) 138px;
  color: var(--is-ink);
  background: var(--is-ground);
}
[data-block="ImpactStats"][data-tone="dark"] {
  --is-ink: #f4f8fa;
  --is-muted: rgb(244 248 250 / 52%);
  --is-rule: rgb(244 248 250 / 26%);
  --is-rule-soft: rgb(244 248 250 / 18%);
  --is-ground: linear-gradient(180deg, #0c1216 0%, #05080a 76%);
}

[data-block="ImpactStats"] .is-statement {
  max-width: 31ch;
  margin: 0;
  color: var(--is-muted);
  font-size: clamp(31px, 2.7vw, 50px);
  font-weight: 400;
  line-height: 1.04;
  letter-spacing: -0.04em;
}
[data-block="ImpactStats"] .is-statement span {
  color: var(--is-ink);
}

/* The band sits to the right of the statement at desk widths — the asymmetry
   is what stops it reading as a generic four-up. */
[data-block="ImpactStats"] .is-stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  width: 70%;
  min-height: 288px;
  margin-top: 88px;
  margin-left: auto;
}
[data-block="ImpactStats"] .is-stat {
  position: relative;
  display: flex;
  min-width: 0;
  flex-direction: column;
}
[data-block="ImpactStats"] .is-stat::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 1px;
  background: var(--is-rule);
  transform: scaleY(0);
  transform-origin: top;
}
[data-block="ImpactStats"] .is-upper {
  display: flex;
  min-height: 0;
  flex: 1;
  flex-direction: column;
  padding: 0 18px;
}
[data-block="ImpactStats"] .is-category {
  max-width: 24ch;
  margin: 0;
  color: var(--is-muted);
  font-size: 11px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}
[data-block="ImpactStats"] .is-number {
  margin: clamp(28px, 4vh, 46px) 0 0;
  font-size: clamp(34px, 2.8vw, 54px);
  font-weight: 400;
  line-height: 0.9;
  letter-spacing: -0.055em;
  white-space: nowrap;
}
[data-block="ImpactStats"] .is-label {
  max-width: 24ch;
  margin: 0;
  padding: 0 18px;
  font-size: clamp(15px, 1.05vw, 19px);
  line-height: 1.28;
  letter-spacing: -0.02em;
  opacity: 0;
  transform: translateY(12px);
}

[data-block="ImpactStats"][data-visible="true"] .is-stat::before {
  animation: is-rule-down 820ms var(--is-ease) both;
  animation-delay: calc(var(--is-index) * 90ms);
}
[data-block="ImpactStats"][data-visible="true"] .is-label {
  animation: is-label-in 460ms var(--is-ease) both;
  animation-delay: calc(760ms + var(--is-index) * 90ms);
}
@keyframes is-rule-down { to { transform: scaleY(1); } }
@keyframes is-label-in { to { opacity: 1; transform: none; } }

@media (max-width: 1100px) {
  [data-block="ImpactStats"] .is-stats {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    width: 100%;
    min-height: 0;
    margin: 150px 0 0;
  }
  [data-block="ImpactStats"] .is-stat {
    min-height: 270px;
    border-bottom: 1px solid var(--is-rule-soft);
  }
  [data-block="ImpactStats"] .is-upper { padding: 24px 20px 0; }
  [data-block="ImpactStats"] .is-label { padding: 0 20px 32px; }
  [data-block="ImpactStats"] .is-stat::before { bottom: 32px; }
  [data-block="ImpactStats"] .is-stat:nth-child(odd) .is-upper,
  [data-block="ImpactStats"] .is-stat:nth-child(odd) .is-label { padding-left: 0; }
  [data-block="ImpactStats"] .is-stat:nth-child(odd)::before { display: none; }
}

/* A 2×2 divided by one cross: a vertical rule between the columns, a
   horizontal rule between the rows. Four stacked rows would lose the sense
   that these figures belong together. */
@media (max-width: 767px) {
  [data-block="ImpactStats"] {
    --is-gutter: 20px;
    padding-top: 96px;
  }
  [data-block="ImpactStats"] .is-statement { font-size: 34px; line-height: 1.06; }
  [data-block="ImpactStats"] .is-stats { margin-top: 110px; }
  [data-block="ImpactStats"] .is-stat { min-height: 0; border: 0; }
  [data-block="ImpactStats"] .is-stat:nth-child(-n + 2) {
    padding-bottom: 30px;
    border-bottom: 1px solid var(--is-rule-soft);
  }
  [data-block="ImpactStats"] .is-stat:nth-child(n + 3) { padding-top: 30px; }
  [data-block="ImpactStats"] .is-stat::before { display: block; bottom: 0; }
  [data-block="ImpactStats"] .is-stat:nth-child(odd)::before { display: none; }
  [data-block="ImpactStats"] .is-upper,
  [data-block="ImpactStats"] .is-label { padding: 0; }
  [data-block="ImpactStats"] .is-stat:nth-child(even) .is-upper,
  [data-block="ImpactStats"] .is-stat:nth-child(even) .is-label { padding-left: 18px; }
  [data-block="ImpactStats"] .is-stat:nth-child(odd) .is-upper,
  [data-block="ImpactStats"] .is-stat:nth-child(odd) .is-label { padding-right: 18px; }
  [data-block="ImpactStats"] .is-number {
    margin-top: 18px;
    font-size: clamp(19px, 6.1vw, 27px);
    letter-spacing: -0.04em;
  }
  [data-block="ImpactStats"] .is-label { max-width: none; margin-top: 14px; font-size: 14px; }
  [data-block="ImpactStats"] .is-category { max-width: none; }
}

@media (prefers-reduced-motion: reduce) {
  [data-block="ImpactStats"] .is-stat::before { transform: scaleY(1); animation: none !important; }
  [data-block="ImpactStats"] .is-label { opacity: 1; transform: none; animation: none !important; }
}
`;

const FORMAT = new Intl.NumberFormat("en-US");

/**
 * Count to the figure, once, when the section is looked at.
 *
 * The written value is the accessible name from the first paint — a screen
 * reader should hear the number, not whatever it happened to be part way
 * through — and with reduced motion the count does not happen at all.
 */
function Counter({ value, active, delay }: { value: string; active: boolean; delay: number }) {
  const target = Number(value.replace(/,/g, ""));
  const [current, setCurrent] = useState(0);

  useEffect(() => {
    if (!active) return;
    if (!Number.isFinite(target)) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setCurrent(target);
      return;
    }

    let frame = 0;
    const duration = 1450;
    const startsAt = performance.now() + delay;
    const step = (now: number) => {
      if (now < startsAt) {
        frame = requestAnimationFrame(step);
        return;
      }
      const progress = Math.min(1, (now - startsAt) / duration);
      setCurrent(Math.round(target * (1 - Math.pow(1 - progress, 3))));
      if (progress < 1) frame = requestAnimationFrame(step);
    };
    frame = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame);
  }, [active, delay, target]);

  if (!Number.isFinite(target)) return <span>{value}</span>;
  return (
    <span aria-label={value}>
      <span aria-hidden="true">{FORMAT.format(current)}</span>
    </span>
  );
}

export function ImpactStats(props: ImpactStatsProps) {
  const p = { ...DEFAULTS, ...props };
  const stats = p.stats.length ? p.stats : DEFAULTS.stats;

  const sectionRef = useRef<HTMLElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const section = sectionRef.current;
    if (!section) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setVisible(true);
        observer.disconnect();
      },
      { threshold: 0.22 },
    );
    observer.observe(section);
    return () => observer.disconnect();
  }, []);

  return (
    <section
      ref={sectionRef}
      data-block="ImpactStats"
      data-tone={p.tone}
      data-visible={visible}
      aria-labelledby="impact-stats-title"
    >
      <style dangerouslySetInnerHTML={{ __html: STYLES }} />

      <header>
        <h2 id="impact-stats-title" className="is-statement">
          <span>{p.lead}</span> {p.statement}
        </h2>
      </header>

      <div className="is-stats">
        {stats.map((stat, index) => (
          <article key={stat.label} className="is-stat" style={{ ["--is-index" as string]: index }}>
            <div className="is-upper">
              <p className="is-category">{stat.category}</p>
              <p className="is-number">
                <Counter value={stat.value} active={visible} delay={160 + index * 90} />
              </p>
            </div>
            <p className="is-label">{stat.label}</p>
          </article>
        ))}
      </div>
    </section>
  );
}

/* A named export as well as the default, so a project that takes this file can
   import it either way. */
export default ImpactStats;