Chart
An animated bar chart component that streams data from AI with smooth reveal animations.
A bar chart component that progressively reveals data as it streams from AI, with interactive hover states and animated value displays.
Click a button to generate chart data
Installation
npm install @stream.ui/react recharts lucide-react motionnpx shadcn@latest add badge card chart skeletonimport { z } from "zod";
export const streamingChartSchema = z.object({
title: z.string().describe("Chart title (e.g., 'Revenue', 'Users')"),
value: z.number().describe("Current highlighted value"),
unit: z.string().describe("Value unit (e.g., '$', '€', 'users')"),
change: z.number().describe("Percentage change vs previous period"),
changeLabel: z.string().describe("Label for change (e.g., 'vs. last quarter')"),
data: z
.array(
z.object({
label: z.string().describe("X-axis label (e.g., 'Jan', 'Feb')"),
value: z.number().describe("Bar value"),
}),
)
.describe("Chart data points"),
});
export type StreamingChartData = z.infer<typeof streamingChartSchema>;"use client";
import type { DeepPartial } from "@stream.ui/react";
import { Stream } from "@stream.ui/react";
import { TrendingDown, TrendingUp } from "lucide-react";
import { motion } from "motion/react";
import * as React from "react";
import { Bar, BarChart, XAxis } from "recharts";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { StreamingChartData } from "./streaming-chart-schema";
const chartConfig = {
value: {
label: "Value",
color: "var(--color-foreground)",
},
} satisfies ChartConfig;
function DottedBackgroundPattern() {
return (
<pattern
id="streaming-chart-dots"
x="0"
y="0"
width="10"
height="10"
patternUnits="userSpaceOnUse"
>
<circle
className="text-muted dark:text-muted/40"
cx="2"
cy="2"
r="1"
fill="currentColor"
/>
</pattern>
);
}
// Custom bar shape that renders skeleton for placeholder items
function CustomBarShape(props: unknown) {
const { x, y, width, height, payload } = props as {
x: number;
y: number;
width: number;
height: number;
payload: { isPlaceholder?: boolean };
};
if (payload?.isPlaceholder) {
// Skeleton bar uses the height calculated from lastValue
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
className="animate-pulse fill-muted"
rx={4}
ry={4}
/>
</g>
);
}
// Normal bar
return (
<rect
x={x}
y={y}
width={width}
height={height}
fill="var(--color-foreground)"
rx={4}
ry={4}
/>
);
}
interface StreamingChartProps {
data: DeepPartial<StreamingChartData> | undefined;
isLoading: boolean;
error?: Error;
}
export function StreamingChart({
data,
isLoading,
error,
}: StreamingChartProps) {
const isStreaming = isLoading && data !== undefined;
const isComplete = !isLoading && data !== undefined;
const currentState = isComplete
? "complete"
: isStreaming
? "streaming"
: isLoading
? "loading"
: "idle";
const borderColors = {
idle: "",
loading: "border-yellow-500/50",
streaming: "border-blue-500/50",
complete: "border-green-500/50",
};
// Get valid chart data items (filter out incomplete ones)
const rawChartData = data?.data as
| Array<{ label?: string; value?: number }>
| undefined;
const validChartData = React.useMemo(() => {
if (!rawChartData) return [];
return rawChartData.filter(
(item): item is { label: string; value: number } =>
item !== null &&
item !== undefined &&
typeof item.label === "string" &&
item.label !== "" &&
typeof item.value === "number",
);
}, [rawChartData]);
// Get last value for skeleton placeholder height
const lastValue = React.useMemo(() => {
if (validChartData.length === 0) return 50000;
return validChartData[validChartData.length - 1].value;
}, [validChartData]);
// Add a placeholder bar when loading to show "next bar loading"
// Also shows initial placeholder when no data yet
const chartDataWithPlaceholder = React.useMemo(() => {
if (!isLoading) return validChartData;
if (validChartData.length === 0) {
// Initial loading state - show single placeholder bar
return [{ label: "", value: 50000, isPlaceholder: true }];
}
return [
...validChartData,
{ label: "", value: lastValue, isPlaceholder: true },
];
}, [validChartData, isLoading, lastValue]);
const change = data?.change as number | undefined;
const isPositive = change !== undefined && change >= 0;
const displayValue = data?.value;
const unit = data?.unit ?? "";
const hasChartData = chartDataWithPlaceholder.length > 0;
return (
<Stream.Root data={data} isLoading={isLoading} error={error}>
<Card
className={cn(
"w-full max-w-md transition-colors",
borderColors[currentState],
)}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle>
<Stream.Field fallback={<Skeleton className="h-7 w-28" />}>
{displayValue !== undefined ? (
<motion.span
key={displayValue}
initial={{ opacity: 0, y: -8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
className="font-mono text-xl font-bold tabular-nums tracking-tight"
>
{unit}
{displayValue.toLocaleString()}
</motion.span>
) : undefined}
</Stream.Field>
</CardTitle>
<div className="flex flex-col items-end gap-1">
<Stream.Field
fallback={<Skeleton className="h-5 w-16 rounded-full" />}
>
{change !== undefined ? (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", bounce: 0.3 }}
>
<Badge
variant="outline"
className={cn(
"gap-1 border-none",
isPositive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
{isPositive ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
<span className="font-mono text-xs tabular-nums">
{isPositive && "+"}
{change.toFixed(1)}%
</span>
</Badge>
</motion.div>
) : undefined}
</Stream.Field>
<CardDescription className="min-h-5">
<Stream.Field fallback={<Skeleton className="h-4 w-28" />}>
{data?.changeLabel}
</Stream.Field>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{hasChartData ? (
<ChartContainer config={chartConfig} className="h-[200px] w-full">
<BarChart accessibilityLayer data={chartDataWithPlaceholder}>
<rect
x="0"
y="0"
width="100%"
height="85%"
fill="url(#streaming-chart-dots)"
/>
<defs>
<DottedBackgroundPattern />
</defs>
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
tick={(tickProps) => {
const { x, y, payload } = tickProps;
// Show skeleton for placeholder label
if (payload?.value === "") {
return (
<foreignObject x={x - 12} y={y} width={24} height={16}>
<div className="h-3 w-6 animate-pulse rounded bg-muted" />
</foreignObject>
);
}
return (
<text
x={x}
y={y}
dy={4}
textAnchor="middle"
fontSize={11}
fill="var(--color-muted-foreground)"
>
{payload?.value}
</text>
);
}}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar
dataKey="value"
radius={4}
shape={<CustomBarShape />}
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
) : (
<div className="flex h-[200px] items-center justify-center text-muted-foreground text-sm">
Click a button to generate chart data
</div>
)}
</CardContent>
</Card>
</Stream.Root>
);
}import { openai } from "@ai-sdk/openai";
import { streamObject } from "ai";
import { streamingChartSchema } from "@/components/ui/streaming-chart-schema";
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamObject({
model: openai("gpt-4o-mini"),
schema: streamingChartSchema,
system: `You are a data visualization generator. Generate realistic chart data based on the user's request.
Always include:
- title: A short descriptive title for the chart
- value: The current/highlighted value
- unit: The unit prefix (e.g., "$", "€", "" for counts)
- change: Percentage change vs previous period
- changeLabel: Context for the change
- data: An array of 8-12 data points with label and value`,
prompt: `Generate chart data for: ${prompt}`,
});
return result.toTextStreamResponse();
}API Reference
StreamingChart
| Prop | Type | Default |
|---|---|---|
data | DeepPartial<StreamingChartData> | undefined | — |
isLoading | boolean | — |
error | Error | undefined | — |
StreamingChartData Schema
| Field | Type |
|---|---|
title | string |
value | number |
unit | string |
change | number |
changeLabel | string |
data | { label: string; value: number }[] |