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>.
| Prop | Tipo | Por defecto | Descripció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. |
style | CSSProperties | — | Se fusiona con las variables --color-<serie> que pone el contenedor. |
className | string | — | 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
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
hideIcon | boolean | false | Ignora el icon de la config y muestra el punto de color. |
nameKey | string | — | Igual que en el tooltip: la clave que identifica la serie. |
payload | readonly LegendPayload[] | — | Lo pone Recharts. |
verticalAlign | "top" | "bottom" | "middle" | "bottom" | Lo pone Recharts desde <Legend verticalAlign>: solo cambia de qué lado va el margen. |
className | string | — | 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
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
active | boolean | — | 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}``. |
hideIndicator | boolean | false | Oculta el testigo de color. |
hideLabel | boolean | false | Oculta el título del tooltip (el valor del eje X). |
indicator | "line" | "none" | "dot" | "dot" | dot (un punto) · line (una barra vertical) · none. |
label | React.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. |
labelKey | string | — | Clave del payload que da el título. Por defecto, el label del eje. |
nameKey | string | — | Clave del payload que identifica la serie, para un Pie donde cada sector es una fila. |
payload | readonly TooltipPayloadEntry[] | — | Lo pone Recharts: las series bajo el puntero. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
Teclado
- Tab
- Entra al gráfico (
tabIndex=0de Recharts, con el anillo de foco del sistema). - ← →
- Recorre los puntos y muestra el tooltip de cada uno (
accessibilityLayerde Recharts, activo por defecto).
Accesibilidad
- La paleta es de cinco series en orden fijo (
chart-1…chart-5, también comobg-chart-1para 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-900y el valor engray-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()devuelveisAnimationActiveyanimationDurationsegúnprefers-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 rechartsen la app que lo usa. Ningún otro subpath del paquete lo importa, yChartno está en el barrel: solosebs7n-ui/chart. - El gráfico lleva
responsive(<AreaChart responsive>): mide con CSS y llena elChartContainer, que es el que decide el tamaño (aspect-videopor defecto). - Cada serie pinta con su variable:
fill="var(--color-<dataKey>)". La poneChartContainera partir de laconfig, 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.