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

Clasificación que se reordena

Barras con borde grueso que cambian de lugar con animación FLIP al actualizar los puntajes. Neobrutalista.

Ventas por región

  1. 1Norte82
  2. 2Occidente74
  3. 3Centro69
  4. 4Bajío58
  5. 5Sureste47

Código completo

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

datos-ranking.tsx89 líneas
Descargar
"use client";

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

/** Clasificación con barras que se reordenan: al actualizar los puntajes las filas cambian de lugar con animación FLIP y las barras crecen. Estilo neobrutalista. Datos de muestra. React + TypeScript, sin dependencias. */
export type DatosRankingItem = { id: string; name: string; score: number };

export type DatosRankingProps = {
  items?: DatosRankingItem[];
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcdrk-root { box-sizing: border-box; width: 100%; max-width: 500px; padding: 20px; border: 4px solid #111; background: #fff4b8; color: #111; font: 800 15px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; box-shadow: 8px 8px 0 #111; }
.gcdrk-root *, .gcdrk-root *::before, .gcdrk-root *::after { box-sizing: border-box; }
.gcdrk-root button { font: inherit; }
.gcdrk-root :focus-visible { outline: 4px solid #2f5bff; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .gcdrk-root * { transition-duration: .01ms !important; } }
.gcdrk-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.gcdrk-title { margin: 0; font-size: 24px; letter-spacing: -.03em; text-transform: uppercase; }
.gcdrk-btn { min-height: 40px; padding: 0 16px; border: 3px solid #111; background: #fff; font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 #111; transition: transform .1s, box-shadow .1s; }
.gcdrk-btn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 #111; }
.gcdrk-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.gcdrk-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 48px; align-items: center; gap: 12px; }
.gcdrk-pos { display: grid; place-items: center; width: 34px; height: 34px; border: 3px solid #111; background: #111; color: #fff; font-variant-numeric: tabular-nums; }
.gcdrk-row:nth-child(1) .gcdrk-pos { background: #ffd23f; color: #111; }
.gcdrk-bar { position: relative; height: 38px; border: 3px solid #111; background: #fff; overflow: hidden; }
.gcdrk-fill { position: absolute; inset: 0 auto 0 0; background: var(--c); border-right: 3px solid #111; transition: width .8s cubic-bezier(.16, 1, .3, 1); }
.gcdrk-name { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); text-transform: uppercase; font-size: 13px; letter-spacing: .02em; }
.gcdrk-score { text-align: right; font-variant-numeric: tabular-nums; }`;

const COLORS = ["#ff90e8", "#7cf5c3", "#8bc8ff", "#ff9a8a", "#c8a4ff"];
const DEFAULT: DatosRankingItem[] = [
  { id: "a", name: "Norte", score: 82 },
  { id: "b", name: "Occidente", score: 74 },
  { id: "c", name: "Centro", score: 69 },
  { id: "d", name: "Bajío", score: 58 },
  { id: "e", name: "Sureste", score: 47 },
];

export function DatosRanking({ items = DEFAULT, className = "", style }: DatosRankingProps) {
  const [data, setData] = useState(items);
  const nodes = useRef<Record<string, HTMLLIElement | null>>({});
  const prev = useRef<Record<string, number>>({});
  const sorted = [...data].sort((a, b) => b.score - a.score);

  useLayoutEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    for (const item of sorted) {
      const el = nodes.current[item.id];
      if (!el) continue;
      const top = el.getBoundingClientRect().top;
      const before = prev.current[item.id];
      if (before !== undefined && before !== top && !reduced) el.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: 600, easing: "cubic-bezier(.16, 1, .3, 1)" });
      prev.current[item.id] = top;
    }
  });

  function shuffle() {
    setData((list) => list.map((item) => ({ ...item, score: Math.max(10, Math.min(99, item.score + Math.round((Math.random() - 0.45) * 36))) })));
  }
  const max = Math.max(...sorted.map((item) => item.score), 1);

  return (
    <section className={`gcdrk-root ${className}`} style={style} aria-label="Clasificación de regiones">
      <style>{CSS}</style>
      <div className="gcdrk-head">
        <h2 className="gcdrk-title">Ventas por región</h2>
        <button className="gcdrk-btn" type="button" onClick={shuffle}>
          Actualizar
        </button>
      </div>
      <ol className="gcdrk-list" aria-live="polite">
        {sorted.map((item, index) => (
          <li key={item.id} ref={(node) => { nodes.current[item.id] = node; }} className="gcdrk-row">
            <span className="gcdrk-pos">{index + 1}</span>
            <span className="gcdrk-bar">
              <span className="gcdrk-fill" style={{ width: `${(item.score / max) * 100}%`, "--c": COLORS[items.findIndex((i) => i.id === item.id) % COLORS.length] } as CSSProperties} />
              <span className="gcdrk-name">{item.name}</span>
            </span>
            <span className="gcdrk-score">{item.score}</span>
          </li>
        ))}
      </ol>
    </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/datos-ranking

Ejemplo de uso de Clasificación que se reordena

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

ejemplo.tsx5 líneas
import { DatosRanking } from "./datos-ranking";

export default function Ejemplo() {
  return <DatosRanking />;
}

Propiedades de DatosRanking

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

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

Accesibilidad y rendimiento

El archivo tiene 89 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Clasificación que se reordena por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)