S

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 motion
npx shadcn@latest add badge card chart skeleton
streaming-chart-schema.ts
import { 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>;
streaming-chart.tsx
"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>
  );
}
app/api/stream/chart/route.ts
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

PropTypeDefault
dataDeepPartial<StreamingChartData> | undefined
isLoadingboolean
errorError | undefined

StreamingChartData Schema

FieldType
titlestring
valuenumber
unitstring
changenumber
changeLabelstring
data{ label: string; value: number }[]

On this page