Chart
Dashboard chart primitives — Recharts cartesian series plus SVG sparkline and donut
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:
Areasnaps a vertical guide and value pill to the nearest data point.Barshighlights the hovered bar (others fade) and floats a value pill.DonutandSparklineare 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
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