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
- 182
- 274
- 369
- 458
- 547
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-rankingEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | DatosRankingItem[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".