Indicadores clave Material 3
Tarjetas tonales con cifra que cuenta al entrar en pantalla, variación con color semántico y comparació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";
/** Fila de indicadores clave en Material 3: cifra que cuenta al entrar en pantalla, variación con flecha y color semántico, y comparación con el periodo anterior. Datos de muestra. React + TypeScript, sin dependencias. */
export type DatosMetricasItem = { label: string; value: number; prefix?: string; suffix?: string; delta: number; note?: string; decimals?: number };
export type DatosMetricasProps = {
items?: DatosMetricasItem[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcdmt-root { box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; width: 100%; max-width: 760px; color: #1d1b20; font: 400 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .25px; }
.gcdmt-root *, .gcdmt-root *::before, .gcdmt-root *::after { box-sizing: border-box; }
.gcdmt-card { display: grid; gap: 8px; padding: 18px 18px 16px; border-radius: 20px; background: var(--bg); transition: transform .3s cubic-bezier(.2, 0, 0, 1); }
.gcdmt-card:hover { transform: translateY(-3px); }
.gcdmt-label { color: #49454f; font-weight: 500; letter-spacing: .1px; }
.gcdmt-value { font: 400 40px/44px Roboto, "Google Sans", ui-sans-serif, sans-serif; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.gcdmt-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.gcdmt-delta { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px 0 6px; border-radius: 8px; font-weight: 500; font-size: 12px; letter-spacing: .5px; font-variant-numeric: tabular-nums; }
.gcdmt-delta[data-up="true"] { background: #c4eed0; color: #0a3818; }
.gcdmt-delta[data-up="false"] { background: #ffd8e4; color: #5c0e29; }
.gcdmt-note { color: #625b71; font-size: 12px; }
@media (prefers-reduced-motion: reduce) { .gcdmt-card { transition: none; } }`;
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: DatosMetricasItem[] = [
{ label: "Pedidos del mes", value: 1284, delta: 12.4, note: "vs. mes anterior" },
{ label: "Ingresos", value: 48.6, prefix: "$", suffix: "k", delta: 8.1, decimals: 1, note: "vs. mes anterior" },
{ label: "Tiempo de entrega", value: 2.4, suffix: " días", delta: -6.2, decimals: 1, note: "menos es mejor" },
];
const BGS = ["#e8def8", "#fff0d6", "#d9f1e6", "#ffe0ea"];
function Count({ to, decimals = 0, run }: { to: number; decimals?: 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) / 1100);
setV(to * (1 - Math.pow(1 - p, 3)));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [run, to, reduced]);
return <>{v.toLocaleString("es-MX", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}</>;
}
export function DatosMetricas({ items = DEFAULT, className = "", style }: DatosMetricasProps) {
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 (
<div ref={ref} className={`gcdmt-root ${className}`} style={style}>
<style>{CSS}</style>
{items.map((item, i) => {
const up = item.delta >= 0;
const good = item.note?.includes("menos es mejor") ? !up : up;
return (
<article key={item.label} className="gcdmt-card" style={{ "--bg": BGS[i % BGS.length] } as CSSProperties}>
<span className="gcdmt-label">{item.label}</span>
<span className="gcdmt-value" aria-label={`${item.prefix ?? ""}${item.value}${item.suffix ?? ""}`}>
<span aria-hidden="true">
{item.prefix}
<Count to={item.value} decimals={item.decimals} run={run} />
{item.suffix}
</span>
</span>
<span className="gcdmt-row">
<span className="gcdmt-delta" data-up={good}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{up ? <path d="M7 17 17 7M8 7h9v9" /> : <path d="M7 7l10 10M17 8v9H8" />}</svg>
{Math.abs(item.delta)}%
</span>
<span className="gcdmt-note">{item.note}</span>
</span>
</article>
);
})}
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-metricasEjemplo de uso de Indicadores clave Material 3
Después de copiar datos-metricas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { DatosMetricas } from "./datos-metricas";
export default function Ejemplo() {
return <DatosMetricas />;
}Propiedades de DatosMetricas
DatosMetricas acepta 3 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | DatosMetricasItem[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 107 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Indicadores clave Material 3 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".