Ir al contenido
Evaluar mi proyecto
Componentes · Datos y visualizaciónDisponible

Indicadores clave Material 3

Tarjetas tonales con cifra que cuenta al entrar en pantalla, variación con color semántico y comparación.

Pedidos del mes12.4%vs. mes anterior
Ingresos8.1%vs. mes anterior
Tiempo de entrega6.2%menos es mejor

Código completo

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

datos-metricas.tsx107 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

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

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)