Chart
Envoltorio delgado sobre Recharts — tú sigues importando BarChart, Line, CartesianGrid directo de recharts; Kenza solo aporta el contenedor, el tooltip, la leyenda y tres colores curados en vez de la paleta arcoíris por defecto de la librería.
Playground
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="plantillas" fill={chartColors[0]} radius={[4, 4, 0, 0]} maxBarSize={24} />
<Bar dataKey="licencias" fill={chartColors[1]} radius={[4, 4, 0, 0]} maxBarSize={24} />
</BarChart>Tres colores, no ocho
chartColors son los únicos tres tonos que le quedan libres a la rueda de color una vez que la marca (dorado) y los cuatro semánticos ya tomaron su arco — ver Colores para la cuenta completa. Una cuarta serie no genera un cuarto hue: cae a muted-foreground, gris neutro. Los tres sí pasaron el checklist real de accesibilidad para daltonismo (separación en espacio OKLab bajo simulación protanopia/deuteranopia), no fueron elegidos a ojo.
Sin la capa de temas
En shadcn, ChartConfig acepta color o un objeto theme: { light, dark }, y el contenedor inyecta un <style> por instancia para resolver cuál te toca. Aquí color es un string literal, sin la rama theme ni el <style> generado — Kenza no tiene modo claro que resolver ("la marca vive de noche"), así que esa capa completa no tenía nada que hacer.
Tooltip y leyenda
ChartTooltipContent es el mismo hairline + fondo sólido que el resto de la librería, no la burbuja blanca por defecto de Recharts — y los números van en tabular-nums para que una columna de cifras alinee. El indicador de serie es un cuadrado de 2px de radio, nunca un círculo — mismo criterio que Calendar y Badge. indicator="line" lo cambia por una barra vertical — mejor lectura cuando el trazo real también es una línea, como en el ejemplo de arriba.
Más variantes
El playground cubre bar/line/area con dos series — aquí el resto de las formas que de verdad aparecen en un dashboard de plantillas: apiladas, horizontal, donut, un gauge de una sola cifra y un tooltip con formato propio.
Barras apiladas
Mismo stackId en las dos barras. El separador entre segmentos es un stroke del color de fondo, no un borde — el gap de 2px que pide un apilado para que dos series contiguas no se lean como una sola pieza.
<Bar dataKey="plantillas" stackId="revenue" fill={chartColors[0]} stroke="hsl(var(--kenza-color-bg))" strokeWidth={2} />
<Bar dataKey="licencias" stackId="revenue" fill={chartColors[1]} stroke="hsl(var(--kenza-color-bg))" strokeWidth={2} radius={[4, 4, 0, 0]} />
{/* mismo stackId, mismo stroke en el color de fondo — ese trazo es el
gap de 2px entre segmentos, no un borde decorativo. Solo el segmento
de arriba lleva radius: es el único que toca un extremo real */}Barras horizontales
layout="vertical" en BarChart — mejor lectura que la versión vertical cuando lo que importa es el ranking, no la evolución en el tiempo. Una sola serie, así que sin leyenda: el título ya dice qué se mide.
<BarChart data={data} layout="vertical" margin={{ left: 8 }}>
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={64} />
<Bar dataKey="ventas" fill={chartColors[0]} radius={[0, 4, 4, 0]} maxBarSize={20} />
</BarChart>
{/* una sola serie: sin ChartLegend — el título de la sección ya dice
qué se mide, un swatch solo repetiría el dato */}Donut
Pie con innerRadius, no un pie completo — deja espacio para un dato real en el centro. Aquí muestra la categoría dominante, no la suma (que en un donut de porcentajes siempre da 100%, un número que no dice nada).
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={64} outerRadius={92} paddingAngle={2} cornerRadius={4}>
{data.map((entry) => <Cell key={entry.name} fill={config[entry.name].color} />)}
<Label content={({ viewBox }) => (/* texto centrado con la categoría dominante */ null)} />
</Pie>
<ChartLegend content={<ChartLegendContent />} />
</PieChart>Gauge radial
Una sola cifra contra una meta — RadialBarChart con domain=[0, 100] fijo en un PolarAngleAxis oculto. Sin ese domain, Recharts autoescala el eje al único valor del dataset y el arco se ve lleno siempre, sin importar el número real — un gauge que miente.
<RadialBarChart data={[{ name: "meta", value: 74 }]} startAngle={90} endAngle={-270} innerRadius={72} outerRadius={100}>
{/* domain fijo: sin esto, el eje se autoescala al único valor del
dataset y el arco siempre se ve al 100% sin importar el número */}
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} axisLine={false} />
<RadialBar dataKey="value" fill={chartColors[0]} background={{ fill: "hsl(var(--kenza-color-muted))" }} cornerRadius={8} />
</RadialBarChart>Formato de tooltip personalizado
formatter en ChartTooltipContent — mismo prop y firma que el Tooltip nativo de Recharts, para casos como moneda o unidades que toLocaleString solo no resuelve.
const currency = new Intl.NumberFormat("es-MX", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
<ChartTooltip content={<ChartTooltipContent formatter={(value) => currency.format(Number(value))} />} />Código
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
// tres colores, no ocho — el resto de la rueda de color ya está ocupada
// por los tonos semánticos (success/danger/warning/info). Ver
// packages/tokens/src/tokens.css para la nota completa. Una cuarta serie
// (o "otros") cae a --kenza-color-muted-fg, no a un hue nuevo.
export const chartColors = ["hsl(var(--kenza-chart-1))", "hsl(var(--kenza-chart-2))", "hsl(var(--kenza-chart-3))"] as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
/** literal, no una variable CSS por tema — Kenza no tiene modo claro
* que resolver, así que no hace falta esa capa de indirección */
color?: string;
}
>;
type ChartContextValue = { config: ChartConfig };
const ChartContext = React.createContext<ChartContextValue | null>(null);
function useChartContext(component: string) {
const ctx = React.useContext(ChartContext);
if (!ctx) throw new Error(`${component} debe usarse dentro de ChartContainer`);
return ctx;
}
export interface ChartContainerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
export const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ config, className, children, ...props }, ref) => (
<ChartContext.Provider value={{ config }}>
<div
ref={ref}
className={cn(
"aspect-video w-full text-xs",
// el grid y los ejes de recharts vienen con sus propios grises —
// se homologan a los tokens de la librería en vez de convivir dos
// paletas de gris distintas en la misma pantalla
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground",
"[&_.recharts-cartesian-grid_line]:stroke-border",
"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border",
"[&_.recharts-layer]:outline-none",
"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/50",
"[&_.recharts-sector]:outline-none",
"[&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
),
);
ChartContainer.displayName = "ChartContainer";
export const ChartTooltip = RechartsPrimitive.Tooltip;
export interface ChartTooltipContentProps
// recharts inyecta active/payload/coordinate al clonar este elemento —
// aquí son opcionales porque quien escribe <ChartTooltipContent /> en JSX
// nunca los pasa a mano
extends Partial<RechartsPrimitive.TooltipContentProps<RechartsPrimitive.TooltipValueType>> {
indicator?: "dot" | "line";
hideLabel?: boolean;
hideIndicator?: boolean;
className?: string;
}
export function ChartTooltipContent({
active,
payload,
label,
labelFormatter,
formatter,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
className,
}: ChartTooltipContentProps) {
const { config } = useChartContext("ChartTooltipContent");
if (!active || !payload?.length) return null;
return (
<div className={cn("min-w-[10rem] rounded-md border border-border bg-background p-2.5 text-xs shadow-none", className)}>
{!hideLabel && label !== undefined && (
<p className="mb-1.5 font-medium text-foreground">{labelFormatter ? labelFormatter(label, payload) : label}</p>
)}
<div className="flex flex-col gap-1">
{payload.map((item, i) => {
// "name" antes que "dataKey": en Bar/Line ambos suelen coincidir,
// pero en Pie cada porción comparte el mismo dataKey (el accessor
// del valor) y solo "name" identifica la categoría real
const key = String(item.name ?? item.dataKey ?? i);
const itemConfig = config[key];
const color = item.color ?? itemConfig?.color;
let valueNode: React.ReactNode = typeof item.value === "number" ? item.value.toLocaleString("es-MX") : item.value;
let nameNode: React.ReactNode = itemConfig?.label ?? item.name;
if (formatter && item.value !== undefined && item.dataKey !== undefined) {
const formatted = formatter(item.value, item.name, item, i, payload);
if (Array.isArray(formatted)) [valueNode, nameNode] = formatted;
else valueNode = formatted;
}
return (
<div key={key} className="flex w-full items-center gap-2">
{!hideIndicator &&
(indicator === "dot" ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: color }} aria-hidden="true" />
) : (
<span className="h-full w-0.5 shrink-0 self-stretch rounded-[1px]" style={{ backgroundColor: color }} aria-hidden="true" />
))}
<span className="flex-1 text-muted-foreground">{nameNode}</span>
{valueNode !== undefined && <span className="font-mono font-medium tabular-nums text-foreground">{valueNode}</span>}
</div>
);
})}
</div>
</div>
);
}
// Recharts ordena la leyenda alfabéticamente por defecto — "Licencias"
// antes que "Plantillas" aunque el bar de Plantillas se dibuje primero.
// itemSorter={null} preserva el orden real de las series.
export function ChartLegend(props: RechartsPrimitive.LegendProps) {
return <RechartsPrimitive.Legend itemSorter={null} {...props} />;
}
export interface ChartLegendContentProps {
payload?: ReadonlyArray<RechartsPrimitive.LegendPayload>;
hideIcon?: boolean;
className?: string;
}
export function ChartLegendContent({ payload, hideIcon = false, className }: ChartLegendContentProps) {
const { config } = useChartContext("ChartLegendContent");
if (!payload?.length) return null;
return (
<div className={cn("flex flex-wrap items-center justify-center gap-4 pt-3", className)}>
{payload.map((item) => {
const key = String(item.value ?? item.dataKey ?? "");
const itemConfig = config[key];
return (
<div key={String(item.value)} className="flex items-center gap-1.5">
{!hideIcon && <span className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} aria-hidden="true" />}
<span className="text-muted-foreground">{itemConfig?.label ?? item.value}</span>
</div>
);
})}
</div>
);
}
// tooltip/leyenda entran con el mismo fade corto que Calendar — sin esto,
// recharts las monta sin transición, un salto seco contra el resto de la
// librería
export const chartTransitionClass = cn("transition-opacity duration-150", EASE);Código real de packages/ui/src/chart.tsx. Los primitivos de gráfica en sí (BarChart, Bar, Line...) se importan directo de recharts — no vienen re-exportados de aquí, así que el archivo se queda corto.
Uso
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, chartColors } from "./components/ui/chart";
const config: ChartConfig = {
plantillas: { label: "Plantillas", color: chartColors[0] },
};
<ChartContainer config={config} className="h-[280px]">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="plantillas" fill={chartColors[0]} radius={[4, 4, 0, 0]} maxBarSize={24} />
</BarChart>
</ChartContainer>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| ChartContainer | config | ChartConfig | — |
| ChartTooltipContent | indicator | dot | line | dot |
| hideLabel / hideIndicator | boolean | false | |
| formatter | (value, name, item, index, payload) => ReactNode | — | |
| ChartLegendContent | hideIcon | boolean | false |
ChartTooltip y ChartLegend son Tooltip/Legend de Recharts reexportados tal cual — todas sus props documentadas ahí aplican igual aquí.