Ir al contenido
Contenido y datos

Chart

Gráficos sobre Recharts con los tokens del sistema: paleta de cinco series validada para daltonismo, tooltip y leyenda propios, y animación que respeta prefers-reduced-motion.

import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, … } from "sebs7n-ui/chart"

Ejemplos

Área: una serie

Con una sola serie no hay leyenda: el título ya dice qué se está mirando. El relleno es la misma tinta al 10 %.

import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "sebs7n-ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent, useChartMotion, type ChartConfig } from "sebs7n-ui/chart"

function Area1() {
  const motion = useChartMotion()
  const data = [186, 305, 237, 273, 209, 314].map((facturado, i) => ({ mes: MESES[i], facturado: facturado * 1000 }))
  const config = { facturado: { label: "Facturado" } } satisfies ChartConfig
  return (
    <Card className="w-full max-w-lg" size="sm">
      <CardHeader>
        <CardTitle>Facturado por mes</CardTitle>
        <CardDescription>Primer semestre, en pesos.</CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer className="aspect-[2/1]" config={config}>
          <AreaChart data={data} margin={{ left: 4, right: 4, top: 8 }} responsive>
            <CartesianGrid vertical={false} />
            <XAxis axisLine={false} dataKey="mes" tickLine={false} tickMargin={8} />
            <ChartTooltip content={<ChartTooltipContent formatter={pesos} indicator="line" />} cursor />
            <Area
              dataKey="facturado"
              fill="var(--color-facturado)"
              fillOpacity={0.1}
              stroke="var(--color-facturado)"
              strokeWidth={2}
              type="monotone"
              {...motion}
            />
          </AreaChart>
        </ChartContainer>
      </CardContent>
    </Card>
  )
}

Barras: dos series

Con dos o más series la leyenda va siempre. Las barras miden 24px como máximo y se redondean solo en la punta.

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, useChartMotion, type ChartConfig } from "sebs7n-ui/chart"

function Barras() {
  const motion = useChartMotion()
  const data = [
    { mes: "Ene", facturado: 186, cobrado: 150 },
    { mes: "Feb", facturado: 305, cobrado: 280 },
    { mes: "Mar", facturado: 237, cobrado: 190 },
    { mes: "Abr", facturado: 273, cobrado: 260 },
    { mes: "May", facturado: 209, cobrado: 205 },
    { mes: "Jun", facturado: 314, cobrado: 240 },
  ]
  const config = { facturado: { label: "Facturado" }, cobrado: { label: "Cobrado" } } satisfies ChartConfig
  return (
    <ChartContainer className="aspect-[2/1] max-w-lg" config={config}>
      <BarChart barGap={2} data={data} margin={{ left: 4, right: 4, top: 8 }} responsive>
        <CartesianGrid vertical={false} />
        <XAxis axisLine={false} dataKey="mes" tickLine={false} tickMargin={8} />
        <YAxis axisLine={false} tickLine={false} width={32} />
        <ChartTooltip content={<ChartTooltipContent />} cursor />
        <ChartLegend content={<ChartLegendContent />} />
        <Bar dataKey="facturado" fill="var(--color-facturado)" maxBarSize={24} radius={[4, 4, 0, 0]} {...motion} />
        <Bar dataKey="cobrado" fill="var(--color-cobrado)" maxBarSize={24} radius={[4, 4, 0, 0]} {...motion} />
      </BarChart>
    </ChartContainer>
  )
}

Donut con el total en el centro

Un hueco de 2px del color de la superficie separa los sectores, y cada uno lleva las puntas apenas redondeadas. El número grande va en tinta, no en el color de un sector.

import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, useChartMotion, type ChartConfig } from "sebs7n-ui/chart"
import { Label, Pie, PieChart } from "recharts"

function Donut() {
  const motion = useChartMotion()
  const data = [
    { canal: "web", monto: 412 },
    { canal: "presencial", monto: 268 },
    { canal: "telefono", monto: 121 },
    { canal: "otros", monto: 44 },
  ]
  const total = data.reduce((suma, fila) => suma + fila.monto, 0)
  const config = {
    web: { label: "Web" },
    presencial: { label: "Presencial" },
    telefono: { label: "Teléfono" },
    otros: { label: "Otros" },
  } satisfies ChartConfig
  // `aspect-square` solo: con `max-h` el alto se frenaba pero el ancho seguía al contenedor y el
  // donut se salía de la card. El tamaño lo fija `max-w-72`, y el alto lo sigue.
  return (
    <ChartContainer className="mx-auto aspect-square w-full max-w-72" config={config}>
      <PieChart responsive>
        <ChartTooltip content={<ChartTooltipContent nameKey="canal" />} />
        <ChartLegend content={<ChartLegendContent nameKey="canal" />} />
        <Pie
          data={data.map((fila) => ({ ...fila, fill: `var(--color-${fila.canal})` }))}
          dataKey="monto"
          cornerRadius={4}
          innerRadius="62%"
          nameKey="canal"
          paddingAngle={2}
          stroke="var(--sf-background-100)"
          strokeWidth={2}
          {...motion}
        >
          <Label
            content={({ viewBox }) => {
              if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
              return (
                <text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
                  <tspan className="fill-gray-1000 text-title-2 tabular-nums" x={viewBox.cx} y={viewBox.cy}>
                    {total}
                  </tspan>
                  <tspan className="fill-label-secondary text-footnote" x={viewBox.cx} y={(viewBox.cy ?? 0) + 22}>
                    ventas
                  </tspan>
                </text>
              )
            }}
          />
        </Pie>
      </PieChart>
    </ChartContainer>
  )
}

Cambio de datos

Al cambiar el rango, la línea se transforma en la nueva en vez de redibujarse: Recharts interpola entre los dos datasets. La tabla es la misma información, para quien no ve el gráfico.

import { CartesianGrid, Line, LineChart, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, useChartMotion, type ChartConfig } from "sebs7n-ui/chart"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "sebs7n-ui/table"
import { ToggleGroup, ToggleGroupItem } from "sebs7n-ui/toggle-group"
import { useState } from "react"

function CambioDeDatos() {
  const motion = useChartMotion()
  const [rango, setRango] = useState<keyof typeof RANGOS>("7d")
  const [vista, setVista] = useState<"grafico" | "tabla">("grafico")
  const data = RANGOS[rango].map((altas, i) => ({ dia: `D${i + 1}`, altas }))
  const config = { altas: { label: "Altas" } } satisfies ChartConfig
  return (
    <div className="flex w-full max-w-lg flex-col gap-4">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <ToggleGroup aria-label="Rango" onValueChange={(v) => v[0] && setRango(v[0] as keyof typeof RANGOS)} value={[rango]}>
          {(Object.keys(RANGOS) as (keyof typeof RANGOS)[]).map((clave) => (
            <ToggleGroupItem key={clave} value={clave}>
              {clave}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <ToggleGroup aria-label="Vista" onValueChange={(v) => v[0] && setVista(v[0] as typeof vista)} value={[vista]}>
          <ToggleGroupItem value="grafico">Gráfico</ToggleGroupItem>
          <ToggleGroupItem value="tabla">Tabla</ToggleGroupItem>
        </ToggleGroup>
      </div>
      {vista === "grafico" ? (
        <ChartContainer className="aspect-[2/1]" config={config}>
          <LineChart data={data} margin={{ left: 4, right: 4, top: 8 }} responsive>
            <CartesianGrid vertical={false} />
            <XAxis axisLine={false} dataKey="dia" tickLine={false} tickMargin={8} />
            <ChartTooltip content={<ChartTooltipContent hideIndicator />} cursor />
            <Line
              activeDot={{ r: 5, strokeWidth: 2 }}
              dataKey="altas"
              dot={false}
              stroke="var(--color-altas)"
              strokeWidth={2}
              type="monotone"
              {...motion}
            />
          </LineChart>
        </ChartContainer>
      ) : (
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Día</TableHead>
              <TableHead className="text-right">Altas</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {data.map((fila) => (
              <TableRow key={fila.dia}>
                <TableCell>{fila.dia}</TableCell>
                <TableCell className="text-right tabular-nums">{fila.altas}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      )}
    </div>
  )
}

Sparkline en un KPI

Sin ejes, sin grilla, sin tooltip: doce puntos que dan la forma de la tendencia debajo del número. El color es el de la serie única.

import { Card, CardContent } from "sebs7n-ui/card"
import { ChartContainer, useChartMotion } from "sebs7n-ui/chart"
import { Line, LineChart } from "recharts"
import { Stat } from "sebs7n-ui/stat"

function Sparkline() {
  const motion = useChartMotion()
  const kpis = [
    { label: "Facturado", value: "$ 1.284.000", delta: "+12,4 %", trend: "up", serie: [4, 6, 5, 8, 7, 9, 8, 11, 10, 12, 13, 15] },
    { label: "Tickets abiertos", value: "37", delta: "+8", trend: "down", serie: [12, 10, 14, 13, 18, 17, 21, 20, 26, 29, 33, 37] },
    { label: "Tiempo de respuesta", value: "1,8 h", delta: "−0,4 h", trend: "up", serie: [3.1, 2.9, 3, 2.6, 2.4, 2.5, 2.2, 2.1, 2, 1.9, 1.9, 1.8] },
  ] as const
  return (
    <div className="grid w-full gap-4 sm:grid-cols-3">
      {kpis.map((kpi) => (
        <Card key={kpi.label} size="sm">
          <CardContent className="flex flex-col gap-3">
            <Stat delta={kpi.delta} hint="vs. mes anterior" label={kpi.label} trend={kpi.trend} value={kpi.value} />
            <ChartContainer aria-hidden="true" className="aspect-auto h-10" config={{ v: { label: kpi.label } }}>
              <LineChart data={kpi.serie.map((v, i) => ({ i, v }))} margin={{ top: 2, bottom: 2, left: 2, right: 2 }} responsive>
                <Line dataKey="v" dot={false} stroke="var(--color-v)" strokeWidth={2} type="monotone" {...motion} />
              </LineChart>
            </ChartContainer>
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

Props

Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».

ChartContainer

Hereda las props de <div>.

PropTipoPor defectoDescripción
config*ChartConfig—Las series, por dataKey: etiqueta, color opcional e ícono opcional para la leyenda. El orden de las claves es el orden de la paleta.
styleCSSProperties—Se fusiona con las variables --color-<serie> que pone el contenedor.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ChartLegend

Sin props propias: pasa todo al primitivo.

ChartLegendContent

PropTipoPor defectoDescripción
hideIconbooleanfalseIgnora el icon de la config y muestra el punto de color.
nameKeystring—Igual que en el tooltip: la clave que identifica la serie.
payloadreadonly LegendPayload[]—Lo pone Recharts.
verticalAlign"top" | "bottom" | "middle""bottom"Lo pone Recharts desde <Legend verticalAlign>: solo cambia de qué lado va el margen.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ChartTooltip

Sin props propias: pasa todo al primitivo.

ChartTooltipContent

PropTipoPor defectoDescripción
activeboolean—Lo pone Recharts: si el puntero está sobre un punto.
formatter(value: number | string, name: string, entry: TooltipPayloadEntry) => React.ReactNode—Cómo se escribe cada valor: (value) => $ ${value}``.
hideIndicatorbooleanfalseOculta el testigo de color.
hideLabelbooleanfalseOculta el título del tooltip (el valor del eje X).
indicator"line" | "none" | "dot""dot"dot (un punto) · line (una barra vertical) · none.
labelReact.ReactNode—Lo pone Recharts: el valor del eje X bajo el puntero.
labelFormatter(label: React.ReactNode, payload: readonly TooltipPayloadEntry[]) => React.ReactNode—Cómo se escribe el título.
labelKeystring—Clave del payload que da el título. Por defecto, el label del eje.
nameKeystring—Clave del payload que identifica la serie, para un Pie donde cada sector es una fila.
payloadreadonly TooltipPayloadEntry[]—Lo pone Recharts: las series bajo el puntero.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

Teclado

Tab
Entra al gráfico (tabIndex=0 de Recharts, con el anillo de foco del sistema).
← →
Recorre los puntos y muestra el tooltip de cada uno (accessibilityLayer de Recharts, activo por defecto).

Accesibilidad

  • La paleta es de cinco series en orden fijo (chart-1…chart-5, también como bg-chart-1 para un testigo propio): pares adyacentes con ΔE ≥ 8 en deutan, protan y tritan, y 3:1 contra la superficie en los dos temas. Es lo que pasa el validador; una sexta serie se agrupa en «Otros» o se parte en otro gráfico.
  • El texto nunca lleva el color de la serie. Tooltip y leyenda escriben el nombre en gray-900 y el valor en gray-1000; el color va en el testigo de al lado.
  • Con dos series o más la leyenda es obligatoria: la identidad no puede depender del color solo. Con una, el título ya la nombra.
  • useChartMotion() devuelve isAnimationActive y animationDuration según prefers-reduced-motion: Recharts no lo mira solo, hay que pasárselo a cada serie.
  • Un gráfico no es la única forma de leer un dato: ofrecé la tabla (ver el ejemplo *Cambio de datos*). Un sparkline decorativo va con aria-hidden.

Reglas de uso

  • `recharts` es un peer opcional: npm install recharts en la app que lo usa. Ningún otro subpath del paquete lo importa, y Chart no está en el barrel: solo sebs7n-ui/chart.
  • El gráfico lleva responsive (<AreaChart responsive>): mide con CSS y llena el ChartContainer, que es el que decide el tamaño (aspect-video por defecto).
  • Cada serie pinta con su variable: fill="var(--color-<dataKey>)". La pone ChartContainer a partir de la config, en el orden en que está escrita, y no se reasigna si una serie se filtra.
  • Marcas finas: barras de 24px como máximo (maxBarSize) redondeadas en la punta (radius={[4, 4, 0, 0]}), líneas de 2px, relleno de área al 10 %. Grilla horizontal sola (vertical={false}), sin línea de eje.
  • Un solo eje Y. Dos medidas de escala distinta son dos gráficos, no un eje a cada lado.
  • Una serie y una pregunta por gráfico. Un dashboard es varios gráficos chicos, no uno con todo.

Relacionados