Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Insight cards

New

An agent’s findings, one card at a time, each backed by a live chart you can scrub.

insight-cards/insight-cards.tsx
Live · interactive

Insights

3

Insight 1 of 3

The worst performer in your @Creamery is Rocky Road — down -6% or -$2,453.44.

Mint Chip-4.41%-$2,377.66
Pistachio+1.15%+$617.22
Trend snapshotSnapshot

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • An agent reporting what it noticed in someone’s data: a drop, a spike, a lopsided split.
  • A handful of findings (two to six) read one at a time, each with the evidence beside the sentence.
  • Findings that lead somewhere: each card ends in a follow-up question the reader can ask with one press.
  • A side panel or a chat, where a 344 px column fits and a full dashboard would not.

When not to use

  • Monitoring many metrics at once. Use a dashboard where every chart stays in view.
  • A single number with no story. Use a stat tile or a metric picker.
  • Alerts that need action now: a pager hides all but one. Use a toast or a banner.
  • Exact figures to copy or compare row by row. Use a table.

You can add this component with the shadcn CLI or by hand:

Into components/insight-cards of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/insight-cards.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

agent-findings.tsx
import { InsightCards, type Insight } from './insight-cards';export function AgentFindings({ insights }: { insights: Insight[] }) {  return (    <InsightCards      insights={insights}      onFollowUp={(insight) => ask(insight.followUp)}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

What an agent noticed in a small shop’s numbers, paged one insight at a time. Each insight is a sentence, a card that backs it up and a follow-up question. There are three kinds of card: two flavours compared over a trend chart, a freezer bill spiking on a live chart with a time axis, and the shop’s stock split into segments you can inspect. The charts come in live: a grey line bends into the data. Scrub them with the pointer, a finger or the arrow keys.

  • The header counts the insights, and its arrows page through them and wrap around. Paging is instant. The new card mounts fresh, so its chart plays its entrance and its toggle and segment start over.
  • Each chart is a canvas drawn at the device’s pixel ratio (2 at most) and redrawn only while something moves. Each series is a monotone cubic (Fritsch–Carlson) through its readings, so the curve never overshoots a value. It fades in from transparent at the left edge to full at 40 px. A dashed line at 40% opacity marks the latest value, and a dot sits on it; a live chart’s dot also gets a halo.
  • Until a chart is first on screen it shows a grey, gently wavy placeholder line. Then the readings head for their values (time constant 95 ms) while the vertical range opens more slowly (140 ms) from a third of its height. The data is also seen through a Gaussian blur that narrows (85 ms), with the placeholder past both ends. So the line swells in the middle, a low series dips out of sight, the detail comes in last, and everything settles within 620 ms. The line takes its colour over 140 ms, and the dashes, the dot and the axis fade in with it.
  • Later changes of value, like the spike’s Spend/Usage toggle, bend the drawn line into the new one over the same 620 ms, on an ease-out quartic.
  • The live chart puts its newest reading at the moment the chart was made and steps back one interval (7 s) per reading. It ticks every 10 s (every 20 s when narrow), with the time under each tick fading out within 50 px of either edge. Dotted gridlines sit at round values, but none closer than 10 px to the latest value’s dashes.
  • Scrubbing snaps to the nearest reading. A 1 px cursor in the ink colour at 26% stands on it, and an HTML tooltip reads every visible series there, kept 16 px inside the chart. On the spike, the chart’s caption shows the scrubbed value in place of the threshold.
  • The compare chart’s legend shows or hides each series. The series fades over 200 ms, and its button dims to 40%.
  • The allocation bar is flex segments as wide as their shares. The inspected one goes fully opaque and gets a light inner ring (300 ms), and a 20% white shine grows across it (500 ms, cubic-bezier(0.16, 1, 0.3, 1)); the others rest at 58%. The figure and the note follow the selection, and the card itself never moves.
  • While on screen and untouched, a demo pointer plays with each card in turn. It sweeps the charts, switches Spend to Usage and back, and inspects the segments, then pages on. Any input of the reader’s hands it over.
  • Every colour is a light-dark() token on the root. The canvas reads its colours from hidden probe elements and reads them again whenever the colour scheme may have changed. The series’ line colours are the same in both schemes. The tooltip is dark in both.
PropTypeDefaultDescription
insightsInsight[]three sample insightsThe insights, in paging order. Each has a summary (text runs, @mentions, figures marked negative or positive, emphasis), a followUp question and a kind. A compare insight has series of { label, tone, values in %, amount }. A spike insight has units of { label, values, format, threshold }, plus interval, total, delta and versus. An allocation insight has segments of { label, short, share, value, tone } and a note.
indexnumber—The insight shown, to control the pager. Without it, the component keeps its own.
defaultIndexnumber0The insight shown first, when uncontrolled.
defaultUnitnumber—The spike card’s unit picked at first, as an index into its units. Setting it turns the demo off.
defaultSegmentnumber—The allocation card’s segment inspected at first. Setting it turns the demo off.
defaultScrubnumber—Holds the first card’s chart scrubbed at this share of its width, from 0 to 1. Setting it turns the demo off.
onIndexChange(index: number) => void—Called with the insight the pager goes to.
onFollowUp(insight: Insight) => void—Called when an insight’s follow-up question is pressed.
titlestring"Insights"The header’s title.
autoplaybooleantrueWhile on screen and untouched, a demo pointer pages through the insights and plays with each card. The charts’ entrance plays either way.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
classNamestring—Added to the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

insight-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';import { INSIGHTS, type Insight, type SummaryPart } from './data';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { TrendChart, TOKEN, type ChartHandle } from './trend-chart';import { useAutoplay } from './use-autoplay';import styles from './insight-cards.module.css';export type { CompareSeries, Insight, Segment, SpikeUnit, SummaryPart, Tone } from './data';export type { ChartSeries } from './trend-chart';/* * Insight cards: what an agent noticed, one insight at a time. A header counts them and pages * through them; each insight is a sentence, a card that backs it up, and a follow-up question. * * - The cards come in three kinds (data.ts). 'compare' sets two series side by side, their change *   in percent and in money, over a trend chart with a legend. 'spike' shows one series on a live *   chart with a time axis, in a unit one picks (spend or usage); scrubbing it reads the value in *   the chart's header. 'allocation' splits a whole into a bar of segments: pressing a segment, or *   its chip, inspects it, and the figure and the note follow. * - The charts (trend-chart.tsx) come in live: a grey placeholder line bends into the data as the
trend-chart.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useImperativeHandle,  useLayoutEffect,  useRef,  useState,  type KeyboardEvent,  type PointerEvent,  type Ref,} from 'react';import type { Tone } from './data';import styles from './insight-cards.module.css';/* * The insight cards' chart: one or more series drawn on a canvas, with a cursor and a tooltip in * HTML over it that one scrubs with the pointer, a finger or the arrow keys. * * - Two kinds. 'compare' draws series side by side on a shared scale, with a legend to show or *   hide each one. 'live' draws one series on a time axis, its newest reading now, and dotted
insight-cards.module.css
/* Insight cards (insight-cards.tsx, trend-chart.tsx): a soft grey stage, and in its middle a column   344 px wide at most: the header with its pager, then the insight's sentence, its card and its   follow-up question.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are   written once with every layer, the layers a scheme does not use being transparent. The charts'   canvas reads its colours from the probe at the end of this file. */.root {  /* The stage, and the ink in three strengths. */  --canvas: oklch(96.1% 0.002 247.84);  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);  --ink-3: oklch(69.5% 0.009 264.505);  /* Hairlines, a stronger one, and the charts' gridlines. */  --line: oklch(94.6% 0.003 264.542);  --line-strong: oklch(91.2% 0.005 258.326);  --grid: color-mix(in srgb, var(--line) 78%, transparent);  /* The card; the chart's well, sunk a little; badges and toggles; a control under the pointer. */  --surface: oklch(100% 0 0);  --inset: oklch(97.9% 0.002 247.839);  --field: oklch(96.1% 0.001 286.375);
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * What an insight card shows, and the three sample insights of a small ice-cream shop's agent. * * An insight is a sentence, a card and a follow-up question. The card is one of three kinds: * - 'compare': two series side by side, their change and amount, over a shared trend chart; * - 'spike': one series rising past its usual level, on a live chart with a time axis, read in *   one of a few units (spend, usage...); * - 'allocation': how one whole splits into parts, as a bar of segments one can inspect. *//** The colours a series, a mention or a segment can take. */export type Tone = 'orange' | 'blue' | 'green' | 'red' | 'neutral' | 'faint';/** A run of the sentence: plain text, an @mention, a figure (bad news or good), or emphasis. */export type SummaryPart =  | string  | { mention: string; tone?: Tone }  | { figure: string; sentiment: 'negative' | 'positive' }  | { strong: string };export type CompareSeries = {  /** The series' name, over its figures and in the chart's tooltip. */
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide * the pointer onto a control, press it and call the component's own handler, as a click would. * A sweep drags the pointer straight across a control at an even pace, reporting how far it has * gone each frame, to scrub a chart as a hand would. * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once: * its promise rejects, which ends the script right there, and the pointer fades out. * * The pointer is an HTML box around a still drawing: it glides by an animation the compositor * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a * layout. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The cards sit in a labelled section with a real heading. The count reads as “3 insights”, and a polite live region announces “Insight 2 of 3” when the page changes.
  • The pager arrows, the legend, the unit toggle, the segments, their chips and the follow-up are real buttons. The toggles, segments and chips report their state with aria-pressed.
  • Each chart is a focusable slider. The arrow keys, Page Up and Page Down, Home and End move between readings, and Escape lets go. Its aria-valuetext reads every visible series at the reading, so the canvas, the cursor and the tooltip can stay hidden from assistive technology.
  • Every control shows a focus ring for the keyboard. Touch scrubbing keeps vertical scrolling (touch-action: pan-y).
  • Under reduced motion the charts are drawn settled at once, nothing transitions and there is no demo.