Skip to content
Star on GitHub
Sources and outputMetric insight
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Metric insight

A number, its trend, and the agent's read on why it moved. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add metric-insight
Trial to paid
0%+0.0 pts
Sep 18
Jumped after the pricing page update on Sep 18.
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add metric-insight

React

$npx designme add metric-insight --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Metric insight shows one number, such as a conversion rate, with its trend line, its change over the chosen range and a one-line note from the agent on what happened. A "Why did it move?" button opens a breakdown of the drivers behind the change.

  • When a dashboard should explain a number as well as show it.
  • When the agent has found a likely cause, like a release, and you want it marked on the chart.
  • When people switch between 7, 30 and 90 day views and expect the story to change with the range.
  • When the agent needs a moment to work out the drivers after someone asks.
03

Usage

Import it and drive it from your own state.

Import

import { MetricInsight } from '@/components/designme/metric-insight/MetricInsight';

Example

The demo above as a React component, 34 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: switch the range, hover the line, ask why it moved.
import { useEffect, useRef, useState } from 'react';
import { MetricInsight, type MetricDriver, type MetricRange } from './MetricInsight';

const RANGES: MetricRange[] = [
  { key: '7', label: '7d', points: [22.8, 23.1, 22.9, 24.6, 25.4, 25.9, 26.3], value: 26.3, change: 3.5, insight: 'Up all week. Most of the gain came from annual plans.' },
  { key: '30', label: '30d', points: [18.2, 18.5, 18.1, 18.9, 18.4, 18.7, 19.0, 18.6, 19.2, 19.1, 18.8, 19.4, 19.0, 19.3, 19.1, 19.6, 19.2, 21.8, 22.9, 23.4, 23.1, 24.0, 23.6, 24.4, 24.1, 24.9, 25.2, 25.6, 25.9, 26.3],
    value: 26.3, change: 8.1, insight: 'Jumped after the pricing page update on Sep 18.', marker: { at: .58, label: 'Pricing page update shipped' } },
  { key: '90', label: '90d', points: [17.1, 17.6, 17.4, 18.0, 17.8, 18.3, 18.1, 18.6, 18.2, 18.9, 19.4, 22.1, 24.0, 25.5, 26.3], value: 26.3, change: 9.2, insight: 'Flat for two months, then a clear step up in September.' }
];
const DRIVERS: MetricDriver[] = [
  { label: 'Annual plan shown first', percent: 54, color: 'var(--dm-accent)' }, { label: 'One CTA per card', percent: 27, color: 'var(--dm-pl)' },
  { label: 'Seasonal traffic', percent: 12, color: 'var(--dm-pt)' }, { label: 'Unexplained', percent: 7, color: 'var(--dm-faint)' }
];

export default function MetricInsightDemo() {
  const [open, setOpen] = useState(false);
  const [explaining, setExplaining] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  // asking takes a moment, the way an agent would look into it
  const toggle = (next: boolean) => {
    if (!next) { setOpen(false); return; }
    setExplaining(true);
    timer.current = setTimeout(() => { setExplaining(false); setOpen(true); }, 900);
  };
  return (
    <div style={{ width: '100%' }}>
      <MetricInsight label="Trial to paid" ranges={RANGES} defaultRange="30" drivers={DRIVERS} explaining={explaining} open={open} onOpenChange={toggle} />
    </div>
  );
}
04

Props

Everything MetricInsight takes.

PropTypeWhat it does
labelrequiredstringThe metric's name, like "Trial to paid"
rangesrequiredMetricRange[]
rangestringThe range on show, by key. Leave it out to let the switch pick it
defaultRangestring
onRangeChange(key: string) => void
format(value: number) => stringFormats the large number and the hovered point. Defaults to one decimal and a %
formatChange(change: number) => stringFormats the change. Defaults to "+8.1 pts"
driversMetricDriver[]What moved the number, shown under "Why did it move?"
explainingbooleanShows "Looking into it" on the button and keeps the breakdown shut, while you fetch the drivers
openbooleanWhether the breakdown is open. Leave it out to let the button toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring
05

Code

The full source, as the CLI installs it.

MetricInsight.tsxReact · 161 lines
'use client';
import { useRef, useState, type PointerEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Seg, Swap, Think, Tip } from '../lib/motion';
import '../designme.css';
import './metric-insight.css';

export interface MetricRange {
  /** Identifies the range, like '30' */
  key: string;
  /** The switch label, like "30d" */
  label: string;
  /** The values over the range, oldest first */
  points: number[];
  /** The number in large type, which rolls to it */
  value: number;
  /** The change over the range, in the tag beside it */
  change: number;
  /** One line on what happened, like "Jumped after the pricing page update on Sep 18." */
  insight: string;
  /** A marker on the chart, like a release: `at` is 0 to 1 across, `label` shows on hover */
  marker?: { at: number; label: string };
}

export interface MetricDriver {
  label: string;
  /** Its share of the change, 0 to 100 */
  percent: number;
  /** Any CSS colour, like 'var(--dm-accent)'. Defaults to the accent */
  color?: string;
}

export interface MetricInsightProps {
  /** The metric's name, like "Trial to paid" */
  label: string;
  ranges: MetricRange[];
  /** The range on show, by key. Leave it out to let the switch pick it */
  range?: string;
  defaultRange?: string;
  onRangeChange?: (key: string) => void;
  /** Formats the large number and the hovered point. Defaults to one decimal and a % */
  format?: (value: number) => string;
  /** Formats the change. Defaults to "+8.1 pts" */
  formatChange?: (change: number) => string;
  /** What moved the number, shown under "Why did it move?" */
  drivers?: MetricDriver[];
  /** Shows "Looking into it" on the button and keeps the breakdown shut, while you fetch the drivers */
  explaining?: boolean;
  /** Whether the breakdown is open. Leave it out to let the button toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

const pct = (v: number) => v.toFixed(1) + '%';
const pts = (v: number) => (v < 0 ? '−' : '+') + Math.abs(v).toFixed(1) + ' pts';
const W = 300, H = 90;

/** A metric with its trend, a one-line explanation, and a breakdown of what moved it. */
export function MetricInsight({ label, ranges, range, defaultRange, onRangeChange, format = pct, formatChange = pts, drivers, explaining = false, open, defaultOpen = false, onOpenChange, className }: MetricInsightProps) {
  const [key, setKey] = useControllable(range, defaultRange ?? ranges[0]?.key ?? '', onRangeChange);
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const r = ranges.find(x => x.key === key) ?? ranges[0];
  const value = useTween(r?.value ?? 0, 900, 0), change = useTween(r?.change ?? 0, 900, 0);
  const [hover, setHover] = useState<{ x: number; y: number; px: number; py: number; v: number } | null>(null);

  // the chart, scaled to 300 by 90 with a point of room above and below
  const d = r?.points ?? [];
  const lo = Math.min(...d) - 1, hi = Math.max(...d) + 1;
  const xy = d.map((v, i) => [d.length > 1 ? i / (d.length - 1) * W : 0, 86 - (v - lo) / (hi - lo) * 80, v] as const);
  const path = xy.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ' ' + q[1].toFixed(1)).join(' ');

  // the area and the marker fade in once the metric appears; a range change redraws the line in place
  const chart = useRef<HTMLDivElement>(null);
  useIsoLayoutEffect(() => {
    const el = chart.current; if (!el) return;
    void el.offsetWidth;
    const id = requestAnimationFrame(() => el.classList.add('dm-in'));
    return () => cancelAnimationFrame(id);
  }, []);

  const move = (e: PointerEvent<HTMLDivElement>) => {
    if (!xy.length) return;
    const b = e.currentTarget.getBoundingClientRect(), fx = (e.clientX - b.left) / b.width * W;
    let best = xy[0]; xy.forEach(q => { if (Math.abs(q[0] - fx) < Math.abs(best[0] - fx)) best = q; });
    setHover({ x: best[0], y: best[1], px: best[0] / W * b.width, py: best[1] / H * b.height, v: best[2] });
  };

  // the button changes at once when it starts looking, then crossfades to its next label
  const [looks, setLooks] = useState(0);
  const [wasBusy, setWasBusy] = useState(explaining);
  if (explaining !== wasBusy) { setWasBusy(explaining); if (explaining) setLooks(n => n + 1); }
  const shown = isOpen && !explaining && !!drivers?.length;
  const [opened, setOpened] = useState(0);
  const [wasShown, setWasShown] = useState(shown);
  if (shown !== wasShown) { setWasShown(shown); if (shown) setOpened(n => n + 1); }

  return (
    <div data-dm="" className={cx('dm-mix dm-panel', className)}>
      <div className="dm-mix-h">
        <span className="dm-muted">{label}</span>
        <Seg className="dm-mix-rg" label="Range" value={key} onChange={setKey} options={ranges.map(x => ({ value: x.key, label: x.label }))} />
      </div>
      <div className="dm-mix-v">
        <b className="dm-num dm-mix-n">{format(hover ? hover.v : value)}</b>
        <span className={cx('dm-tag dm-mix-d dm-num', (r?.change ?? 0) < 0 ? 'dm-bad' : 'dm-ok')}>{formatChange(change)}</span>
      </div>
      <div className="dm-mix-ch" ref={chart} onPointerMove={move} onPointerLeave={() => setHover(null)}>
        <svg className="dm-mix-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
          <defs><linearGradient id="dm-mixg" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stopColor="var(--dm-accent)" stopOpacity=".18" /><stop offset="1" stopColor="var(--dm-accent)" stopOpacity="0" /></linearGradient></defs>
          <path className="dm-mix-a" fill="url(#dm-mixg)" d={path && path + ` L${W} ${H} L0 ${H} Z`} />
          <path className="dm-mix-l" fill="none" stroke="var(--dm-accent)" strokeWidth={2} vectorEffect="non-scaling-stroke" strokeLinejoin="round" d={path} />
          <line className="dm-mix-x" x1={hover?.x ?? 0} x2={hover?.x ?? 0} y1={0} y2={H} stroke="var(--dm-faint)" strokeDasharray="2 3" vectorEffect="non-scaling-stroke" />
        </svg>
        <span className="dm-mix-dot" style={hover ? { left: hover.px, top: hover.py } : undefined} />
        {r?.marker && <Marker key={r.key} at={r.marker.at} label={r.marker.label} />}
      </div>
      <div className="dm-mix-ins"><span className="dm-mix-ic"><Icon name="spark" size="xs" /></span><Swap className="dm-mix-t">{r?.insight ?? ''}</Swap></div>
      {drivers && (
        <button type="button" className="dm-pill dm-soft dm-sm dm-mix-why" aria-expanded={shown} onClick={() => { if (!explaining) setOpen(!isOpen); }}>
          {explaining && <Think />}
          <Swap key={looks}>{explaining ? 'Looking into it' : shown ? 'Hide the breakdown' : 'Why did it move?'}</Swap>
        </button>
      )}
      {drivers && (
        <Fold open={shown} className="dm-mix-box">
          <div className="dm-mix-rs" key={opened}>
            {drivers.map((q, i) => {
              const e = enter(i * 60), c = q.color ?? 'var(--dm-accent)';
              return (
                <div key={q.label + i} className={cx('dm-mix-r', e.className)} style={e.style}>
                  <span className="dm-dot" style={{ color: c }} />
                  <span className="dm-t">{q.label}</span>
                  <span className="dm-bar"><i style={{ width: q.percent + '%', background: c }} /></span>
                  <span className="dm-v dm-num">{q.percent}%</span>
                </div>
              );
            })}
          </div>
        </Fold>
      )}
    </div>
  );
}

/** The marker on the chart. It fades in late, after the line, each time a range that has one comes up */
function Marker({ at, label }: { at: number; label: string }) {
  const ref = useRef<HTMLSpanElement>(null);
  // it renders hidden, then lets the stylesheet take over a frame later, so the delayed fade-in runs
  useIsoLayoutEffect(() => {
    const el = ref.current; if (!el) return;
    void el.offsetWidth;
    const id = requestAnimationFrame(() => { el.style.opacity = ''; el.style.transform = ''; });
    return () => cancelAnimationFrame(id);
  }, []);
  return <Tip label={label}><span ref={ref} className="dm-mix-mk" style={{ left: at * 100 + '%', opacity: 0, transform: 'scale(.5)' }}><Icon name="spark" size="xs" /></span></Tip>;
}
metric-insight.cssReact · 37 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Metric insight ---------- */
.dm-mix{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px}
.dm-mix-h{display:flex;align-items:center;justify-content:space-between;font-size:12.5px}
.dm-mix-rg button{font-size:11.5px;padding:0 var(--dm-s-2);height:24px}
.dm-mix-v{display:flex;align-items:baseline;gap:var(--dm-s-2)}
.dm-mix-v b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.dm-mix-ch{position:relative;height:90px;cursor:crosshair}
.dm-mix-svg{width:100%;height:100%;display:block;overflow:visible}
.dm-mix-l{stroke-dasharray:var(--dm-len);stroke-dashoffset:var(--dm-len);transition:stroke-dashoffset 1.2s var(--dm-ease)}
.dm-mix-ch.dm-in .dm-mix-l{stroke-dashoffset:0}
.dm-mix-a{opacity:0;transition:opacity .8s .5s}
.dm-mix-ch.dm-in .dm-mix-a{opacity:1}
.dm-mix-x{opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.dm-mix-ch:hover .dm-mix-x,.dm-mix-ch:hover .dm-mix-dot{opacity:1}}
.dm-mix-dot{position:absolute;width:9px;height:9px;margin:-4.5px;border-radius:50%;background:var(--dm-surface);box-shadow:0 0 0 2px var(--dm-accent-mark,var(--dm-accent));opacity:0;transition:opacity .2s;pointer-events:none}
/* the change it explains is marked where it happened: a small date label at the top of a dashed guide down through the chart */
.dm-mix-mk{position:absolute;top:-14px;bottom:0;display:flex;flex-direction:column;align-items:center;transform:translateX(-50%);opacity:0;transition:opacity .4s 1.1s;pointer-events:none}
.dm-mix-mk b{pointer-events:auto;display:inline-flex;align-items:center;gap:var(--dm-s-1);height:20px;padding:0 7px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:11.5px;font-weight:500;color:var(--dm-fg2);white-space:nowrap}
.dm-mix-mk b svg{width:10px;height:10px;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-mix-mk::after{content:"";flex:1;border-left:1px dashed color-mix(in srgb,var(--dm-faint) 50%,transparent)}
.dm-mix-ch.dm-in .dm-mix-mk{opacity:1}
.dm-mix-ins{display:flex;gap:var(--dm-s-2);align-items:flex-start;font-size:13px;line-height:1.5;color:var(--dm-fg2)}
.dm-mix-ic{color:var(--dm-accent-fg,var(--dm-accent));display:grid;margin-top:2px}
.dm-mix-why{align-self:flex-start}
/* a lighter button than the soft pill, like the other quiet asks */
.dm-pill.dm-soft.dm-mix-why{background:var(--dm-chip);box-shadow:none}
.dm-pill.dm-soft.dm-mix-why[aria-expanded="true"]{background:var(--dm-hover)}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-soft.dm-mix-why:hover{background:var(--dm-hover)}}
.dm-mix-rs{display:flex;flex-direction:column;gap:6px;padding-top:2px}
.dm-mix-r{display:flex;align-items:center;gap:10px;font-size:12.5px}
.dm-mix-r .dm-t{flex:1;min-width:0}
.dm-mix-r .dm-bar{width:70px;height:5px;border-radius:3px;background:var(--dm-line);overflow:hidden;flex:none}
.dm-mix-r .dm-bar i{display:block;height:100%;border-radius:3px;transform-origin:left;animation:dm-ssrel .8s var(--dm-ease) both}
.dm-mix-r .dm-v{width:44px;text-align:right;font-size:12px;color:var(--dm-fg2)}
@keyframes dm-ssrel{from{transform:scaleX(0)}}
metric-insight.htmlHTML · 13 lines
<div class="card" data-c="metric-insight">
  <div class="stage-in">
    <div class="mix panel">
      <div class="mix-h"><span class="muted">Trial to paid</span><div class="seg2 mix-rg" role="group" aria-label="Range"><button aria-pressed="false" data-v="7">7d</button><button aria-pressed="true" data-v="30">30d</button><button aria-pressed="false" data-v="90">90d</button></div></div>
      <div class="mix-v"><b class="num mix-n">0%</b><span class="tag ok mix-d num">+0.0 pts</span></div>
      <div class="mix-ch"><svg class="mix-svg" viewBox="0 0 300 90" preserveAspectRatio="none"><defs><linearGradient id="mixg" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="var(--accent-mark,var(--accent))" stop-opacity=".18"/><stop offset="1" stop-color="var(--accent-mark,var(--accent))" stop-opacity="0"/></linearGradient></defs><path class="mix-a" fill="url(#mixg)"/><path class="mix-l" fill="none" stroke="var(--accent-mark,var(--accent))" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/><line class="mix-x" y1="0" y2="90" stroke="var(--faint)" stroke-dasharray="2 3" vector-effect="non-scaling-stroke"/></svg><span class="mix-dot"></span><span class="mix-mk" style="left:58%"><b><svg class="i xs"><use href="#i-spark"/></svg>Sep 18</b></span></div>
      <div class="mix-ins"><span class="mix-ic"><svg class="i xs"><use href="#i-spark"/></svg></span><span class="mix-t">Jumped after the pricing page update on Sep 18.</span></div>
      <button class="pill soft sm mix-why">Why did it move?</button>
      <div class="fold shut mix-box"><div><div class="mix-rs"></div></div></div>
    </div>
  </div>
</div>
metric-insight.cssHTML · 36 lines
/* ---------- Metric insight ---------- */
.mix{padding:var(--s-4);display:flex;flex-direction:column;gap:10px}
.mix-h{display:flex;align-items:center;justify-content:space-between;font-size:12.5px}
.mix-rg button{font-size:11.5px;padding:0 var(--s-2);height:24px}
.mix-v{display:flex;align-items:baseline;gap:var(--s-2)}
.mix-v b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.mix-ch{position:relative;height:90px;cursor:crosshair}
.mix-svg{width:100%;height:100%;display:block;overflow:visible}
.mix-l{stroke-dasharray:var(--len);stroke-dashoffset:var(--len);transition:stroke-dashoffset 1.2s var(--ease)}
.mix-ch.in .mix-l{stroke-dashoffset:0}
.mix-a{opacity:0;transition:opacity .8s .5s}
.mix-ch.in .mix-a{opacity:1}
.mix-x{opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.mix-ch:hover .mix-x,.mix-ch:hover .mix-dot{opacity:1}}
.mix-dot{position:absolute;width:9px;height:9px;margin:-4.5px;border-radius:50%;background:var(--surface);box-shadow:0 0 0 2px var(--accent-mark,var(--accent));opacity:0;transition:opacity .2s;pointer-events:none}
/* the change it explains is marked where it happened: a small date label at the top of a dashed guide down through the chart */
.mix-mk{position:absolute;top:-14px;bottom:0;display:flex;flex-direction:column;align-items:center;transform:translateX(-50%);opacity:0;transition:opacity .4s 1.1s;pointer-events:none}
.mix-mk b{pointer-events:auto;display:inline-flex;align-items:center;gap:var(--s-1);height:20px;padding:0 7px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:11.5px;font-weight:500;color:var(--fg2);white-space:nowrap}
.mix-mk b svg{width:10px;height:10px;color:var(--accent-fg,var(--accent))}
.mix-mk::after{content:"";flex:1;border-left:1px dashed color-mix(in srgb,var(--faint) 50%,transparent)}
.mix-ch.in .mix-mk{opacity:1}
.mix-ins{display:flex;gap:var(--s-2);align-items:flex-start;font-size:13px;line-height:1.5;color:var(--fg2)}
.mix-ic{color:var(--accent-fg,var(--accent));display:grid;margin-top:2px}
.mix-why{align-self:flex-start}
/* a lighter button than the soft pill, like the other quiet asks */
.pill.soft.mix-why{background:var(--chip);box-shadow:none}
.pill.soft.mix-why[aria-expanded="true"]{background:var(--hover)}
@media (hover:hover) and (pointer:fine){.pill.soft.mix-why:hover{background:var(--hover)}}
.mix-rs{display:flex;flex-direction:column;gap:6px;padding-top:2px}
.mix-r{display:flex;align-items:center;gap:10px;font-size:12.5px}
.mix-r .t{flex:1;min-width:0}
.mix-r .bar{width:70px;height:5px;border-radius:3px;background:var(--line);overflow:hidden;flex:none}
.mix-r .bar i{display:block;height:100%;border-radius:3px;transform-origin:left;animation:ssrel .8s var(--ease) both}
.mix-r .v{width:44px;text-align:right;font-size:12px;color:var(--fg2)}
@keyframes ssrel{from{transform:scaleX(0)}}
metric-insight.jsHTML · 42 lines
DM.def('metric-insight', (r, c) => {
  r.innerHTML = r.innerHTML.replace(/\{spark\}/g, I('spark','xs'));
  const D = {
    7:{ d:[22.8,23.1,22.9,24.6,25.4,25.9,26.3], v:26.3, ch:3.5, t:'Up all week. Most of the gain came from annual plans.' },
    30:{ d:[18.2,18.5,18.1,18.9,18.4,18.7,19.0,18.6,19.2,19.1,18.8,19.4,19.0,19.3,19.1,19.6,19.2,21.8,22.9,23.4,23.1,24.0,23.6,24.4,24.1,24.9,25.2,25.6,25.9,26.3], v:26.3, ch:8.1, t:'Jumped after the pricing page update on Sep 18.' },
    90:{ d:[17.1,17.6,17.4,18.0,17.8,18.3,18.1,18.6,18.2,18.9,19.4,22.1,24.0,25.5,26.3], v:26.3, ch:9.2, t:'Flat for two months, then a clear step up in September.' }
  };
  const ch = $('.mix-ch', r), l = $('.mix-l', r), a = $('.mix-a', r), x = $('.mix-x', r), dot = $('.mix-dot', r), n = $('.mix-n', r), dl = $('.mix-d', r), mk = $('.mix-mk', r);
  let pts = [], cur = 30, val = 0, chg = 0;
  function draw(k){
    const d = D[k].d, lo = Math.min(...d) - 1, hi = Math.max(...d) + 1;
    pts = d.map((v, i) => [i / (d.length - 1) * 300, 86 - (v - lo) / (hi - lo) * 80, v]);
    const p = pts.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ' ' + q[1].toFixed(1)).join(' ');
    l.setAttribute('d', p); a.setAttribute('d', p + ' L300 90 L0 90 Z');
    // reset to the hidden state without a transition (otherwise it reverses in the same frame and never draws), then draw in
    // the stroke doesn't scale with the stretched chart, so its dashes are measured on screen: use the line's on-screen length
    const sx = ch.clientWidth / 300, sy = ch.clientHeight / 90, len = pts.reduce((t, q, i) => i ? t + Math.hypot((q[0] - pts[i - 1][0]) * sx, (q[1] - pts[i - 1][1]) * sy) : 0, 0);
    [l, a, mk].forEach(e => e.style.transition = 'none'); ch.classList.remove('in'); l.style.strokeDasharray = ''; l.style.setProperty('--len', Math.ceil(len) + 2);
    void ch.offsetWidth; [l, a, mk].forEach(e => e.style.transition = ''); requestAnimationFrame(() => ch.classList.add('in'));
    mk.style.display = k == 30 ? '' : 'none';
    tween(val, D[k].v, 900, v => { val = v; n.textContent = v.toFixed(1) + '%'; });
    tween(chg, D[k].ch, 900, v => { chg = v; dl.textContent = '+' + v.toFixed(1) + ' pts'; });
    swap($('.mix-t', r), D[k].t);
  }
  c.on(ch, 'pointermove', e => { const b = ch.getBoundingClientRect(), fx = (e.clientX - b.left) / b.width * 300; let best = pts[0]; pts.forEach(q => { if (Math.abs(q[0] - fx) < Math.abs(best[0] - fx)) best = q; });
    x.setAttribute('x1', best[0]); x.setAttribute('x2', best[0]); dot.style.left = best[0] / 300 * b.width + 'px'; dot.style.top = best[1] / 90 * b.height + 'px'; n.textContent = best[2].toFixed(1) + '%'; });
  c.on(ch, 'pointerleave', () => n.textContent = D[cur].v.toFixed(1) + '%');
  // once drawn, drop the dashes so a later resize can't cut the line short
  c.on(l, 'transitionend', e => { if (e.propertyName === 'stroke-dashoffset' && ch.classList.contains('in')) l.style.strokeDasharray = 'none'; });
  seg($('.mix-rg', r), v => { cur = +v; draw(cur); });
  $('b', mk).dataset.tip = 'Pricing page update shipped';
  const why = $('.mix-why', r), box = $('.mix-box', r), rs = $('.mix-rs', r);
  why.onclick = () => c.run(async () => {
    if (!box.classList.contains('shut')){ box.classList.add('shut'); why.setAttribute('aria-expanded', 'false'); swap(why, 'Why did it move?'); return; }
    why.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Looking into it'; await c.wait(900);
    const R = [['Annual plan shown first', 54, 'var(--accent-mark,var(--accent))'], ['One CTA per card', 27, 'var(--pl)'], ['Seasonal traffic', 12, 'var(--pt)'], ['Unexplained', 7, 'var(--faint)']];
    rs.innerHTML = R.map(q => `<div class="mix-r"><span class="dot" style="color:${q[2]}"></span><span class="t">${q[0]}</span><span class="bar"><i style="width:${q[1]}%;background:${q[2]}"></i></span><span class="v num">${q[1]}%</span></div>`).join('');
    box.classList.remove('shut'); why.setAttribute('aria-expanded', 'true'); stagger($$('.mix-r', rs), 60); swap(why, 'Hide the breakdown');
  });
  c.later(400, () => draw(30));
});
06

Questions

What people ask about it.

Can I show something other than a percentage?
Yes. Pass format for the large number and hovered points, and formatChange for the tag. The defaults are one decimal with a % and a change like "+8.1 pts".
How do I fetch the drivers only when someone asks?
Control open and handle onOpenChange. When it asks to open, set explaining to true, load the drivers, then set explaining to false and open to true. The demo does this.
How do I mark a release on the chart?
Add marker: { at, label } to a range. at is a position from 0 to 1 across the chart, and label shows when the marker is hovered.
07

More in Sources and output

What the agent read, what it produced and where each answer came from.

Code viewerCode the agent wrote, with line notes you can ask about.
Terminal streamShell commands the agent runs, with their output as it streams in.
Citation hover cardHover a citation to see the exact passage it came from.
Source stackEverything the agent read, folded into one quiet pill.
Field extractionThe agent pulls fields out of a document and shows where each came from.
Details panelProperties of a run you can change in place.
Task tableAgent tasks in a sortable table with bulk actions.