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

Gráfico de dona interactivo

Segmentos enfocables que se destacan junto a su leyenda; el centro muestra el valor del segmento activo.

Código completo

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

datos-dona.tsx81 líneas
Descargar
"use client";

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

/** Gráfico de dona minimalista con segmentos enfocables: al apuntar o enfocar un segmento se destaca, el centro muestra su valor y la leyenda lo resalta. React + TypeScript, sin dependencias. */
export type DatosDonaItem = { label: string; value: number; color?: string };

export type DatosDonaProps = {
  data?: DatosDonaItem[];
  title?: string;
  format?: (value: number) => string;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcddo-root { box-sizing: border-box; display: flex; align-items: center; gap: 32px; flex-wrap: wrap; width: 100%; max-width: 560px; padding: 24px; border: 1px solid #e7e5e0; border-radius: 20px; background: #fff; color: #1c1b19; font: 500 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcddo-root *, .gcddo-root *::before, .gcddo-root *::after { box-sizing: border-box; }
.gcddo-root :focus-visible { outline: 2px solid #1c1b19; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcddo-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcddo-wrap { position: relative; width: 200px; height: 200px; flex: none; }
.gcddo-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); overflow: visible; }
.gcddo-seg { fill: none; stroke-width: 26; cursor: pointer; transition: stroke-width .25s cubic-bezier(.16, 1, .3, 1), opacity .2s; animation: gcddo-draw 1s cubic-bezier(.16, 1, .3, 1) backwards; }
.gcddo-wrap[data-h="true"] .gcddo-seg { opacity: .35; }
.gcddo-wrap[data-h="true"] .gcddo-seg[data-on="true"] { opacity: 1; stroke-width: 32; }
.gcddo-center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
.gcddo-center b { display: block; font: 700 30px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.gcddo-center span { margin-top: 4px; font-size: 12px; color: #78756d; }
.gcddo-legend { flex: 1; min-width: 160px; margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; }
.gcddo-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: background .15s; }
.gcddo-item:hover, .gcddo-item[data-on="true"] { background: #f4f2ee; }
.gcddo-sw { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.gcddo-v { margin-left: auto; font-variant-numeric: tabular-nums; color: #78756d; }
@keyframes gcddo-draw { from { stroke-dasharray: 0 100; } }`;

const PALETTE = ["#2f5bff", "#ff7a45", "#18b37e", "#f2c230", "#9b5de5", "#8a8f98"];

export function DatosDona({ data = [{ label: "Servicios", value: 46 }, { label: "Suscripciones", value: 28 }, { label: "Licencias", value: 14 }, { label: "Otros", value: 12 }], title = "Ingresos por línea", format = (value) => `$${value}k`, className = "", style }: DatosDonaProps) {
  const [hot, setHot] = useState<number | null>(null);
  const total = data.reduce((sum, item) => sum + item.value, 0);
  const R = 70;
  const C = 2 * Math.PI * R;
  const segs = data.map((item, i) => ({
    ...item,
    color: item.color ?? PALETTE[i % PALETTE.length],
    len: (item.value / total) * C,
    off: (data.slice(0, i).reduce((sum, d) => sum + d.value, 0) / total) * C,
  }));
  const current = hot !== null ? segs[hot] : null;

  return (
    <figure className={`gcddo-root ${className}`} style={{ margin: 0, ...style }}>
      <style>{CSS}</style>
      <div className="gcddo-wrap" data-h={hot !== null}>
        <svg viewBox="0 0 200 200" role="img" aria-label={`${title}. Total ${format(total)}`}>
          {segs.map((seg, i) => (
            <circle key={seg.label} className="gcddo-seg" data-on={hot === i} cx="100" cy="100" r={R} stroke={seg.color} strokeDasharray={`${Math.max(0, seg.len - 3)} ${C}`} strokeDashoffset={-seg.off} tabIndex={0} aria-label={`${seg.label}: ${format(seg.value)}, ${Math.round((seg.value / total) * 100)}%`} onPointerEnter={() => setHot(i)} onPointerLeave={() => setHot(null)} onFocus={() => setHot(i)} onBlur={() => setHot(null)} style={{ animationDelay: `${i * 0.08}s` }} />
          ))}
        </svg>
        <div className="gcddo-center" aria-hidden="true">
          <b>{current ? `${Math.round((current.value / total) * 100)}%` : format(total)}</b>
          <span>{current ? current.label : title}</span>
        </div>
      </div>
      <figcaption style={{ display: "contents" }}>
        <ul className="gcddo-legend">
          {segs.map((seg, i) => (
            <li key={seg.label}>
              <button className="gcddo-item" type="button" data-on={hot === i} onPointerEnter={() => setHot(i)} onPointerLeave={() => setHot(null)} onFocus={() => setHot(i)} onBlur={() => setHot(null)}>
                <span className="gcddo-sw" style={{ background: seg.color }} />
                {seg.label}
                <span className="gcddo-v">{format(seg.value)}</span>
              </button>
            </li>
          ))}
        </ul>
      </figcaption>
    </figure>
  );
}

Ú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-dona

Ejemplo de uso de Gráfico de dona interactivo

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

ejemplo.tsx5 líneas
import { DatosDona } from "./datos-dona";

export default function Ejemplo() {
  return <DatosDona title="Ejemplo" />;
}

Propiedades de DatosDona

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

PropiedadTipoDescripción
data?DatosDonaItem[]Opcional.
title?stringOpcional.
format?(value: number) => stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 81 líneas (5 KB) y define 1 animación con @keyframes. Esto es lo que hace Gráfico de dona interactivo 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).
  • Declara roles ARIA para lectores de pantalla: img.
  • 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)