1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
Métricas & Estadísticas

Stats Counter Band

Cifras destacadas que cuentan al hacer scroll, con el valor real en el HTML para lectores de pantalla.

statscounternumbersanimated
Vista Previa en Vivo

Trusted at scale

Developers using the kit
12,000+
Components & sections
250+
CDN uptime last 12 months
99.98%
Median time to first byte
<48ms

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { StatsCounterBand } from 'react-component-kit-ui/sections';

<StatsCounterBand stats={[
    { value: 12000, suffix: '+', label: 'Developers' },
    { value: 99.98, suffix: '%', label: 'Uptime', decimals: 2 },
  ]} variant="cards" />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/sections/StatsCounterBand.tsx + StatsCounterBand.css:

StatsCounterBand.tsx
'use client';

import { useEffect, useRef } from 'react';
import './StatsCounterBand.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

interface Stat { value: number; prefix?: string; suffix?: string; label: string; decimals?: number }

/**
 * StatsCounterBand — Full-width band of headline numbers that count up when
 * scrolled into view. Final values are in the HTML, so no-JS and screen
 * readers always get the real number; motion respects reduced-motion.
 *
 * @param {string} title - Optional heading (visually hidden when `hideTitle`).
 * @param {boolean} hideTitle - Visually hide the heading.
 * @param {Array<{value: number, prefix?: string, suffix?: string, label: string, decimals?: number}>} stats - Numbers.
 * @param {string} variant - Band style (divided|cards|plain).
 * @param {string} size - Vertical rhythm (sm|md|lg).
 * @param {string} className - Extra CSS class.
 */
export interface StatsCounterBandProps {
  title?: string;
  hideTitle?: boolean;
  stats?: Stat[];
  variant?: 'divided' | 'cards' | 'plain';
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

export function StatsCounterBand({
  title = 'Trusted at scale',
  hideTitle = false,
  stats = [
    { value: 12000, suffix: '+', label: 'Developers using the kit' },
    { value: 250, suffix: '+', label: 'Components & sections' },
    { value: 99.98, suffix: '%', label: 'CDN uptime last 12 months', decimals: 2 },
    { value: 48, prefix: '<', suffix: 'ms', label: 'Median time to first byte' },
  ],
  variant = 'divided',
  size = 'md',
  className = '',
}: StatsCounterBandProps) {
  const id = `scb-${Math.random().toString(36).slice(2, 8)}`;
  const fmt = (s: Stat) => `${s.prefix ?? ''}${s.value.toLocaleString('en-US', { minimumFractionDigits: s.decimals ?? 0, maximumFractionDigits: s.decimals ?? 0 })}${s.suffix ?? ''}`;

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      const els = document.querySelectorAll<HTMLElement>('.scb__value[data-count]');
      if (!reduce && 'IntersectionObserver' in window) {
        const io = new IntersectionObserver((entries) => {
          entries.forEach((entry) => {
            if (!entry.isIntersecting) return;
            const el = entry.target as HTMLElement;
            io.unobserve(el);
            const target = Number(el.dataset.count);
            const dec = Number(el.dataset.decimals);
            const pre = el.dataset.prefix || '';
            const suf = el.dataset.suffix || '';
            const final = el.textContent;
            el.setAttribute('aria-label', final || '');
            const start = performance.now();
            const dur = 1400;
            const tick = (now: number) => {
              const t = Math.min(1, (now - start) / dur);
              const eased = 1 - Math.pow(1 - t, 3);
              const v = target * eased;
              el.textContent = `${pre}${v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec })}${suf}`;
              if (t < 1) requestAnimationFrame(tick);
              else el.textContent = final;
            };
            requestAnimationFrame(tick);
          });
        }, { threshold: 0.4 });
        els.forEach((el) => io.observe(el));
      }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className="cq-stats-counter-band-cq" data-rk-72cbca="">
      <section className={`scb scb--${variant} scb--${size} ${className}`} aria-labelledby={id} data-rk-72cbca="">
        <div className="scb__inner" data-rk-72cbca="">
          <h2 id={id} className={cx(['scb__title', { 'scb__sr': hideTitle }])} data-rk-72cbca="">{title}</h2>
          <dl className="scb__grid" data-rk-72cbca="">
            {stats.map((s, __i: number) => (
              <div key={__i} className="scb__item" data-rk-72cbca="">
                <dt className="scb__label" data-rk-72cbca="">{s.label}</dt>
                <dd className="scb__value" data-count={s.value} data-decimals={s.decimals ?? 0} data-prefix={s.prefix ?? ''} data-suffix={s.suffix ?? ''} data-rk-72cbca="">{fmt(s)}</dd>
              </div>
            ))}
          </dl>
        </div>
      </section>
      </div>
    </>
  );
}

export default StatsCounterBand;
StatsCounterBand.css
/* StatsCounterBand — ported from astro-component-kit (sections/StatsCounterBand.astro) */

.cq-stats-counter-band-cq:where([data-rk-72cbca]) { container-type: inline-size; }
.scb:where([data-rk-72cbca]) {
  --scb-py: 4.5rem;
  padding: var(--scb-py) var(--sp-6, 1.5rem);
  background: linear-gradient(180deg, transparent, rgb(var(--ink-rgb, 255 255 255) / 0.02) 50%, transparent), var(--c-bg, #050505);
}
.scb--sm:where([data-rk-72cbca]) { --scb-py: 3rem; }
.scb--lg:where([data-rk-72cbca]) { --scb-py: 6.5rem; }
.scb__inner:where([data-rk-72cbca]) { max-width: 1180px; margin: 0 auto; }
.scb__title:where([data-rk-72cbca]) { margin: 0 0 2.5rem; text-align: center; font-size: 0.875rem; font-weight: 650; letter-spacing: 0.1em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.scb__sr:where([data-rk-72cbca]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; margin: 0; }
.scb__grid:where([data-rk-72cbca]) { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.scb__item:where([data-rk-72cbca]) { display: flex; flex-direction: column-reverse; align-items: center; gap: 0.5rem; padding: 1.5rem 1rem; text-align: center; }
.scb--divided:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) + .scb__item:where([data-rk-72cbca]) { border-left: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08); }
.scb--cards:where([data-rk-72cbca]) .scb__grid:where([data-rk-72cbca]) { gap: 1rem; }
.scb--cards:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) { border-radius: var(--r-lg, 20px); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07)); }
.scb__value:where([data-rk-72cbca]) {
  margin: 0;
  font-size: clamp(2.25rem, 5vw, 3.25rem);
  font-weight: 800;
  letter-spacing: -0.04em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  background: linear-gradient(180deg, var(--c-text-1, #fff), rgb(var(--ink-rgb, 255 255 255) / 0.65));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.scb__label:where([data-rk-72cbca]) { font-size: 0.9375rem; color: var(--c-text-2, #a3a3a3); max-width: 220px; }
@container (max-width: 640px) {
  .scb__grid:where([data-rk-72cbca]) { grid-template-columns: 1fr 1fr; }
  .scb--divided:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) + .scb__item:where([data-rk-72cbca]) { border-left: 0; }
  .scb--divided:where([data-rk-72cbca]) .scb__item:nth-child(even):where([data-rk-72cbca]) { border-left: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08); }
}