← Componentes

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

Tipo
<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

ComponentePropTipoDefault
ChartContainerconfigChartConfig
ChartTooltipContentindicatordot | linedot
hideLabel / hideIndicatorbooleanfalse
formatter(value, name, item, index, payload) => ReactNode
ChartLegendContenthideIconbooleanfalse

ChartTooltip y ChartLegend son Tooltip/Legend de Recharts reexportados tal cual — todas sus props documentadas ahí aplican igual aquí.