Chart.
Recharts v3 wrappers: a responsive ChartContainer driven by a ChartConfig of series labels and colors, plus a Lantern tooltip and legend. Axes and grid lines pick up border and muted colors.
"use client";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
const data = [
{ time: "06:00", fuel: 18400 },
{ time: "08:00", fuel: 16900 },
{ time: "10:00", fuel: 14200 },
{ time: "12:00", fuel: 19600 },
{ time: "14:00", fuel: 17100 },
{ time: "16:00", fuel: 13800 },
{ time: "18:00", fuel: 11200 },
{ time: "20:00", fuel: 15400 },
];
const chartConfig = {
fuel: { label: "Fuel", color: "var(--chart-1)" },
} satisfies ChartConfig;
export default function ChartDemo() {
return (
<Card className="w-full max-w-xl gap-4">
<CardHeader>
<CardTitle>Fuel over time</CardTitle>
<CardDescription>Quarry-2, refueled from the coal chest at noon.</CardDescription>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={chartConfig} className="aspect-auto h-[220px] w-full">
<AreaChart data={data} margin={{ left: 0, right: 12, top: 8 }}>
<defs>
<linearGradient id="fillFuel" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-fuel)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-fuel)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="time" tickLine={false} axisLine={false} tickMargin={8} minTickGap={16} />
<YAxis
tickLine={false}
axisLine={false}
width={36}
tickFormatter={(value: number) => `${value / 1000}k`}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
<Area
dataKey="fuel"
type="stepAfter"
fill="url(#fillFuel)"
stroke="var(--color-fuel)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}Installation#
With the CLI
$npx lanterncn add chartThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/chart. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install rechartsCopy the source into your project
components/ui/chart.tsx"use client"; import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { cn } from "@/lib/utils"; const DEFAULT_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; /** Maps each series key to a label, an optional icon and a color. Colors default to var(--chart-1..5). */ type ChartConfig = Record< string, { label?: React.ReactNode; icon?: React.ComponentType; color?: string; } >; type ChartContextProps = { config: ChartConfig }; const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() { const context = React.useContext(ChartContext); if (!context) { throw new Error("useChart must be used within a <ChartContainer />"); } return context; } function ChartContainer({ id, className, children, config, initialDimension = { width: 320, height: 200 }, ...props }: React.ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]; initialDimension?: { width: number; height: number }; }) { const uniqueId = React.useId(); const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; return ( <ChartContext.Provider value={{ config }}> <div data-slot="chart" data-chart={chartId} className={cn( "flex aspect-video justify-center font-mono text-[10px] tracking-wider", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-axis-line]:stroke-border", "[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/70 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-input", "[&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted", "[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-secondary/60 [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border", "[&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-sector[stroke='#fff']]:stroke-transparent", "[&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden", className, )} {...props} > <ChartStyle id={chartId} config={config} /> <RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}> {children} </RechartsPrimitive.ResponsiveContainer> </div> </ChartContext.Provider> ); } /** Writes a --color-<key> variable for every series so charts can use fill="var(--color-fuel)". */ function ChartStyle({ id, config }: { id: string; config: ChartConfig }) { const entries = Object.entries(config); if (!entries.length) return null; const vars = entries .map(([key, item], index) => { const color = item.color ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length]; return ` --color-${key.replace(/[^a-zA-Z0-9_-]/g, "-")}: ${color};`; }) .join("\n"); return <style dangerouslySetInnerHTML={{ __html: `[data-chart=${id}] {\n${vars}\n}` }} />; } const ChartTooltip = RechartsPrimitive.Tooltip; type TooltipPayload = ReadonlyArray<RechartsPrimitive.TooltipPayloadEntry>; function ChartTooltipContent({ active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, color, nameKey, labelKey, }: Omit<React.ComponentProps<"div">, "children"> & Pick< Partial<RechartsPrimitive.TooltipContentProps<RechartsPrimitive.TooltipValueType, string | number>>, "active" | "payload" | "label" | "labelFormatter" | "formatter" > & { hideLabel?: boolean; hideIndicator?: boolean; indicator?: "line" | "dot" | "dashed"; labelClassName?: string; nameKey?: string; labelKey?: string; color?: string; }) { const { config } = useChart(); const tooltipLabel = React.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-mono text-[10px] tracking-[0.18em] text-muted-foreground uppercase", labelClassName)}> {labelFormatter(value, payload as TooltipPayload)} </div> ); } if (!value) return null; return ( <div className={cn("font-mono text-[10px] tracking-[0.18em] text-muted-foreground uppercase", 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 data-slot="chart-tooltip" className={cn( "grid min-w-[8rem] items-start gap-1.5 rounded-md border bg-popover px-3 py-2 font-sans text-xs text-popover-foreground normal-case shadow-block-sm tracking-normal", 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={`${item.dataKey ?? index}`} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center", )} > {formatter && item?.value !== undefined && item.name ? ( formatter(item.value, item.name, item, index, payload as TooltipPayload) ) : ( <> {itemConfig?.icon ? ( <itemConfig.icon /> ) : ( !hideIndicator && ( <div className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", { "size-2.5": indicator === "dot", "w-1": indicator === "line", "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed", })} style={ { "--color-bg": indicatorColor, "--color-border": indicatorColor, } as React.CSSProperties } /> ) )} <div className={cn( "flex flex-1 justify-between gap-4 leading-none", nestLabel ? "items-end" : "items-center", )} > <div className="grid gap-1.5"> {nestLabel ? tooltipLabel : null} <span className="text-muted-foreground">{itemConfig?.label || item.name}</span> </div> {item.value !== undefined && ( <span className="font-mono font-medium text-foreground tabular-nums"> {typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)} </span> )} </div> </> )} </div> ); })} </div> </div> ); } /** Recharts Legend that keeps series in the order they are drawn instead of sorting by name. */ function ChartLegend({ itemSorter = null, ...props }: React.ComponentProps<typeof RechartsPrimitive.Legend>) { return <RechartsPrimitive.Legend itemSorter={itemSorter} {...props} />; } function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey, }: React.ComponentProps<"div"> & { payload?: ReadonlyArray<RechartsPrimitive.LegendPayload>; verticalAlign?: "top" | "bottom" | "middle"; hideIcon?: boolean; nameKey?: string; }) { const { config } = useChart(); if (!payload?.length) return null; return ( <div data-slot="chart-legend" className={cn( "flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase", verticalAlign === "top" ? "pb-3" : "pt-3", className, )} > {payload .filter((item) => item.type !== "none") .map((item) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( <div key={String(item.value ?? key)} className="flex items-center gap-1.5 [&>svg]:size-3 [&>svg]:text-muted-foreground" > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div className="size-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} /> )} {itemConfig?.label ?? item.value} </div> ); })} </div> ); } /** Finds the config entry for a tooltip or legend item, checking the item and its data row for the key. */ function 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 as Record<string, unknown>) : undefined; const record = payload as Record<string, unknown>; let configLabelKey: string = key; if (typeof record[key] === "string") { configLabelKey = record[key] as string; } else if (payloadPayload && typeof payloadPayload[key] === "string") { configLabelKey = payloadPayload[key] as string; } return configLabelKey in config ? config[configLabelKey] : config[key]; } export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, useChart, type ChartConfig };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
useChart,
} from "@/components/ui/chart";const chartConfig = {
fuel: { label: "Fuel", color: "var(--chart-1)" },
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="h-[220px] w-full">
<AreaChart data={data}>
<XAxis dataKey="time" />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="fuel" fill="var(--color-fuel)" stroke="var(--color-fuel)" />
</AreaChart>
</ChartContainer>Every key in the config becomes a --color-<key> variable on the container, so series can use fill="var(--color-fuel)". Keys without a color fall back to var(--chart-1) through var(--chart-5) in order.
ChartContainer uses aspect-video by default. Pass aspect-auto with a fixed height, or aspect-square with a max height for pie and radial charts.
Examples#
Bar chart with two series
"use client";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const data = [
{ day: "Mon", stone: 1840, ore: 212 },
{ day: "Tue", stone: 2210, ore: 264 },
{ day: "Wed", stone: 1560, ore: 180 },
{ day: "Thu", stone: 2480, ore: 331 },
{ day: "Fri", stone: 1990, ore: 247 },
{ day: "Sat", stone: 2760, ore: 402 },
{ day: "Sun", stone: 1230, ore: 138 },
];
const chartConfig = {
stone: { label: "Stone", color: "var(--chart-2)" },
ore: { label: "Ore", color: "var(--chart-1)" },
} satisfies ChartConfig;
export default function ChartBar() {
return (
<Card className="w-full max-w-xl gap-4">
<CardHeader>
<CardTitle>Blocks mined per day</CardTitle>
<CardDescription>All turtles on Deepstone, last seven days.</CardDescription>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={chartConfig} className="aspect-auto h-[240px] w-full">
<BarChart data={data} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="day" tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip cursor content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="stone" fill="var(--color-stone)" radius={[2, 2, 0, 0]} />
<Bar dataKey="ore" fill="var(--color-ore)" radius={[2, 2, 0, 0]} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}Line chart
"use client";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const data = [
{ hour: "00", hearth: 4, copperline: 11 },
{ hour: "03", hearth: 2, copperline: 6 },
{ hour: "06", hearth: 5, copperline: 9 },
{ hour: "09", hearth: 12, copperline: 18 },
{ hour: "12", hearth: 19, copperline: 27 },
{ hour: "15", hearth: 16, copperline: 34 },
{ hour: "18", hearth: 24, copperline: 41 },
{ hour: "21", hearth: 14, copperline: 22 },
];
const chartConfig = {
hearth: { label: "Hearth SMP", color: "var(--chart-1)" },
copperline: { label: "Copperline", color: "var(--chart-3)" },
} satisfies ChartConfig;
export default function ChartLine() {
return (
<Card className="w-full max-w-xl gap-4">
<CardHeader>
<CardTitle>Players online</CardTitle>
<CardDescription>Sampled every three hours by the hub computer.</CardDescription>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={chartConfig} className="aspect-auto h-[220px] w-full">
<LineChart data={data} margin={{ left: 0, right: 12, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) => `${value}:00`}
/>
<YAxis tickLine={false} axisLine={false} width={28} />
<ChartTooltip
cursor
content={<ChartTooltipContent labelFormatter={(label) => `${label}:00`} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line dataKey="hearth" type="monotone" stroke="var(--color-hearth)" strokeWidth={2} dot={false} />
<Line
dataKey="copperline"
type="monotone"
stroke="var(--color-copperline)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}Donut with a center label
"use client";
import { Label, Pie, PieChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const data = [
{ job: "mining", turtles: 11, fill: "var(--color-mining)" },
{ job: "farming", turtles: 6, fill: "var(--color-farming)" },
{ job: "building", turtles: 4, fill: "var(--color-building)" },
{ job: "idle", turtles: 3, fill: "var(--color-idle)" },
];
const chartConfig = {
turtles: { label: "Turtles" },
mining: { label: "Mining", color: "var(--chart-1)" },
farming: { label: "Farming", color: "var(--chart-2)" },
building: { label: "Building", color: "var(--chart-3)" },
idle: { label: "Idle", color: "var(--chart-4)" },
} satisfies ChartConfig;
const total = data.reduce((sum, item) => sum + item.turtles, 0);
export default function ChartDonut() {
return (
<Card className="w-full max-w-sm gap-2">
<CardHeader>
<CardTitle>Fleet by job</CardTitle>
<CardDescription>What each turtle is running right now.</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[280px]">
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent nameKey="job" hideLabel />} />
<Pie
data={data}
dataKey="turtles"
nameKey="job"
innerRadius="58%"
outerRadius="80%"
paddingAngle={2}
stroke="var(--card)"
strokeWidth={2}
>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox)) return null;
const { cx, cy } = viewBox;
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan
x={cx}
y={(cy ?? 0) - 6}
className="fill-foreground font-display text-3xl font-medium tracking-tight"
>
{total}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 18} className="fill-muted-foreground font-mono text-[10px] tracking-[0.2em] uppercase">
Turtles
</tspan>
</text>
);
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="job" />} />
</PieChart>
</ChartContainer>
</CardContent>
</Card>
);
}Radial bar
"use client";
import { PolarAngleAxis, RadialBar, RadialBarChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
const data = [
{ server: "copperline", uptime: 99, fill: "var(--color-copperline)" },
{ server: "hearth", uptime: 94, fill: "var(--color-hearth)" },
{ server: "deepstone", uptime: 81, fill: "var(--color-deepstone)" },
{ server: "mossgate", uptime: 62, fill: "var(--color-mossgate)" },
];
const chartConfig = {
uptime: { label: "Uptime %" },
copperline: { label: "Copperline", color: "var(--chart-2)" },
hearth: { label: "Hearth SMP", color: "var(--chart-1)" },
deepstone: { label: "Deepstone", color: "var(--chart-3)" },
mossgate: { label: "Mossgate", color: "var(--chart-5)" },
} satisfies ChartConfig;
export default function ChartRadial() {
return (
<Card className="w-full max-w-sm gap-2">
<CardHeader>
<CardTitle>Server uptime</CardTitle>
<CardDescription>Share of the last 30 days each hub answered pings.</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[260px]">
<RadialBarChart data={data} innerRadius="28%" outerRadius="100%" startAngle={90} endAngle={-270}>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} axisLine={false} />
<ChartTooltip cursor={false} shared={false} content={<ChartTooltipContent nameKey="server" hideLabel />} />
<RadialBar dataKey="uptime" background cornerRadius={2} />
</RadialBarChart>
</ChartContainer>
</CardContent>
</Card>
);
}