Agents (llms.txt)

Chart

Dashboard chart primitives — Recharts cartesian series plus SVG sparkline and donut

Area
Bars
Sparkline
Donut
72%
Stacked
import { Chart } from '@/components/chart';
import { Surface } from '@/components/surface';

const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];
const areaData = [12, 19, 14, 22, 18, 26, 24];
const barData = [8, 12, 6, 15, 10, 18, 14];

export default function ChartPreview() {
  return (
    <div className="flex w-full max-w-3xl flex-col gap-8 p-4">
      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Area</span>
        <Chart.Area data={areaData} xLabels={months} aspect={3.2} />
      </Surface>

      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Bars</span>
        <Chart.Bars data={barData} xLabels={months} aspect={3.2} />
      </Surface>

      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
        <Surface
          elevation="default"
          radius="lg"
          padding="md"
          className="flex flex-col items-center gap-3"
        >
          <span className="font-medium text-foreground text-sm">Sparkline</span>
          <Chart.Sparkline data={areaData} filled width={120} height={32} />
        </Surface>

        <Surface
          elevation="default"
          radius="lg"
          padding="md"
          className="flex flex-col items-center gap-3"
        >
          <span className="font-medium text-foreground text-sm">Donut</span>
          <Chart.Donut
            segments={[
              { label: 'Done', value: 72 },
              { label: 'Open', value: 28 },
            ]}
            size={96}
            thickness={0.16}
            centerLabel="72%"
          />
        </Surface>
      </div>

      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Stacked</span>
        <Chart.Stacked
          series={[
            { label: 'Income', data: [4, 6, 5, 8, 7, 9, 8] },
            { label: 'Expenses', data: [2, 3, 4, 3, 5, 4, 3] },
          ]}
          xLabels={months}
          aspect={3.2}
        />
      </Surface>
    </div>
  );
}

Dependencies

Source Code

'use client';

import { useId, useMemo, useState } from 'react';
import {
  Area,
  AreaChart,
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  XAxis,
  YAxis,
} from 'recharts';
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
} from '@/components/chart-container';
import { cn } from '@/lib/utils/classnames';

// ---------------------------------------------------------------------------
//  Shared utilities
// ---------------------------------------------------------------------------

const DEFAULT_FORMAT = (n: number) =>
  n.toLocaleString(undefined, { maximumFractionDigits: 2 });

const CHART_PALETTE = [
  'var(--chart-1)',
  'var(--chart-2)',
  'var(--chart-3)',
  'var(--chart-4)',
  'var(--chart-5)',
] as const;

/**
 * Generate "nice" axis ticks across a numeric range — snaps to 1 / 2 / 5 × 10ⁿ
 * steps so labels read 0 / 25 / 50 / 75 / 100 instead of 13.4 / 26.8…
 */
const niceTicks = (min: number, max: number, count = 4): number[] => {
  if (min === max) return [min];
  const range = max - min;
  const rough = range / (count - 1);
  const pow = 10 ** Math.floor(Math.log10(rough));
  const norm = rough / pow;
  const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * pow;
  const start = Math.floor(min / step) * step;
  const ticks: number[] = [];
  for (let v = start; v <= max + step * 0.0001; v += step) ticks.push(v);
  return ticks;
};

/** At most ~6 visible x labels, last index pinned. */
const buildTickIndexSet = (length: number, maxLabels = 6): Set<number> => {
  if (length === 0) return new Set();
  const stride = Math.max(1, Math.ceil(length / maxLabels));
  const set = new Set<number>();
  for (let i = 0; i < length; i += stride) set.add(i);
  const last = length - 1;
  if (last > 0 && last % stride !== 0) set.add(last);
  return set;
};

const chartMargins = (showAxis: boolean, hasXLabels: boolean) => ({
  top: 14,
  right: 12,
  bottom: hasXLabels ? 24 : 8,
  left: showAxis ? 44 : 12,
});

const computeYExtent = (
  values: number[],
  yTickCount: number,
  floorAtZero = false
) => {
  if (values.length === 0) return { min: 0, max: 1, ticks: [0, 1] as number[] };
  const lo = floorAtZero ? Math.min(0, ...values) : Math.min(...values);
  const hi = Math.max(...values);
  const ticks = niceTicks(lo, hi, yTickCount);
  return { min: ticks[0], max: ticks[ticks.length - 1], ticks };
};

const axisTickStyle = {
  fill: 'var(--color-foreground-secondary)',
  fontSize: 11,
};

interface ValuePillTooltipProps {
  active?: boolean;
  payload?: ReadonlyArray<{ value?: number }>;
  formatValue: (n: number) => string;
}

const ValuePillTooltip = ({
  active,
  payload,
  formatValue,
}: ValuePillTooltipProps) => {
  if (!active || !payload?.length) return null;
  const raw = payload[0]?.value;
  if (raw == null || typeof raw !== 'number') return null;
  return (
    <div className="font-(family-name:--font-ui) rounded-md border border-foreground/8 bg-background px-3 py-1 font-medium text-[12px] text-foreground tabular-nums">
      {formatValue(raw)}
    </div>
  );
};

// ---------------------------------------------------------------------------
//  Chart.Sparkline — inline trend hint for KPI tiles (SVG)
// ---------------------------------------------------------------------------

const buildSparklinePath = (
  values: number[],
  width: number,
  height: number
) => {
  if (values.length === 0) return { line: '', area: '' };
  const pad = 1;
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = max - min || 1;
  const innerW = width - pad * 2;
  const innerH = height - pad * 2;
  const points = values.map((v, i) => {
    const x = pad + (i / Math.max(values.length - 1, 1)) * innerW;
    const y = pad + innerH - ((v - min) / range) * innerH;
    return [x, y] as const;
  });
  const line = points
    .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`)
    .join(' ');
  const first = points[0];
  const last = points[points.length - 1];
  const area = `M${first[0].toFixed(2)},${(height - pad).toFixed(2)} L${points
    .map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`)
    .join(' L')} L${last[0].toFixed(2)},${(height - pad).toFixed(2)} Z`;
  return { line, area };
};

interface SparklineProps
  extends Omit<React.ComponentPropsWithoutRef<'svg'>, 'values'> {
  data: number[];
  width?: number;
  height?: number;
  stroke?: string;
  strokeWidth?: number;
  filled?: boolean;
}

const ChartSparkline = ({
  data,
  width = 96,
  height = 28,
  stroke,
  strokeWidth = 1.5,
  filled = false,
  className,
  ...rest
}: SparklineProps) => {
  const gradientId = useId();
  const { line, area } = useMemo(
    () => buildSparklinePath(data, width, height),
    [data, width, height]
  );
  return (
    <svg
      role="img"
      aria-label="trend"
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      className={cn('text-(--chart-1)', className)}
      {...rest}
    >
      {filled && (
        <>
          <defs>
            <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="currentColor" stopOpacity="0.25" />
              <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
            </linearGradient>
          </defs>
          <path d={area} fill={`url(#${gradientId})`} />
        </>
      )}
      <path
        d={line}
        fill="none"
        stroke={stroke ?? 'currentColor'}
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
};

// ---------------------------------------------------------------------------
//  Chart.Area — hero "balance over time" (Recharts)
// ---------------------------------------------------------------------------

interface AreaChartProps extends React.ComponentPropsWithoutRef<'div'> {
  data: number[];
  aspect?: number;
  xLabels?: string[];
  stroke?: string;
  strokeWidth?: number;
  bleed?: boolean;
  formatValue?: (n: number) => string;
  showAxis?: boolean;
  yTicks?: number;
}

const ChartArea = ({
  data,
  aspect = 3,
  xLabels,
  stroke,
  strokeWidth = 1.25,
  bleed = false,
  formatValue = DEFAULT_FORMAT,
  showAxis = true,
  yTicks: yTickCount = 4,
  className,
  ...rest
}: AreaChartProps) => {
  const gradientId = useId();
  const rows = useMemo(
    () => data.map((value, index) => ({ index, value })),
    [data]
  );
  const tickIndices = useMemo(
    () => buildTickIndexSet(data.length),
    [data.length]
  );
  const { min, max, ticks } = useMemo(
    () => computeYExtent(data, yTickCount),
    [data, yTickCount]
  );
  const hasXLabels = Boolean(xLabels?.length);
  const seriesColor = stroke ?? 'currentColor';
  const chartConfig = {
    value: { label: 'Value', color: seriesColor },
  } satisfies ChartConfig;

  return (
    <div
      className={cn(
        'relative w-full',
        !stroke && 'text-(--chart-1)',
        bleed && '-mx-(--surface-pad,0px)',
        className
      )}
      style={{ aspectRatio: `${aspect}` }}
      {...rest}
    >
      <ChartContainer config={chartConfig} className="h-full min-h-0 w-full">
        <AreaChart
          data={rows}
          margin={chartMargins(showAxis, hasXLabels)}
          accessibilityLayer
        >
          <defs>
            <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
              <stop
                offset="0%"
                stopColor="var(--color-value)"
                stopOpacity={0.18}
              />
              <stop
                offset="60%"
                stopColor="var(--color-value)"
                stopOpacity={0.04}
              />
              <stop
                offset="100%"
                stopColor="var(--color-value)"
                stopOpacity={0}
              />
            </linearGradient>
          </defs>
          {showAxis && (
            <CartesianGrid
              vertical={false}
              stroke="currentColor"
              strokeOpacity={0.08}
            />
          )}
          {showAxis && (
            <YAxis
              domain={[min, max]}
              ticks={ticks}
              tickFormatter={formatValue}
              axisLine={false}
              tickLine={false}
              width={44}
              tick={axisTickStyle}
            />
          )}
          <XAxis
            dataKey="index"
            axisLine={false}
            tickLine={false}
            tickMargin={8}
            interval={0}
            tickFormatter={(idx) =>
              tickIndices.has(Number(idx)) ? (xLabels?.[Number(idx)] ?? '') : ''
            }
            tick={axisTickStyle}
            hide={!hasXLabels}
          />
          <ChartTooltip
            content={<ValuePillTooltip formatValue={formatValue} />}
            cursor={{
              stroke: 'currentColor',
              strokeOpacity: 0.15,
              strokeWidth: 1,
              strokeDasharray: '3 3',
            }}
          />
          <Area
            type="monotone"
            dataKey="value"
            stroke="var(--color-value)"
            strokeWidth={strokeWidth}
            fill={`url(#${gradientId})`}
            dot={false}
            activeDot={{
              r: 3.5,
              fill: 'var(--color-value)',
              stroke: 'var(--color-background)',
              strokeWidth: 6,
            }}
          />
        </AreaChart>
      </ChartContainer>
    </div>
  );
};

// ---------------------------------------------------------------------------
//  Chart.Bars — discrete categorical / temporal bars (Recharts)
// ---------------------------------------------------------------------------

interface BarsProps extends React.ComponentPropsWithoutRef<'div'> {
  data: number[];
  aspect?: number;
  xLabels?: string[];
  bleed?: boolean;
  formatValue?: (n: number) => string;
  showAxis?: boolean;
  yTicks?: number;
}

const ChartBars = ({
  data,
  aspect = 3,
  xLabels,
  bleed = false,
  formatValue = DEFAULT_FORMAT,
  showAxis = true,
  yTicks: yTickCount = 4,
  className,
  ...rest
}: BarsProps) => {
  const [hoverIdx, setHoverIdx] = useState<number | null>(null);
  const rows = useMemo(
    () => data.map((value, index) => ({ index, value })),
    [data]
  );
  const tickIndices = useMemo(
    () => buildTickIndexSet(data.length),
    [data.length]
  );
  const { min, max, ticks } = useMemo(
    () => computeYExtent(data, yTickCount, true),
    [data, yTickCount]
  );
  const hasXLabels = Boolean(xLabels?.length);
  const chartConfig = {
    value: { label: 'Value', color: 'currentColor' },
  } satisfies ChartConfig;

  return (
    <div
      className={cn(
        'relative w-full text-(--chart-1)',
        bleed && '-mx-(--surface-pad,0px)',
        className
      )}
      style={{ aspectRatio: `${aspect}` }}
      {...rest}
    >
      <ChartContainer config={chartConfig} className="h-full min-h-0 w-full">
        <BarChart
          data={rows}
          margin={chartMargins(showAxis, hasXLabels)}
          accessibilityLayer
          onMouseLeave={() => setHoverIdx(null)}
        >
          {showAxis && (
            <CartesianGrid
              vertical={false}
              stroke="currentColor"
              strokeOpacity={0.08}
            />
          )}
          {showAxis && (
            <YAxis
              domain={[min, max]}
              ticks={ticks}
              tickFormatter={formatValue}
              axisLine={false}
              tickLine={false}
              width={44}
              tick={axisTickStyle}
            />
          )}
          <XAxis
            dataKey="index"
            axisLine={false}
            tickLine={false}
            tickMargin={8}
            interval={0}
            tickFormatter={(idx) =>
              tickIndices.has(Number(idx)) ? (xLabels?.[Number(idx)] ?? '') : ''
            }
            tick={axisTickStyle}
            hide={!hasXLabels}
          />
          <ChartTooltip
            content={<ValuePillTooltip formatValue={formatValue} />}
            cursor={{ fill: 'transparent' }}
          />
          <Bar
            dataKey="value"
            fill="var(--color-value)"
            radius={[3, 3, 0, 0]}
            maxBarSize={9999}
            onMouseEnter={(_, index) => setHoverIdx(index)}
            onMouseLeave={() => setHoverIdx(null)}
          >
            {rows.map((_, index) => (
              <Cell
                key={index}
                fillOpacity={
                  hoverIdx === null ? 0.9 : hoverIdx === index ? 1 : 0.4
                }
              />
            ))}
          </Bar>
        </BarChart>
      </ChartContainer>
    </div>
  );
};

// ---------------------------------------------------------------------------
//  Chart.Stacked — multi-series stacked bars (Recharts)
// ---------------------------------------------------------------------------

interface StackedSeries {
  label: string;
  data: number[];
  /** CSS color (e.g. `var(--chart-2)`). Defaults to `--chart-N`. */
  color?: string;
}

interface StackedProps extends React.ComponentPropsWithoutRef<'div'> {
  series: StackedSeries[];
  xLabels?: string[];
  aspect?: number;
  bleed?: boolean;
  formatValue?: (n: number) => string;
  showAxis?: boolean;
  yTicks?: number;
}

const ChartStacked = ({
  series,
  xLabels,
  aspect = 3,
  bleed = false,
  formatValue = DEFAULT_FORMAT,
  showAxis = true,
  yTicks: yTickCount = 4,
  className,
  ...rest
}: StackedProps) => {
  const sampleCount = series[0]?.data.length ?? 0;
  const rows = useMemo(() => {
    return Array.from({ length: sampleCount }, (_, i) => {
      const row: Record<string, number> = { index: i };
      for (let s = 0; s < series.length; s += 1) {
        row[`s${s}`] = series[s]?.data[i] ?? 0;
      }
      return row;
    });
  }, [series, sampleCount]);

  const totals = useMemo(() => {
    const out: number[] = [];
    for (let i = 0; i < sampleCount; i += 1) {
      let sum = 0;
      for (const s of series) sum += s.data[i] ?? 0;
      out.push(sum);
    }
    return out;
  }, [series, sampleCount]);

  const { max, ticks } = useMemo(() => {
    if (totals.length === 0) return { max: 1, ticks: [0, 1] as number[] };
    const hi = Math.max(...totals);
    const t = niceTicks(0, hi, yTickCount);
    return { max: t[t.length - 1], ticks: t };
  }, [totals, yTickCount]);

  const tickIndices = useMemo(
    () => buildTickIndexSet(sampleCount),
    [sampleCount]
  );
  const hasXLabels = Boolean(xLabels?.length);

  const chartConfig = useMemo(
    () =>
      Object.fromEntries(
        series.map((s, i) => [
          `s${i}`,
          {
            label: s.label,
            color: s.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
          },
        ])
      ) satisfies ChartConfig,
    [series]
  );

  return (
    <div
      className={cn(
        'relative w-full text-(--chart-1)',
        bleed && '-mx-(--surface-pad,0px)',
        className
      )}
      style={{ aspectRatio: `${aspect}` }}
      {...rest}
    >
      <ChartContainer config={chartConfig} className="h-full min-h-0 w-full">
        <BarChart
          data={rows}
          margin={chartMargins(showAxis, hasXLabels)}
          accessibilityLayer
        >
          {showAxis && (
            <CartesianGrid
              vertical={false}
              stroke="currentColor"
              strokeOpacity={0.08}
            />
          )}
          {showAxis && (
            <YAxis
              domain={[0, max]}
              ticks={ticks}
              tickFormatter={formatValue}
              axisLine={false}
              tickLine={false}
              width={44}
              tick={axisTickStyle}
            />
          )}
          <XAxis
            dataKey="index"
            axisLine={false}
            tickLine={false}
            tickMargin={8}
            interval={0}
            tickFormatter={(idx) =>
              tickIndices.has(Number(idx)) ? (xLabels?.[Number(idx)] ?? '') : ''
            }
            tick={axisTickStyle}
            hide={!hasXLabels}
          />
          {series.map((s, i) => (
            <Bar
              key={s.label}
              dataKey={`s${i}`}
              stackId="stack"
              fill={`var(--color-s${i})`}
              radius={i === series.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
            />
          ))}
        </BarChart>
      </ChartContainer>
    </div>
  );
};

// ---------------------------------------------------------------------------
//  Chart.Donut — single-figure-with-context (SVG)
// ---------------------------------------------------------------------------

interface DonutSegment {
  label: string;
  value: number;
  color?: string;
}

interface DonutProps extends React.ComponentPropsWithoutRef<'div'> {
  segments: DonutSegment[];
  size?: number;
  thickness?: number;
  centerLabel?: React.ReactNode;
  centerSubLabel?: React.ReactNode;
}

const polarToCartesian = (cx: number, cy: number, r: number, angle: number) => {
  const rad = ((angle - 90) * Math.PI) / 180;
  return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
};

const arcPath = (
  cx: number,
  cy: number,
  rOuter: number,
  rInner: number,
  startAngle: number,
  endAngle: number
) => {
  const largeArc = endAngle - startAngle > 180 ? 1 : 0;
  const o1 = polarToCartesian(cx, cy, rOuter, endAngle);
  const o2 = polarToCartesian(cx, cy, rOuter, startAngle);
  const i1 = polarToCartesian(cx, cy, rInner, startAngle);
  const i2 = polarToCartesian(cx, cy, rInner, endAngle);
  return [
    `M${o2.x.toFixed(2)},${o2.y.toFixed(2)}`,
    `A${rOuter},${rOuter} 0 ${largeArc} 1 ${o1.x.toFixed(2)},${o1.y.toFixed(2)}`,
    `L${i2.x.toFixed(2)},${i2.y.toFixed(2)}`,
    `A${rInner},${rInner} 0 ${largeArc} 0 ${i1.x.toFixed(2)},${i1.y.toFixed(2)}`,
    'Z',
  ].join(' ');
};

const ChartDonut = ({
  segments,
  size = 160,
  thickness = 0.18,
  centerLabel,
  centerSubLabel,
  className,
  ...rest
}: DonutProps) => {
  const total = segments.reduce((s, x) => s + x.value, 0);
  const cx = size / 2;
  const cy = size / 2;
  const rOuter = size / 2;
  const rInner = rOuter * (1 - thickness);
  const gapAngle = segments.length > 1 ? 1.5 : 0;

  let cursor = 0;
  const slices = segments.map((seg, i) => {
    const sweep = total === 0 ? 0 : (seg.value / total) * 360;
    const start = cursor + (segments.length > 1 ? gapAngle / 2 : 0);
    const end = cursor + sweep - (segments.length > 1 ? gapAngle / 2 : 0);
    cursor += sweep;
    return {
      d: end > start ? arcPath(cx, cy, rOuter, rInner, start, end) : '',
      color: seg.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
      label: seg.label,
    };
  });

  return (
    <div
      className={cn(
        'relative inline-flex items-center justify-center',
        className
      )}
      style={{ width: size, height: size }}
      {...rest}
    >
      <svg
        role="img"
        aria-label="donut chart"
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        className="block text-(--color-border)"
      >
        <circle
          cx={cx}
          cy={cy}
          r={(rOuter + rInner) / 2}
          fill="none"
          stroke="currentColor"
          strokeOpacity={0.08}
          strokeWidth={rOuter - rInner}
        />
        {slices.map((slice) => (
          <path key={slice.label} d={slice.d} fill={slice.color} />
        ))}
      </svg>

      {(centerLabel || centerSubLabel) && (
        <div className="absolute inset-0 flex flex-col items-center justify-center text-center">
          {centerLabel && (
            <div className="font-(family-name:--font-display) font-semibold text-foreground text-xl tabular-nums">
              {centerLabel}
            </div>
          )}
          {centerSubLabel && (
            <div className="text-foreground-secondary text-xs">
              {centerSubLabel}
            </div>
          )}
        </div>
      )}
    </div>
  );
};

// ---------------------------------------------------------------------------
//  Compound export
// ---------------------------------------------------------------------------

const Chart = {
  Sparkline: ChartSparkline,
  Area: ChartArea,
  Bars: ChartBars,
  Stacked: ChartStacked,
  Donut: ChartDonut,
};

export type { ChartConfig } from '@/components/chart-container';
export type {
  AreaChartProps,
  BarsProps,
  DonutProps,
  DonutSegment,
  SparklineProps,
  StackedProps,
  StackedSeries,
};
export { Chart, ChartContainer };
'use client';

import type * as React from 'react';
import { createContext, use, useId, useMemo } from 'react';
import type { TooltipValueType } from 'recharts';
import * as RechartsPrimitive from 'recharts';

import { cn } from '@/lib/utils/classnames';

/** Playstack uses `[data-theme="dark"]`, not `.dark`. */
const THEMES = { light: '', dark: '[data-theme="dark"]' } as const;

const INITIAL_DIMENSION = { width: 320, height: 200 } as const;

type TooltipNameType = number | string;

export type ChartConfig = Record<
  string,
  {
    label?: React.ReactNode;
    icon?: React.ComponentType;
  } & (
    | { color?: string; theme?: never }
    | { color?: never; theme: Record<keyof typeof THEMES, string> }
  )
>;

type ChartContextProps = {
  config: ChartConfig;
};

const ChartContext = createContext<ChartContextProps | null>(null);

const useChart = () => {
  const context = use(ChartContext);
  if (!context) {
    throw new Error('useChart must be used within a <ChartContainer />');
  }
  return context;
};

const CHART_CONTAINER_CLASS =
  'flex h-full w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-foreground-secondary [&_.recharts-cartesian-axis-tick_text]:text-[11px] [&_.recharts-cartesian-grid_line[stroke="#ccc"]]:stroke-foreground/8 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-foreground/15 [&_.recharts-dot[stroke="#fff"]]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke="#ccc"]]:stroke-foreground/8 [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-foreground/5 [&_.recharts-reference-line_[stroke="#ccc"]]:stroke-foreground/8 [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke="#fff"]]:stroke-transparent [&_.recharts-surface]:outline-hidden';

interface ChartContainerProps extends React.ComponentProps<'div'> {
  config: ChartConfig;
  children: React.ComponentProps<
    typeof RechartsPrimitive.ResponsiveContainer
  >['children'];
  initialDimension?: {
    width: number;
    height: number;
  };
}

const ChartContainer = ({
  id,
  className,
  children,
  config,
  initialDimension = INITIAL_DIMENSION,
  ...props
}: ChartContainerProps) => {
  const uniqueId = useId();
  const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;

  return (
    <ChartContext value={{ config }}>
      <div
        data-slot="chart"
        data-chart={chartId}
        className={cn(CHART_CONTAINER_CLASS, className)}
        {...props}
      >
        <ChartStyle id={chartId} config={config} />
        <RechartsPrimitive.ResponsiveContainer
          initialDimension={initialDimension}
        >
          {children}
        </RechartsPrimitive.ResponsiveContainer>
      </div>
    </ChartContext>
  );
};

const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
  const colorConfig = Object.entries(config).filter(
    ([, item]) => item.theme ?? item.color
  );

  if (!colorConfig.length) {
    return null;
  }

  return (
    <style
      // biome-ignore lint/security/noDangerouslySetInnerHtml: scoped CSS variables per chart id (shadcn chart pattern)
      dangerouslySetInnerHTML={{
        __html: Object.entries(THEMES)
          .map(
            ([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
  .map(([key, itemConfig]) => {
    const color =
      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
      itemConfig.color;
    return color ? `  --color-${key}: ${color};` : null;
  })
  .join('\n')}
}
`
          )
          .join('\n'),
      }}
    />
  );
};

const ChartTooltip = RechartsPrimitive.Tooltip;

interface ChartTooltipContentProps {
  active?: boolean;
  payload?: ReadonlyArray<{
    type?: string;
    value?: TooltipValueType;
    name?: TooltipNameType;
    color?: string;
    dataKey?: string | number;
    payload?: { fill?: string };
  }>;
  label?: React.ReactNode;
  className?: string;
  labelClassName?: string;
  labelFormatter?: (
    label: React.ReactNode,
    payload: ChartTooltipContentProps['payload']
  ) => React.ReactNode;
  formatter?: (
    value: TooltipValueType | undefined,
    name: TooltipNameType | undefined,
    item: NonNullable<ChartTooltipContentProps['payload']>[number],
    index: number,
    payload: NonNullable<ChartTooltipContentProps['payload']>
  ) => React.ReactNode;
  color?: string;
  hideLabel?: boolean;
  hideIndicator?: boolean;
  indicator?: 'line' | 'dot' | 'dashed';
  nameKey?: string;
  labelKey?: string;
}

const ChartTooltipContent = ({
  active,
  payload,
  className,
  indicator = 'dot',
  hideLabel = false,
  hideIndicator = false,
  label,
  labelFormatter,
  labelClassName,
  formatter,
  color,
  nameKey,
  labelKey,
}: ChartTooltipContentProps) => {
  const { config } = useChart();

  const tooltipLabel = useMemo(() => {
    if (hideLabel || !payload?.length) {
      return null;
    }

    const [item] = payload;
    const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
    const itemConfig = getPayloadConfigFromPayload(config, item, key);
    const value =
      !labelKey && typeof label === 'string'
        ? (config[label]?.label ?? label)
        : itemConfig?.label;

    if (labelFormatter) {
      return (
        <div className={cn('font-medium', labelClassName)}>
          {labelFormatter(value, payload)}
        </div>
      );
    }

    if (!value) {
      return null;
    }

    return <div className={cn('font-medium', labelClassName)}>{value}</div>;
  }, [
    label,
    labelFormatter,
    payload,
    hideLabel,
    labelClassName,
    config,
    labelKey,
  ]);

  if (!active || !payload?.length) {
    return null;
  }

  const nestLabel = payload.length === 1 && indicator !== 'dot';

  return (
    <div
      className={cn(
        'grid min-w-[5.5rem] items-start gap-1.5 rounded-md border border-foreground/8 bg-background px-2.5 py-1.5 text-xs shadow-sm',
        className
      )}
    >
      {!nestLabel ? tooltipLabel : null}
      <div className="grid gap-1.5">
        {payload
          .filter((item) => item.type !== 'none')
          .map((item, index) => {
            const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
            const itemConfig = getPayloadConfigFromPayload(config, item, key);
            const indicatorColor = color ?? item.payload?.fill ?? item.color;

            return (
              <div
                key={index}
                className={cn(
                  'flex w-full flex-wrap items-stretch gap-2',
                  indicator === 'dot' && 'items-center'
                )}
              >
                {formatter && item?.value !== undefined && item.name ? (
                  formatter(item.value, item.name, item, index, payload ?? [])
                ) : (
                  <>
                    {itemConfig?.icon ? (
                      <itemConfig.icon />
                    ) : (
                      !hideIndicator && (
                        <div
                          className={cn('size-2.5 shrink-0 rounded-[2px]', {
                            'w-1': indicator === 'line',
                            'w-0 border-[1.5px] border-dashed bg-transparent':
                              indicator === 'dashed',
                          })}
                          style={
                            {
                              backgroundColor:
                                indicator === 'dashed'
                                  ? 'transparent'
                                  : indicatorColor,
                              borderColor: indicatorColor,
                            } as React.CSSProperties
                          }
                        />
                      )
                    )}
                    <div
                      className={cn(
                        'flex flex-1 justify-between leading-none',
                        nestLabel ? 'items-end' : 'items-center'
                      )}
                    >
                      <div className="grid gap-1.5">
                        {nestLabel ? tooltipLabel : null}
                        <span className="text-foreground-secondary">
                          {itemConfig?.label ?? item.name}
                        </span>
                      </div>
                      {item.value != null && (
                        <span className="font-medium text-foreground tabular-nums">
                          {typeof item.value === 'number'
                            ? item.value.toLocaleString()
                            : String(item.value)}
                        </span>
                      )}
                    </div>
                  </>
                )}
              </div>
            );
          })}
      </div>
    </div>
  );
};

const ChartLegend = RechartsPrimitive.Legend;

const ChartLegendContent = ({
  className,
  hideIcon = false,
  payload,
  verticalAlign = 'bottom',
  nameKey,
}: React.ComponentProps<'div'> & {
  hideIcon?: boolean;
  nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) => {
  const { config } = useChart();

  if (!payload?.length) {
    return null;
  }

  return (
    <div
      className={cn(
        'flex items-center justify-center gap-4',
        verticalAlign === 'top' ? 'pb-3' : 'pt-3',
        className
      )}
    >
      {payload
        .filter((item) => item.type !== 'none')
        .map((item, index) => {
          const key = `${nameKey ?? item.dataKey ?? 'value'}`;
          const itemConfig = getPayloadConfigFromPayload(config, item, key);

          return (
            <div
              key={index}
              className="flex items-center gap-1.5 text-foreground-secondary"
            >
              {itemConfig?.icon && !hideIcon ? (
                <itemConfig.icon />
              ) : (
                <div
                  className="size-2 shrink-0 rounded-[2px]"
                  style={{
                    backgroundColor: item.color,
                  }}
                />
              )}
              {itemConfig?.label}
            </div>
          );
        })}
    </div>
  );
};

const getPayloadConfigFromPayload = (
  config: ChartConfig,
  payload: unknown,
  key: string
) => {
  if (typeof payload !== 'object' || payload === null) {
    return undefined;
  }

  const payloadPayload =
    'payload' in payload &&
    typeof payload.payload === 'object' &&
    payload.payload !== null
      ? payload.payload
      : undefined;

  let configLabelKey: string = key;

  if (
    key in payload &&
    typeof payload[key as keyof typeof payload] === 'string'
  ) {
    configLabelKey = payload[key as keyof typeof payload] as string;
  } else if (
    payloadPayload &&
    key in payloadPayload &&
    typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
  ) {
    configLabelKey = payloadPayload[
      key as keyof typeof payloadPayload
    ] as string;
  }

  return configLabelKey in config ? config[configLabelKey] : config[key];
};

export type { ChartContainerProps };
export {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartStyle,
  ChartTooltip,
  ChartTooltipContent,
};

Chart is an opinionated chart set for productivity dashboards. Five primitives ship: Sparkline for inline trend hints, Area for hero “balance over time” curves, Bars for discrete categorical or temporal data, Stacked for multi-series composition, and Donut for single-figure-with-context displays.

Area, Bars, and Stacked use Recharts under a token-driven ChartContainer (series colors from --chart-1--chart-5). Sparkline and Donut stay dependency-free SVG for small, fixed-size marks.

The set is intentionally small. Pie charts, scatter, candlestick, heatmap, and treemap are out of scope — compose with Recharts directly via the exported ChartContainer when you need more chart types.

Anatomy


          <Chart.Sparkline data={[3, 5, 2, 8, 6, 9]} filled />

<Chart.Area
  data={values}
  xLabels={dates}
  formatValue={(n) => `$${n.toLocaleString()}`}
  bleed
/>

<Chart.Bars data={counts} xLabels={days} formatValue={n => n.toString()} />

<Chart.Stacked
  series={[
    { label: 'Income', data: income, color: 'var(--color-border-hover)' },
    { label: 'Expenses', data: expenses },
  ]}
  xLabels={months}
/>

<Chart.Donut
  segments={[
    { label: 'Done', value: 68 },
    { label: 'Open', value: 32 },
  ]}
  centerLabel="68%"
  centerSubLabel="34 of 50"
/>
        

Behavior

Area, Bars, and Stacked measure their container with ResizeObserver and render at true pixel coordinates, so lines and bars stay crisp at any width and never stretch. They cooperate with Surface via the bleed prop — escaping the surface’s padding via --surface-pad so the data can hit card edges while axis labels stay aligned to the surface’s content padding.

Hover behavior:

  • Area snaps a vertical guide and value pill to the nearest data point.
  • Bars highlights the hovered bar (others fade) and floats a value pill.
  • Donut and Sparkline are read-only.

All hover transitions use --duration-hover (100ms) and respect prefers-reduced-motion.

Cartesian series default to --chart-1 (or currentColor when a parent sets text-*). Pass stroke on Chart.Area to override. Chart.Stacked cycles --chart-1--chart-5 per series unless series[].color is set.

API Reference

Chart.Sparkline

Extends the svg element.

Prop Default Type
data - number[]
width 96 number
height 28 number
stroke - string
strokeWidth 1.5 number
filled - boolean

Chart.Area

Extends the div element. SVG inside scales to its container width.

Prop Default Type
data - number[]
aspect 3 number
xLabels - string[]
stroke - string
strokeWidth 1.25 number
bleed false boolean
formatValue - (n: number) => string
showAxis true boolean
yTicks 4 number

Chart.Bars

Extends the div element. SVG inside scales to its container width.

Prop Default Type
data - number[]
aspect 3 number
xLabels - string[]
bleed false boolean
formatValue - (n: number) => string
showAxis true boolean
yTicks 4 number

Chart.Stacked

Extends the div element. SVG inside scales to its container width.

Prop Default Type
series - { label: string; data: number[]; color?: string }[]
xLabels - string[]
aspect 3 number
bleed false boolean
formatValue - (n: number) => string
showAxis true boolean
yTicks 4 number

Chart.Donut

Extends the div element. Fixed size — pass size in pixels.

Prop Default Type
segments - { label: string; value: number; color?: string }[]
size 160 number
thickness 0.18 number
centerLabel - ReactNode
centerSubLabel - ReactNode

Examples

Default

Area
Bars
Sparkline
Donut
72%
Stacked
import { Chart } from '@/components/chart';
import { Surface } from '@/components/surface';

const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];
const areaData = [12, 19, 14, 22, 18, 26, 24];
const barData = [8, 12, 6, 15, 10, 18, 14];

export default function ChartPreview() {
  return (
    <div className="flex w-full max-w-3xl flex-col gap-8 p-4">
      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Area</span>
        <Chart.Area data={areaData} xLabels={months} aspect={3.2} />
      </Surface>

      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Bars</span>
        <Chart.Bars data={barData} xLabels={months} aspect={3.2} />
      </Surface>

      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
        <Surface
          elevation="default"
          radius="lg"
          padding="md"
          className="flex flex-col items-center gap-3"
        >
          <span className="font-medium text-foreground text-sm">Sparkline</span>
          <Chart.Sparkline data={areaData} filled width={120} height={32} />
        </Surface>

        <Surface
          elevation="default"
          radius="lg"
          padding="md"
          className="flex flex-col items-center gap-3"
        >
          <span className="font-medium text-foreground text-sm">Donut</span>
          <Chart.Donut
            segments={[
              { label: 'Done', value: 72 },
              { label: 'Open', value: 28 },
            ]}
            size={96}
            thickness={0.16}
            centerLabel="72%"
          />
        </Surface>
      </div>

      <Surface
        elevation="default"
        radius="lg"
        padding="md"
        className="flex flex-col gap-3"
      >
        <span className="font-medium text-foreground text-sm">Stacked</span>
        <Chart.Stacked
          series={[
            { label: 'Income', data: [4, 6, 5, 8, 7, 9, 8] },
            { label: 'Expenses', data: [2, 3, 4, 3, 5, 4, 3] },
          ]}
          xLabels={months}
          aspect={3.2}
        />
      </Surface>
    </div>
  );
}

Previous

Center

Next

Checkbox