Analytics Assistant That Answers with Animated Charts

An ask-your-data panel that plays itself. A question is typed into a dark bar, placeholder bars breathe while the query runs, then a real chart springs up: bars with labels that count to their value, or a line that draws left to right with a pulsing end point. Beside it the written answer streams in word by word with the figures picked out, followed by three stat tiles and the source. Three canned questions rotate, and each chip jumps to one. On phones the answer sits under the chart.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

type Question = {
  ask: string;
  chip: string;
  chart: "bars" | "line";
  title: string;
  badge: string;
  points: { label: string; value: number }[];
  ticks: number[];
  /** Only these point labels are printed under a line chart. */
  marks?: string[];
  answer: string;
  stats: { label: string; value: string }[];
  source: string;
};

/** Canned questions. The demo cycles through them, and each chip jumps straight to one. */
const questions: Question[] = [
  {
    ask: "Which channel drove the most signups last month?",
    chip: "Signups by channel",
    chart: "bars",
    title: "Signups by channel, June",
    badge: "+38% vs May",
    points: [
      { label: "Organic", value: 1840 },
      { label: "Referral", value: 1210 },
      { label: "Email", value: 960 },
      { label: "Paid", value: 720 },
      { label: "Partner", value: 410 },
    ],
    ticks: [0, 500, 1000, 1500, 2000],
    answer: "Organic search led with 1,840 signups, 36% of the total and up 38% on May. Referrals came second. Paid social cost the most per signup, so that is the first place to trim.",
    stats: [{ label: "Total", value: "5,140" }, { label: "Top share", value: "36%" }, { label: "Channels", value: "5" }],
    source: "signups table, 5,140 rows, 0.8s",
  },
  {
    ask: "How did weekly signups trend this quarter?",
    chip: "Weekly trend",
    chart: "line",
    title: "Weekly signups, Q2",
    badge: "+119% in 12 weeks",
    points: [310, 340, 330, 390, 420, 405, 470, 520, 500, 560, 610, 680].map((value, i) => ({ label: `W${i + 1}`, value })),
    ticks: [0, 200, 400, 600, 800],
    marks: ["W1", "W4", "W8", "W12"],
    answer: "Weekly signups more than doubled, from 310 to 680. The climb is steady with two small dips, in weeks 3 and 9, both right after a pricing email. Week 12 is the best week on record.",
    stats: [{ label: "Week 1", value: "310" }, { label: "Week 12", value: "680" }, { label: "Dips", value: "2" }],
    source: "signups table, 12 weeks, 0.6s",
  },
  {
    ask: "Which plan do new accounts pick first?",
    chip: "Plan mix",
    chart: "bars",
    title: "First plan chosen, June",
    badge: "64% start free",
    points: [
      { label: "Free", value: 3290 },
      { label: "Starter", value: 1130 },
      { label: "Team", value: 560 },
      { label: "Scale", value: 160 },
    ],
    ticks: [0, 1000, 2000, 3000, 4000],
    answer: "Most new accounts start on Free: 3,290 of 5,140, or 64%. Starter takes 22% and Team 11%. Scale is small at 160 accounts, but each one is worth the most, so a 1% shift there matters.",
    stats: [{ label: "Free", value: "64%" }, { label: "Paid", value: "36%" }, { label: "Plans", value: "4" }],
    source: "accounts table, 5,140 rows, 0.7s",
  },
];

// Timeline in milliseconds.
const TYPE_MS = 14; // per typed character
const CHART_AT = 1000; // the chart draws
const ANSWER_AT = 1400; // the written answer starts
const WORD_MS = 38; // per word of the answer
const END = 3000;
const HOLD = 4200; // pause before the next question
const TICK = 40;

/** Clock that loops and calls `onLoop` each time it wraps round. */
function useClock(playing: boolean, run: number, still: boolean | null, onLoop: () => void) {
  const [now, setNow] = useState(END);
  const t = useRef(END);
  const loop = useRef(onLoop);
  loop.current = onLoop;
  useEffect(() => {
    if (still) return;
    t.current = 0;
    setNow(0);
  }, [run, still]);
  useEffect(() => {
    if (!playing || still) return;
    const id = window.setInterval(() => {
      if (document.hidden) return;
      t.current += TICK;
      if (t.current > END + HOLD) {
        t.current = 0;
        loop.current();
      }
      setNow(Math.min(t.current, END));
    }, TICK);
    return () => window.clearInterval(id);
  }, [playing, still]);
  return now;
}

const fmt = (n: number) => Math.round(n).toLocaleString("en-US");
const ease = (p: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, p)), 3);

export default function AnalyticsAssistantThatAnswersWithAnimatedCharts() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.2 });
  const [q, setQ] = useState(0);
  const [run, setRun] = useState(0);
  // Entrance animations only play once the page is live, so the server-rendered HTML shows the finished answer.
  const [live, setLive] = useState(false);
  useEffect(() => setLive(true), []);
  const now = useClock(inView, run, reduce, () => setQ((i) => (i + 1) % questions.length));
  const enter = live && !reduce;

  const item = questions[q];
  const typed = Math.min(item.ask.length, Math.floor(now / TYPE_MS));
  const asked = typed >= item.ask.length;
  const charted = now >= CHART_AT;
  const words = item.answer.split(" ");
  const said = now < ANSWER_AT ? 0 : Math.min(words.length, Math.floor((now - ANSWER_AT) / WORD_MS) + 1);
  const done = now >= END;
  const max = item.ticks[item.ticks.length - 1];
  const top = item.points.reduce((best, p, i) => (p.value > item.points[best].value ? i : best), 0);

  const jump = (i: number) => {
    setQ(i);
    setRun((r) => r + 1);
  };

  const line = item.points.map((p, i) => `${(i / (item.points.length - 1)) * 100} ${100 - (p.value / max) * 100}`).join("L");

  return (
    <section className="relative overflow-hidden bg-[#eef5f0] px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes analytics-assistant-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
        @keyframes analytics-assistant-spin { to { transform: rotate(360deg); } }
        @keyframes analytics-assistant-wait { 0%, 100% { opacity: 0.45; transform: scaleY(0.82); } 50% { opacity: 1; transform: scaleY(1); } }
        @keyframes analytics-assistant-ping { 0% { transform: scale(1); opacity: 0.7; } 100% { transform: scale(3.2); opacity: 0; } }
        .analytics-assistant-caret { animation: analytics-assistant-caret 0.9s step-end infinite; }
        .analytics-assistant-spin { animation: analytics-assistant-spin 0.7s linear infinite; }
        .analytics-assistant-wait { animation: analytics-assistant-wait 0.9s ease-in-out infinite; transform-origin: bottom; }
        .analytics-assistant-ping { animation: analytics-assistant-ping 1.6s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .analytics-assistant-caret, .analytics-assistant-spin, .analytics-assistant-wait, .analytics-assistant-ping { animation: none; }
        }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute -right-40 -top-40 h-[520px] w-[520px] rounded-full bg-emerald-300/40 blur-[110px]" />
      <div aria-hidden="true" className="pointer-events-none absolute -bottom-52 -left-40 h-[520px] w-[520px] rounded-full bg-teal-200/60 blur-[110px]" />

      <div className="relative mx-auto max-w-5xl">
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="text-4xl font-semibold tracking-tight text-emerald-950 sm:text-5xl">Ask the numbers a question</h2>
          <p className="mt-4 text-lg leading-relaxed text-emerald-900/80">Type it the way you would say it. You get the chart, the answer in plain words and where the figures came from.</p>
        </div>

        <div ref={ref} className="mt-10 rounded-3xl bg-white p-3 shadow-2xl shadow-emerald-900/10 ring-1 ring-emerald-950/10 sm:p-5">
          {/* Ask bar */}
          <div className="flex min-h-[56px] items-center gap-3 rounded-2xl bg-emerald-950 px-4 py-2.5">
            <svg viewBox="0 0 20 20" className="h-5 w-5 shrink-0 text-emerald-300" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
              <path d="M4 16v-5M10 16V4M16 16V8" />
            </svg>
            <p className="min-w-0 flex-1 text-[15px] font-medium leading-snug text-white sm:text-base" aria-label={`Question: ${item.ask}`}>
              <span aria-hidden="true">
                {item.ask.slice(0, typed)}
                {!asked && <span className="analytics-assistant-caret ml-px inline-block h-[1.05em] w-0.5 translate-y-[0.18em] bg-emerald-300" />}
                <span className="opacity-0">{item.ask.slice(typed)}</span>
              </span>
            </p>
            <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-emerald-200" aria-hidden="true">
              {done ? (
                <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="m4.5 10.5 3.5 3.5 7.5-8" /></svg>
              ) : (
                <span className="analytics-assistant-spin h-4 w-4 rounded-full border-2 border-emerald-200/30 border-t-emerald-200" />
              )}
            </span>
          </div>

          <div className="mt-3 grid gap-3 sm:mt-5 sm:gap-5 lg:grid-cols-[1.3fr_1fr]">
            {/* Chart */}
            <div className="rounded-2xl p-3 ring-1 ring-slate-200 sm:p-5" role="img" aria-label={`${item.title}: ${item.points.map((p) => `${p.label} ${fmt(p.value)}`).join(", ")}`}>
              <div className="flex min-h-[28px] flex-wrap items-center justify-between gap-2">
                <p className={`text-sm font-semibold text-slate-900 transition-opacity duration-300 ${charted ? "opacity-100" : "opacity-0"}`}>{item.title}</p>
                <p className={`rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-800 transition-all duration-500 ${now >= CHART_AT + 500 ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"}`}>{item.badge}</p>
              </div>

              <div className="mt-4 flex gap-2">
                <div className="-my-1.5 flex h-48 w-8 shrink-0 flex-col-reverse justify-between text-right text-[10px] tabular-nums text-slate-500 sm:h-56" aria-hidden="true">
                  {item.ticks.map((t) => (
                    <span key={t} className={`leading-3 transition-opacity duration-300 ${charted ? "opacity-100" : "opacity-0"}`}>{t >= 1000 ? `${t / 1000}k` : t}</span>
                  ))}
                </div>
                <div className="relative h-48 min-w-0 flex-1 sm:h-56" aria-hidden="true">
                  {item.ticks.map((t, i) => (
                    <span key={t} className={`absolute inset-x-0 border-t ${i === 0 ? "border-slate-300" : "border-dashed border-slate-200"}`} style={{ bottom: `${(i / (item.ticks.length - 1)) * 100}%` }} />
                  ))}

                  {!charted && (
                    <div className="absolute inset-0 flex items-end gap-2 px-1 sm:gap-5 sm:px-4">
                      {[46, 72, 38, 60, 28].map((h, i) => (
                        <span key={i} className="analytics-assistant-wait flex-1 rounded-t-lg bg-slate-100" style={{ height: `${h}%`, animationDelay: `${i * 110}ms` }} />
                      ))}
                    </div>
                  )}

                  {charted && item.chart === "bars" && (
                    <div className="absolute inset-0 flex items-end gap-2 px-1 sm:gap-5 sm:px-4">
                      {item.points.map((p, i) => {
                        const h = (p.value / max) * 100;
                        const grown = ease((now - CHART_AT - i * 70) / 550);
                        return (
                          <div key={p.label} className="relative h-full flex-1">
                            <motion.div
                              initial={enter ? { scaleY: 0 } : false}
                              animate={{ scaleY: 1 }}
                              transition={{ type: "spring", stiffness: 170, damping: 17, delay: i * 0.07 }}
                              className={`absolute inset-x-0 bottom-0 origin-bottom rounded-t-lg ${i === top ? "bg-gradient-to-t from-emerald-600 to-emerald-400 shadow-lg shadow-emerald-500/30" : "bg-emerald-900/15"}`}
                              style={{ height: `${h}%` }}
                            />
                            <span className={`absolute inset-x-0 text-center text-[11px] font-semibold tabular-nums sm:text-xs ${i === top ? "text-emerald-700" : "text-slate-600"}`} style={{ bottom: `calc(${h * grown}% + 5px)`, opacity: Math.min(1, grown * 2) }}>
                              {fmt(p.value * grown)}
                            </span>
                          </div>
                        );
                      })}
                    </div>
                  )}

                  {charted && item.chart === "line" && (
                    <>
                      <motion.div
                        initial={enter ? { clipPath: "inset(0% 100% 0% 0%)" } : false}
                        animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
                        transition={{ duration: 0.95, ease: [0.3, 0, 0.2, 1] }}
                        className="absolute inset-0"
                      >
                        <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full overflow-visible">
                          <defs>
                            <linearGradient id="analytics-assistant-area" x1="0" y1="0" x2="0" y2="1">
                              <stop offset="0" stopColor="#10b981" stopOpacity="0.35" />
                              <stop offset="1" stopColor="#10b981" stopOpacity="0" />
                            </linearGradient>
                          </defs>
                          <path d={`M${line}L100 100L0 100Z`} fill="url(#analytics-assistant-area)" />
                          <path d={`M${line}`} fill="none" stroke="#059669" strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
                        </svg>
                      </motion.div>
                      <motion.span
                        initial={enter ? { opacity: 0, scale: 0 } : false}
                        animate={{ opacity: 1, scale: 1 }}
                        transition={{ type: "spring", stiffness: 400, damping: 16, delay: enter ? 0.85 : 0 }}
                        className="absolute right-0 -mb-1.5 -mr-1.5 h-3 w-3"
                        style={{ bottom: `${(item.points[item.points.length - 1].value / max) * 100}%` }}
                      >
                        <span className="analytics-assistant-ping absolute inset-0 rounded-full bg-emerald-500" />
                        <span className="absolute inset-0 rounded-full bg-emerald-600 ring-2 ring-white" />
                        <span className="absolute bottom-full right-0 mb-1.5 whitespace-nowrap rounded-md bg-emerald-950 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-white">{fmt(item.points[item.points.length - 1].value)}</span>
                      </motion.span>
                    </>
                  )}
                </div>
              </div>
              <div className={`ml-10 mt-2 flex text-[10px] font-medium text-slate-600 transition-opacity duration-300 sm:text-xs ${charted ? "opacity-100" : "opacity-0"} ${item.chart === "bars" ? "gap-2 px-1 sm:gap-5 sm:px-4" : "justify-between"}`} aria-hidden="true">
                {item.chart === "bars"
                  ? item.points.map((p) => <span key={p.label} className="min-w-0 flex-1 truncate text-center">{p.label}</span>)
                  : (item.marks ?? []).map((m) => <span key={m}>{m}</span>)}
              </div>
            </div>

            {/* Written answer */}
            <div className="flex flex-col rounded-2xl bg-emerald-50/70 p-4 sm:p-5">
              <p className="text-xs font-semibold uppercase tracking-wider text-emerald-800">Answer</p>
              <p className="mt-2 text-[15px] leading-relaxed text-slate-800 sm:text-base sm:leading-7" aria-label={item.answer}>
                <span aria-hidden="true">
                  {words.map((w, i) => (
                    <span key={i} className={`transition-opacity duration-300 motion-reduce:transition-none ${i < said ? "opacity-100" : "opacity-0"} ${/\d/.test(w) ? "font-semibold text-emerald-800" : ""}`}>{w} </span>
                  ))}
                </span>
              </p>
              <dl className="mt-4 grid grid-cols-3 gap-2">
                {item.stats.map((s, i) => (
                  <div key={s.label} className={`rounded-xl bg-white px-3 py-2.5 ring-1 ring-emerald-900/10 transition-all duration-500 motion-reduce:transition-none ${now >= END - 350 ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"}`} style={{ transitionDelay: now >= END - 350 ? `${i * 70}ms` : "0ms" }}>
                    <dt className="text-[11px] text-slate-600">{s.label}</dt>
                    <dd className="text-lg font-semibold tabular-nums text-slate-900">{s.value}</dd>
                  </div>
                ))}
              </dl>
              <p className="mt-auto pt-4 font-mono text-[11px] text-slate-600" aria-live="polite">
                {done ? `Source: ${item.source}` : charted ? "Writing the answer" : asked ? "Querying your data" : "Listening"}
              </p>
            </div>
          </div>

          {/* Other questions */}
          <div className="mt-3 flex flex-wrap items-center gap-2 sm:mt-5">
            <p className="mr-1 text-xs font-medium text-slate-600">Ask next</p>
            {questions.map((other, i) => (
              <button key={other.chip} type="button" aria-pressed={i === q} onClick={() => jump(i)} className={`h-10 rounded-full px-4 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 ${i === q ? "bg-emerald-950 text-white" : "bg-white text-slate-800 ring-1 ring-slate-300 hover:bg-emerald-50"}`}>
                {other.chip}
              </button>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/analytics-assistant-that-answers-with-animated-charts.json

When to use it

How to customize it

Accessibility

Questions

How are the charts drawn without a chart library?

Bars are plain `div` elements with a percentage height inside a fixed-height plot, scaled from zero with a spring on `scaleY`. The line chart is one SVG path in a 100 by 100 `viewBox` stretched to the plot, with `vector-effect: non-scaling-stroke` so the stroke stays even. Grid lines and axis labels are ordinary elements.

How does the line draw itself?

The SVG sits in a wrapper whose `clip-path` animates from `inset(0% 100% 0% 0%)` to `inset(0% 0% 0% 0%)`. That reveals the line and the gradient area under it together, left to right, and avoids dash tricks that misbehave when an SVG is stretched.

How do the bar labels count up?

Each label multiplies its value by an eased progress number taken from the same clock that runs the rest of the demo, and its `bottom` offset uses the same number, so the label rides on top of the bar as it grows.

Is the answer generated by an AI model?

No. The questions, numbers and answers are fixed demo content and nothing is sent anywhere. In a real product you would feed the same props from your own query and summary.

Can I add a third chart type?

Yes. Add a value to the `chart` type, then add another block next to the `bars` and `line` blocks inside the plot. The grid, axis labels, answer and stats are shared, so only the marks need drawing.

Related: Single Choice Poll with Animated Result Bars, Stat Tiles with Sparkline Trends, Agent Workflow Canvas with Nodes Lighting in Sequence.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap