Skip to contentVibraUI
Charts

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-card

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
titleReact.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.
insightInsight—The summary from summarise(); leave it out while one is being written.
asOfDate—When the numbers behind it were read.
nowDate—What now is for the stamp — a page fixed to a reference date passes it.
loadingbooleanfalseShows 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

components/ui/insight-card.tsx
"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 }