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
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-metricasEjemplo de uso de Cifras editoriales
Después de copiar seccion-metricas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
items? | SeccionMetricasItem[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".