Insight card
A written "what changed" summary in which every figure is still a real number.
Three rules keep it honest. Each value is a MetricValue rather than text, so it carries its own delta and its own "explain this number" button and a reader can open the rows behind a sentence. The sources are named and linked to the panels they came from, as anchors to those elements' ids. And it never refreshes itself — a summary that rewrites under the reader is a summary nobody trusts — so it changes only when Regenerate is pressed, and that button holds its own pending state off the promise it is handed. It is a role="region" landmark named by its own heading, aria-busy while a summary is being written, with three skeleton lines standing in for the paragraph. The thumbs pair and the regenerate button each render only when a caller passes the state for them.
Install
npx shadcn@latest add @vibra/insight-cardNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { summarise, type InsightMetric } from "@/lib/insight-adapter"
import { InsightCard, type InsightVerdict } from "@/components/ui/insight-card"
// A fixed instant, so the stamp reads the same in every timezone the docs are
// opened in.
const AS_OF = new Date("2026-09-04T15:40:00.000Z")
const NOW = new Date("2026-09-04T16:12:00.000Z")
const METRICS: InsightMetric[] = [
{
key: "visitors",
label: "Visitors",
metric: { kind: "count", value: 48_120 },
previous: 42_860,
sourceId: "traffic",
provenance: {
source: "daily traffic",
formula: "sum(visitors)",
columns: ["date", "visitors"],
rows: [
{ date: "2026-09-03", visitors: 1_560 },
{ date: "2026-09-02", visitors: 1_724 },
{ date: "2026-09-01", visitors: 1_811 },
],
total: 30,
},
},
{
key: "rate",
label: "Signup rate",
metric: { kind: "percent", value: 0.034, precision: 2 },
previous: 0.0361,
positiveIsGood: true,
sourceId: "traffic",
},
{
key: "session",
label: "Median session",
metric: { kind: "duration", value: 252, unit: "s" },
previous: 251,
sourceId: "sessions",
},
]
/** `heading` lowers the card's title under a page that already has an h2 — the landing renders this inside a tile. */
export default function InsightCardDemo({ heading }: { heading?: "h2" | "h3" | "h4" } = {}) {
const [verdict, setVerdict] = React.useState<InsightVerdict | null>(null)
const [writing, setWriting] = React.useState(false)
const insight = React.useMemo(
() =>
summarise({
metrics: METRICS,
range: "the last 30 days",
event: { label: "A launch post", on: "10 August" },
}),
[]
)
return (
<div className="flex w-full flex-col gap-4">
<InsightCard
title="What changed"
heading={heading}
insight={insight}
asOf={AS_OF}
now={NOW}
loading={writing}
verdict={verdict}
onVerdictChange={setVerdict}
onRegenerate={async () => {
setWriting(true)
await new Promise((resolve) => setTimeout(resolve, 600))
setWriting(false)
}}
/>
<p className="text-sm text-muted-foreground">
Every figure in the sentence is a real <code>MetricValue</code>: the first one carries the
rows it was computed from, so it keeps its own explain button inside the prose.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | Names the region, e.g. "What changed". |
| heading | "h2" | "h3" | "h4" | "h2" | The heading level the title takes. Lower it inside a section that already has an h2, so the page's outline stays in order. |
| insight | Insight | — | The summary from summarise(); leave it out while one is being written. |
| asOf | Date | — | When the numbers behind it were read. |
| now | Date | — | What now is for the stamp — a page fixed to a reference date passes it. |
| loading | boolean | false | Shows skeleton lines and sets aria-busy. |
| verdict | "up" | "down" | null | — | Renders the thumbs pair; pass onVerdictChange with it. |
| onRegenerate | () => void | Promise<void> | — | Renders the regenerate button; a promise holds its pending state. |
Dependencies
Source
"use client"
import * as React from "react"
import { RefreshCwIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Insight } from "@/lib/insight-adapter"
import { formatMetric } from "@/lib/metric"
import { AsyncButton } from "@/components/ui/async-button"
import { Badge } from "@/components/ui/badge"
import { ExplainNumber } from "@/components/ui/explain-number"
import { RelativeTime } from "@/components/ui/relative-time"
import { Skeleton } from "@/components/ui/skeleton"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export type InsightVerdict = "up" | "down"
export type InsightCardProps = Omit<React.ComponentProps<"section">, "title"> & {
/** The heading level the title takes: h2 by default, lower inside a section that already has one. */
heading?: "h2" | "h3" | "h4"
/** Names the region, e.g. "What changed". */
title: React.ReactNode
/** The summary itself; leave it out while one is being written. */
insight?: Insight
/** When the numbers behind it were read. */
asOf?: Date
/** What "now" is for the stamp — a page fixed to a reference date passes it. */
now?: Date
/** Shows skeleton lines and sets aria-busy. */
loading?: boolean
/** Renders the thumbs pair; the value is controlled by the caller. */
verdict?: InsightVerdict | null
onVerdictChange?: (verdict: InsightVerdict | null) => void
/** Renders the regenerate button; return a promise and it holds its pending state. */
onRegenerate?: () => void | Promise<void>
}
/**
* A written summary of what the numbers did, with every figure in it still a
* real number.
*
* Three rules keep it honest. Each value is a `MetricValue` rather than text, so
* it carries its own delta and its own "explain this number" button — a reader
* can open the rows behind a sentence. The sources are named and linked to the
* panels they came from. And it never refreshes itself: a summary that rewrites
* under the reader is a summary nobody trusts, so it changes only when the
* regenerate button is pressed.
*/
function InsightCard({
className,
title,
insight,
asOf,
now,
loading = false,
verdict,
onVerdictChange,
onRegenerate,
heading = "h2",
children,
...props
}: InsightCardProps) {
const headingId = React.useId()
const Heading = heading
const showVerdict = verdict !== undefined && onVerdictChange !== undefined
return (
<section
data-slot="insight-card"
data-state={loading ? "loading" : "ready"}
// A region rather than an article: it is a named landmark on the page,
// which is how a screen reader reaches it without walking the whole grid.
role="region"
aria-labelledby={headingId}
aria-busy={loading || undefined}
className={cn("panel flex flex-col gap-3 p-[var(--density-card,1rem)]", className)}
{...props}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-baseline gap-2">
<Heading id={headingId} data-slot="insight-card-title" className="type-display text-xl">
{title}
</Heading>
{asOf ? (
<span data-slot="insight-card-as-of" className="type-eyebrow">
as of <RelativeTime date={asOf} now={now} />
</span>
) : null}
</div>
{onRegenerate ? (
<AsyncButton
variant="ghost"
size="sm"
data-slot="insight-card-regenerate"
onClick={onRegenerate}
loadingText="Writing…"
>
<RefreshCwIcon data-icon="inline-start" />
Regenerate
</AsyncButton>
) : null}
</div>
{loading || !insight ? (
<div data-slot="insight-card-skeleton" className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[92%]" />
<Skeleton className="h-4 w-[64%]" />
</div>
) : (
// A div rather than a p: the figures inside the sentence are real
// MetricValues, which render a div of their own, and a div inside a p
// is a parse error the browser silently repairs into something else.
<div
data-slot="insight-card-body"
className="text-prose text-foreground"
>
{insight.segments.map((segment, index) =>
segment.kind === "text" ? (
<span key={index}>{segment.text}</span>
) : (
// The figure is part of the sentence, not a stat card dropped into
// a paragraph: the number keeps the numeral register and its own
// explain trigger, and the delta lives in the words around it.
<span
key={index}
data-slot="insight-card-figure"
className="inline-flex items-center gap-1 whitespace-nowrap"
>
<span className="type-numeral text-prose">
{formatMetric(segment.metric.metric)}
</span>
{segment.metric.provenance ? (
<ExplainNumber
provenance={segment.metric.provenance}
value={formatMetric(segment.metric.metric)}
triggerLabel={`Explain ${segment.metric.label}`}
/>
) : null}
</span>
)
)}
</div>
)}
{children}
{(insight?.citations.length ?? 0) > 0 || showVerdict ? (
<div className="flex flex-wrap items-center justify-between gap-3">
{insight && insight.citations.length > 0 ? (
<div data-slot="insight-card-sources" className="flex flex-wrap items-center gap-1.5">
<span className="type-eyebrow">Sources</span>
{insight.citations.map((citation) => (
<Badge key={citation.id} variant="outline" render={<a href={`#${citation.id}`} />}>
{citation.label}
</Badge>
))}
</div>
) : (
<span />
)}
{showVerdict ? (
<ToggleGroup
data-slot="insight-card-verdict"
aria-label="Was this summary useful?"
variant="outline"
size="sm"
spacing={0}
value={verdict ? [verdict] : []}
onValueChange={(next) => onVerdictChange?.((next[0] as InsightVerdict) ?? null)}
>
<ToggleGroupItem value="up" aria-label="Useful">
<ThumbsUpIcon />
</ToggleGroupItem>
<ToggleGroupItem value="down" aria-label="Not useful">
<ThumbsDownIcon />
</ToggleGroupItem>
</ToggleGroup>
) : null}
</div>
) : null}
</section>
)
}
export { InsightCard }