
Interactive toggleable series legend to show or hide individual series dynamically.
components/tanstack-charts/line/recharts-interactive-legend.tsx
import { type ChartPoint, defineChart, lineY } from "@tanstack/charts";
import { controlledSignal } from "@tanstack/charts/interaction/signal";
import { interactiveColorLegend } from "@tanstack/charts/legend";
import { motion } from "@tanstack/charts/motion";
import { tooltip } from "@tanstack/charts/tooltip";
import { Chart } from "@tanstack/react-charts/core";
import { useMemo, useState } from "react";
import { scaleLinear, scaleUtc } from "d3-scale";
import { Eye } from "lucide-react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
interface IndustryDatum {
industry: string;
date: Date;
unemployed: number;
}
type SeriesId = "Manufacturing" | "Construction";
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
const seriesColors: Record<SeriesId, string> = {
Manufacturing: "var(--chart-1)",
Construction: "var(--chart-2)",
};
function generateDemoData(): IndustryDatum[] {
const results: IndustryDatum[] = [];
for (let m = 0; m < 14; m++) {
const date = new Date(Date.UTC(2024, m, 1));
results.push({
industry: "Manufacturing",
date,
unemployed: Math.round(450 + Math.sin(m * 0.4) * 120 + Math.random() * 30),
});
results.push({
industry: "Construction",
date,
unemployed: Math.round(300 + Math.cos(m * 0.4) * 90 + Math.random() * 25),
});
}
return results;
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as IndustryDatum;
if (!d) return { title: "", rows: [] };
return {
title: `${d.industry} (${d.date.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" })})`,
rows: [
{
label: "Unemployed Count",
value: `${d.unemployed}k`,
color: seriesColors[d.industry as SeriesId] || "var(--chart-1)",
},
],
};
}
export function RechartsInteractiveLegend() {
const [visibleSeries, setVisibleSeries] = useState<readonly SeriesId[]>(["Manufacturing", "Construction"]);
const rawData = useMemo(() => generateDemoData(), []);
const filteredData = useMemo(() => {
return rawData.filter((d) => visibleSeries.includes(d.industry as SeriesId));
}, [rawData, visibleSeries]);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
lineY(filteredData, {
id: "industry-lines",
x: "date",
y: "unemployed",
color: "industry",
strokeWidth: 2.5,
}),
],
scales: {
x: {
scale: scaleUtc,
axis: {
ticks: {
format: (v: Date) => v.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }),
},
},
},
y: {
scale: scaleLinear().domain([0, 750]),
grid: true,
axis: { label: "Unemployed Count (k)" },
},
},
color: {
domain: ["Manufacturing", "Construction"],
range: ["var(--chart-1)", "var(--chart-2)"],
legend: interactiveColorLegend({
visible: controlledSignal(visibleSeries as readonly string[], (v) =>
setVisibleSeries(v as readonly SeriesId[]),
),
placement: "bottom",
ariaLabel: "Series visibility toggle",
}),
},
margin: { top: 20, right: 24, bottom: 60, left: 48 },
theme: {
foreground: "var(--muted-foreground)",
grid: "var(--border)",
background: "transparent",
},
},
{
focus: "group-x",
keyboard: true,
tooltip: {
use: tooltip,
className:
"font-sans shadow-none rounded-md bg-popover text-popover-foreground border p-2.5 text-xs pointer-events-none transition-all duration-75",
anchor: "pointer",
placement: "auto",
content: (points) => shadcnTooltipContent(points),
},
},
),
[filteredData, visibleSeries],
);
const toggleSeries = (id: SeriesId) => {
if (visibleSeries.includes(id)) {
if (visibleSeries.length > 1) {
setVisibleSeries(visibleSeries.filter((s) => s !== id));
}
} else {
setVisibleSeries([...visibleSeries, id]);
}
};
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Interactive Toggleable Legend</CardTitle>
<CardDescription>
Click interactive legend items to toggle series visibility on/off dynamically
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-center gap-3 pt-1">
{(["Manufacturing", "Construction"] as const).map((id) => {
const isVisible = visibleSeries.includes(id);
const color = seriesColors[id];
return (
<button
key={id}
type="button"
onClick={() => toggleSeries(id)}
className={`inline-flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium transition-all duration-150 ${
isVisible
? "border-border bg-muted/60 text-foreground hover:bg-muted shadow-2xs"
: "text-muted-foreground border-transparent bg-transparent opacity-40 hover:opacity-70"
}`}>
<span
className="h-2.5 w-2.5 rounded-full transition-transform"
style={{
backgroundColor: color,
transform: isVisible ? "scale(1)" : "scale(0.75)",
}}
/>
{id}
</button>
);
})}
</div>
<div className="h-[250px] w-full">
<Chart
definition={definition}
renderer={motionRenderer}
height={250}
ariaLabel="Interactive Toggleable Legend Chart"
/>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
Controlled signal updates visible series in state reactively{" "}
<Eye className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-muted-foreground leading-none">
`interactiveColorLegend` integrates with React state for seamless toggle interaction
</div>
</CardFooter>
</Card>
);
}
export default RechartsInteractiveLegend;