
Interactive timeline play/pause scrubber with keyframe animation.
components/tanstack-charts/line/timeline-playback-scrubber.tsx
import { type ChartPoint, defineChart, dot, lineY } from "@tanstack/charts";
import { handleX } from "@tanstack/charts/interaction/handle";
import { controlledSignal } from "@tanstack/charts/interaction/signal";
import { motion } from "@tanstack/charts/motion";
import { tooltip } from "@tanstack/charts/tooltip";
import { Chart } from "@tanstack/react-charts/core";
import { useEffect, useMemo, useState } from "react";
import { scaleLinear, scaleUtc } from "d3-scale";
import { Pause, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
interface StockDatum {
date: Date;
close: number;
}
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
function generateDemoData(): StockDatum[] {
const results: StockDatum[] = [];
let price = 160;
const baseDate = new Date(Date.UTC(2024, 0, 1)).getTime();
for (let i = 0; i < 20; i++) {
price += Math.sin(i * 0.5) * 5 + (Math.random() - 0.45) * 6;
results.push({
date: new Date(baseDate + i * 7 * 24 * 60 * 60 * 1000),
close: Number(Math.max(100, price).toFixed(2)),
});
}
return results;
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as StockDatum;
if (!d || !d.date) return { title: "", rows: [] };
const dateObj = d.date instanceof Date ? d.date : new Date(d.date);
const titleStr = isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
return {
title: titleStr,
rows: [{ label: "Close Price", value: `$${d.close.toFixed(2)}`, color: "var(--chart-1)" }],
};
}
export function TimelinePlaybackScrubber() {
const data = useMemo(() => generateDemoData(), []);
const dates = useMemo(() => data.map((d) => d.date), [data]);
const [currentFrame, setCurrentFrame] = useState<Date>(dates[0]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
useEffect(() => {
if (!isPlaying) return;
const interval = setInterval(() => {
setCurrentFrame((prev) => {
const prevTime = prev instanceof Date ? prev.getTime() : new Date(prev).getTime();
const idx = dates.findIndex(
(d) => (d instanceof Date ? d.getTime() : new Date(d).getTime()) === prevTime,
);
if (idx < 0 || idx >= dates.length - 1) {
setIsPlaying(false);
return prev;
}
return dates[idx + 1];
});
}, 600);
return () => clearInterval(interval);
}, [dates, isPlaying]);
const activeDatum = useMemo(() => {
const frameTime = currentFrame instanceof Date ? currentFrame.getTime() : new Date(currentFrame).getTime();
return (
data.find((d) => (d.date instanceof Date ? d.date.getTime() : new Date(d.date).getTime()) === frameTime) ||
data[0]
);
}, [currentFrame, data]);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
lineY(data, {
id: "playback-line",
x: "date",
y: "close",
stroke: "var(--chart-1)",
strokeWidth: 2.5,
}),
dot(data, {
id: "playback-points",
x: "date",
y: "close",
fill: "var(--chart-1)",
r: 3.5,
stroke: "var(--background)",
strokeWidth: 1,
}),
],
scales: {
x: {
scale: scaleUtc,
axis: {
ticks: {
format: (v: any) => {
const dateObj = v instanceof Date ? v : new Date(v);
return isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
},
},
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { label: "Stock Close ($)" },
},
},
controls: [
handleX({
id: "playback-frame",
value: controlledSignal<Date, any>(currentFrame, (next) =>
setCurrentFrame(next instanceof Date ? next : new Date(next)),
),
values: dates,
cross: { edge: "bottom", offset: 24 },
ruleStyle: { fill: "var(--chart-2)" },
handleStyle: {
fill: "var(--chart-2)",
stroke: "var(--background)",
strokeWidth: 2,
},
ariaLabel: "Timeline playback frame scrubber",
}),
],
margin: { top: 24, right: 24, bottom: 45, left: 45 },
theme: {
foreground: "var(--muted-foreground)",
grid: "var(--border)",
background: "transparent",
},
},
{
keyboard: true,
tooltip: {
use: tooltip,
className:
"font-sans shadow-none rounded-md bg-popover text-popover-foreground border p-2 text-xs",
anchor: "pointer",
placement: "auto",
content: (points) => shadcnTooltipContent(points),
},
},
),
[currentFrame, data, dates],
);
const fmtDate = (d: any) => {
const dateObj = d instanceof Date ? d : new Date(d);
return isNaN(dateObj.getTime())
? ""
: dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
};
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Animated Timeline Playback Scrubber</CardTitle>
<CardDescription>
Interactive timeline play/pause scrubber stepping through sequential data frames
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => setIsPlaying((prev) => !prev)}>
{isPlaying ? <Pause className="mr-1.5 h-3.5 w-3.5" /> : <Play className="mr-1.5 h-3.5 w-3.5" />}
{isPlaying ? "Pause" : "Play Timeline"}
</Button>
<span className="text-muted-foreground font-mono text-xs">
Active: <strong className="text-foreground">{fmtDate(activeDatum.date)}</strong> ($
{activeDatum.close.toFixed(2)})
</span>
</div>
<div className="h-[230px] w-full">
<Chart
definition={definition}
renderer={motionRenderer}
height={230}
ariaLabel="Animated Timeline Playback Scrubber Chart"
/>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
{isPlaying
? "Playback active - stepping through timeline..."
: "Drag orange handle or click Play to scrub"}
</div>
<div className="text-muted-foreground leading-none">
`handleX` control provides draggable playhead handle along bottom X axis rail
</div>
</CardFooter>
</Card>
);
}
export default TimelinePlaybackScrubber;