Ir al contenido
Evaluar mi proyecto
Componentes · SeccionesDisponible

Cifras editoriales

Números enormes en serif separados por filetes, que cuentan una sola vez al entrar en pantalla.

Cifras que nos importan porque son verificables.

años construyendo software
proyectos entregados
registros procesados en producción
Demostración interactiva

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

seccion-metricas.tsx97 líneas
Descargar
"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";

/** Sección de cifras en formato editorial: números enormes en serif separados por filetes finos, que cuentan una sola vez al entrar en pantalla. Cifras de muestra. React + TypeScript, sin dependencias. */
export type SeccionMetricasItem = { value: number; suffix?: string; prefix?: string; label: string };

export type SeccionMetricasProps = {
  title?: string;
  items?: SeccionMetricasItem[];
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcsmt-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(32px, 6cqi, 88px) clamp(18px, 5cqi, 72px); background: #14130f; color: #f1ede4; font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcsmt-root *, .gcsmt-root *::before, .gcsmt-root *::after { box-sizing: border-box; }
.gcsmt-title { max-width: 20ch; margin: 0 0 clamp(28px, 5cqi, 64px); font: 400 clamp(26px, 4.2cqi, 46px)/1.1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; color: #b9b3a3; text-wrap: balance; }
.gcsmt-grid { display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); border-top: 1px solid #3a372f; }
.gcsmt-cell { padding: 28px 24px 0 0; margin-right: 24px; border-right: 1px solid #2c2a24; }
.gcsmt-cell:last-child { border-right: 0; margin-right: 0; }
.gcsmt-num { display: block; font: 400 clamp(52px, 11cqi, 150px)/.92 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.05em; font-variant-numeric: tabular-nums; }
.gcsmt-num sup { margin-left: .04em; color: #ff8a5c; font-size: .5em; vertical-align: top; letter-spacing: 0; }
.gcsmt-label { display: block; margin-top: 14px; max-width: 22ch; color: #b9b3a3; }
@container (max-width: 720px) { .gcsmt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gcsmt-cell { padding-bottom: 28px; border-bottom: 1px solid #2c2a24; } .gcsmt-cell:nth-child(2n) { border-right: 0; margin-right: 0; } }`;

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function useReduced() {
  return useSyncExternalStore(
    (notify) => {
      const query = window.matchMedia(REDUCED_QUERY);
      query.addEventListener("change", notify);
      return () => query.removeEventListener("change", notify);
    },
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false,
  );
}


const DEFAULT: SeccionMetricasItem[] = [
  { value: 10, suffix: "+", label: "años construyendo software" },
  { value: 50, suffix: "+", label: "proyectos entregados" },
  { value: 1, suffix: "M+", label: "registros procesados en producción" },
];

function Count({ to, run }: { to: number; run: boolean }) {
  const reduced = useReduced();
  const [v0, setV] = useState(0);
  const v = reduced ? to : v0;
  useEffect(() => {
    if (!run || reduced) return;
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const p = Math.min(1, (now - t0) / 1600);
      setV(Math.round(to * (1 - Math.pow(1 - p, 4))));
      if (p < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [run, to, reduced]);
  return <>{v.toLocaleString("es-MX")}</>;
}

export function SeccionMetricas({ title = "Cifras que nos importan porque son verificables.", items = DEFAULT, className = "", style }: SeccionMetricasProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [run, setRun] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (setRun(true), io.disconnect()), { threshold: 0.3 });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return (
    <section className={`gcsmt-root ${className}`} style={style}>
      <style>{CSS}</style>
      <h2 className="gcsmt-title">{title}</h2>
      <div ref={ref} className="gcsmt-grid" style={{ "--n": items.length } as CSSProperties}>
        {items.map((item) => (
          <div key={item.label} className="gcsmt-cell">
            <span className="gcsmt-num" aria-label={`${item.prefix ?? ""}${item.value}${item.suffix ?? ""}`}>
              <span aria-hidden="true">
                {item.prefix}
                <Count to={item.value} run={run} />
                <sup>{item.suffix}</sup>
              </span>
            </span>
            <span className="gcsmt-label">{item.label}</span>
          </div>
        ))}
      </div>
    </section>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-metricas

Ejemplo de uso de Cifras editoriales

Después de copiar seccion-metricas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { SeccionMetricas } from "./seccion-metricas";

export default function Ejemplo() {
  return <SeccionMetricas title="Ejemplo" />;
}

Propiedades de SeccionMetricas

SeccionMetricas acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
title?stringOpcional.
items?SeccionMetricasItem[]Opcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 97 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Cifras editoriales por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)