Skip to content
AI Interfaces block

Unprompted Insight Cards

Findings the agent surfaced on its own, paged one at a time, each stating its magnitude rather than its direction, dismissible, and carrying a sparkline that is also a sentence for anyone who cannot see it.

284 lineslucide-reactAdded 13 Aug 2026Updated 10 Sept 2026
  • insights
  • sparkline
  • anomaly
  • proactive
  • svg

What's included

  • components/ai-insight-cards.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add ai-insight-cards

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Noticed while working

3 findings

Churn in the under-20-seat band is accelerating

Up 4.7 points over three months, against 0.3 in every other band. Concentrated in accounts that never connected a second data source.

Monthly churn, <20 seats

+4.7 pts

Monthly churn, <20 seats: rose from 1.4 to 6.1 over 7 periods

1 of 3

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/ai-insight-cards.tsx
/**
 * <AiInsightCards> — paged findings an agent surfaced on its own, each with
 * a sparkline, a magnitude and a way to disagree with it.
 *
 * Unprompted insight is the highest-risk surface in an AI product: nobody
 * asked for it, so it has to earn its place on screen every time. The design
 * rules that follow from that:
 *
 *  - Every card states the *size* of the finding, not just its direction.
 *    "Churn up" is noise; "churn up 4.7 points, worth $412k" is a decision.
 *  - Every card can be dismissed with a reason, and the reasons are the
 *    training signal. An insight feed with no "not useful" is a feed nobody
 *    trusts.
 *  - The sparkline is drawn as an inline SVG with `role="img"` and a real
 *    label, so the trend is available as a sentence to anyone who cannot see
 *    the line. A chart with no text alternative is decoration.
 *  - Paging is a `tablist`-free carousel: previous/next buttons with a
 *    `role="status"` position readout, which is simpler and more robust than
 *    tabs for a set that changes daily.
 *
 * The SVG path is computed from the data rather than hand-drawn, so a real
 * series drops in without redrawing anything.
 */

'use client'

import * as React from 'react'
import { ArrowDownRight, ArrowUpRight, ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react'

export interface Insight {
  id: string
  title: string
  body: string
  /** Headline magnitude, e.g. "+4.7 pts". */
  delta: string
  direction: 'up' | 'down'
  /** True when up is bad — churn rising, latency rising. */
  inverted?: boolean
  metric: string
  series: number[]
  action?: string
}

export interface AiInsightCardsProps {
  heading?: string
  insights?: Insight[]
  className?: string
}

const DEFAULT_INSIGHTS: Insight[] = [
  {
    id: 'churn',
    title: 'Churn in the under-20-seat band is accelerating',
    body: 'Up 4.7 points over three months, against 0.3 in every other band. Concentrated in accounts that never connected a second data source.',
    delta: '+4.7 pts',
    direction: 'up',
    inverted: true,
    metric: 'Monthly churn, <20 seats',
    series: [1.4, 1.5, 1.9, 2.6, 3.4, 4.8, 6.1],
    action: 'Show the 38 highest-risk accounts',
  },
  {
    id: 'expansion',
    title: 'Second-integration accounts are expanding faster',
    body: 'Accounts that connect a second source within 30 days expand 2.4× more in year one. 61% of new accounts never do.',
    delta: '2.4×',
    direction: 'up',
    metric: 'Net expansion, 2+ sources',
    series: [100, 104, 112, 121, 134, 149, 168],
    action: 'Draft an activation nudge',
  },
  {
    id: 'latency',
    title: 'Query latency fell after the index change',
    body: 'p95 down 38% since Tuesday’s migration, with no change in error rate. Worth confirming before the next batch.',
    delta: '−38%',
    direction: 'down',
    metric: 'p95 query latency',
    series: [820, 810, 795, 640, 520, 505, 508],
    action: 'Open the latency dashboard',
  },
]

export function AiInsightCards({
  heading = 'Noticed while working',
  insights = DEFAULT_INSIGHTS,
  className = '',
}: AiInsightCardsProps) {
  const [index, setIndex] = React.useState(0)
  const [dismissed, setDismissed] = React.useState<string[]>([])

  const live = insights.filter((i) => !dismissed.includes(i.id))
  // The index can outrun a shrinking list, so it is clamped on read rather
  // than corrected in an effect — one source of truth, no flash of nothing.
  const at = Math.min(index, Math.max(live.length - 1, 0))
  const insight = live[at]

  if (!insight) {
    return (
      <div className={`mx-auto w-full max-w-xl p-6 ${className}`}>
        <p
          role="status"
          className="rounded-2xl border border-dashed border-border/60 px-5 py-10 text-center text-sm text-muted-foreground"
        >
          Nothing left to review. New findings appear as the agent works.
        </p>
      </div>
    )
  }

  // "Up" is good by default, bad when inverted — a rising churn line must
  // not be green.
  const good = insight.inverted ? insight.direction === 'down' : insight.direction === 'up'
  const tone = good ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'
  const Arrow = insight.direction === 'up' ? ArrowUpRight : ArrowDownRight

  return (
    <div className={`mx-auto w-full max-w-xl p-6 ${className}`}>
      <div className="mb-3 flex items-center gap-2">
        <Sparkles aria-hidden className="h-3.5 w-3.5 text-primary" />
        {heading ? (
          <h3
            data-stress-ignore
            className="text-xs font-bold uppercase tracking-wider text-muted-foreground"
          >
            {heading}
          </h3>
        ) : null}
        <span className="ms-auto font-mono text-xs text-muted-foreground">
          {live.length} {live.length === 1 ? 'finding' : 'findings'}
        </span>
      </div>

      <article className="overflow-hidden rounded-2xl border border-border/60 bg-card">
        <div className="px-5 py-4">
          <div className="flex items-start gap-3">
            {insight.title ? (
              <h4 data-stress-ignore className="min-w-0 flex-1 text-sm font-semibold leading-snug">
                {insight.title}
              </h4>
            ) : null}

            <button
              type="button"
              onClick={() => setDismissed((list) => [...list, insight.id])}
              className="shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X aria-hidden className="h-4 w-4" />
              <span className="sr-only">Dismiss: {insight.title}</span>
            </button>
          </div>

          <p className="mt-2 text-xs leading-relaxed text-muted-foreground">{insight.body}</p>

          {/* -- Magnitude and trend ---------------------------------- */}
          <div className="mt-4 flex items-end gap-4 rounded-xl border border-border/60 bg-muted/30 px-4 py-3">
            <div className="min-w-0">
              <p className="truncate text-[11px] uppercase tracking-wider text-muted-foreground">
                {insight.metric}
              </p>
              <p className={`mt-0.5 flex items-center gap-1 text-xl font-bold ${tone}`}>
                <Arrow aria-hidden className="h-4 w-4" />
                {insight.delta}
              </p>
            </div>

            <Sparkline
              values={insight.series}
              label={`${insight.metric}: ${describe(insight.series)}`}
              good={good}
            />
          </div>

          {insight.action ? (
            <button
              type="button"
              className="mt-3 inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {insight.action}
              <ChevronRight aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
            </button>
          ) : null}
        </div>

        {/* -- Paging ------------------------------------------------- */}
        <div className="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-2.5">
          <p role="status" className="text-xs text-muted-foreground">
            {at + 1} of {live.length}
          </p>

          <div className="ms-auto flex gap-1">
            <button
              type="button"
              disabled={at === 0}
              onClick={() => setIndex(at - 1)}
              className="rounded-lg border border-border/60 p-1.5 transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ChevronLeft aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
              <span className="sr-only">Previous finding</span>
            </button>
            <button
              type="button"
              disabled={at >= live.length - 1}
              onClick={() => setIndex(at + 1)}
              className="rounded-lg border border-border/60 p-1.5 transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ChevronRight aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
              <span className="sr-only">Next finding</span>
            </button>
          </div>
        </div>
      </article>
    </div>
  )
}

/** "rose steadily from 1.4 to 6.1" — the chart, as a sentence. */
function describe(values: number[]): string {
  const first = values[0] ?? 0
  const last = values[values.length - 1] ?? 0
  const verb = last > first ? 'rose' : last < first ? 'fell' : 'held flat'
  return `${verb} from ${first} to ${last} over ${values.length} periods`
}

/**
 * An inline sparkline.
 *
 * `role="img"` with a `<title>` and an `aria-label`, because a bare `<svg>`
 * is announced inconsistently across readers and a trend line with no text
 * alternative conveys nothing to anyone who cannot see it.
 */
function Sparkline({
  values,
  label,
  good,
}: {
  values: number[]
  label: string
  good: boolean
}) {
  const width = 96
  const height = 32

  const min = Math.min(...values)
  const max = Math.max(...values)
  const span = max - min || 1

  // Inset by the end-cap radius on both axes, so the final dot sits inside
  // the box instead of half-hanging off its right edge.
  const pad = 3

  const points = values.map((value, i) => {
    const x = pad + (i / (values.length - 1 || 1)) * (width - pad * 2)
    // SVG y grows downward, so the value is inverted into the box.
    const y = height - ((value - min) / span) * (height - 4) - 2
    return `${x.toFixed(1)},${y.toFixed(1)}`
  })

  return (
    <svg
      role="img"
      aria-label={label}
      viewBox={`0 0 ${width} ${height}`}
      className="ms-auto h-8 w-24 shrink-0 overflow-visible"
    >
      <title>{label}</title>
      <polyline
        points={points.join(' ')}
        fill="none"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={good ? 'stroke-emerald-500' : 'stroke-rose-500'}
      />
      <circle
        cx={width - pad}
        cy={points[points.length - 1]?.split(',')[1]}
        r={2.5}
        className={good ? 'fill-emerald-500' : 'fill-rose-500'}
      />
    </svg>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/ai-insight-cards.tsx and import it where you need the section:

import { AiInsightCards } from '@/components/ai-insight-cards'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'Noticed while working'
insightsInsight[]DEFAULT_INSIGHTS
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

ai-insight-cards.html
<!--
  Unprompted Insight Cards — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<div class="mx-auto w-full max-w-xl p-6 ">
  <div class="mb-3 flex items-center gap-2">
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles h-3.5 w-3.5 text-primary" aria-hidden="true">
      <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path>
      <path d="M20 3v4"></path>
      <path d="M22 5h-4"></path>
      <path d="M4 17v2"></path>
      <path d="M5 18H3"></path>
    </svg>
    <h3 data-stress-ignore="true" class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Noticed while working</h3>
    <span class="ms-auto font-mono text-xs text-muted-foreground">3 findings</span>
  </div>
  <article class="overflow-hidden rounded-2xl border border-border/60 bg-card">
    <div class="px-5 py-4">
      <div class="flex items-start gap-3">
        <h4 data-stress-ignore="true" class="min-w-0 flex-1 text-sm font-semibold leading-snug">Churn in the under-20-seat band is accelerating</h4>
        <button type="button" class="shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true">
            <path d="M18 6 6 18"></path>
            <path d="m6 6 12 12"></path>
          </svg>
          <span class="sr-only">Dismiss: Churn in the under-20-seat band is accelerating</span>
        </button>
      </div>
      <p class="mt-2 text-xs leading-relaxed text-muted-foreground">Up 4.7 points over three months, against 0.3 in every other band. Concentrated in accounts that never connected a second data source.</p>
      <div class="mt-4 flex items-end gap-4 rounded-xl border border-border/60 bg-muted/30 px-4 py-3">
        <div class="min-w-0">
          <p class="truncate text-[11px] uppercase tracking-wider text-muted-foreground">Monthly churn, &lt;20 seats</p>
          <p class="mt-0.5 flex items-center gap-1 text-xl font-bold text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-4 w-4" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+4.7 pts</p>
        </div>
        <svg role="img" aria-label="Monthly churn, &amp;lt;20 seats: rose from 1.4 to 6.1 over 7 periods" viewBox="0 0 96 32" class="ms-auto h-8 w-24 shrink-0 overflow-visible">
          <title>Monthly churn, &lt;20 seats: rose from 1.4 to 6.1 over 7 periods</title>
          <polyline points="3.0,30.0 18.0,29.4 33.0,27.0 48.0,22.9 63.0,18.1 78.0,9.7 93.0,2.0" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="stroke-rose-500"></polyline>
          <circle cx="93" cy="2.0" r="2.5" class="fill-rose-500"></circle>
        </svg>
      </div>
      <button type="button" class="mt-3 inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Show the 38 highest-risk accounts<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true"><path d="m9 18 6-6-6-6"></path></svg></button>
    </div>
    <div class="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-2.5">
      <p role="status" class="text-xs text-muted-foreground">1 of 3</p>
      <div class="ms-auto flex gap-1">
        <button type="button" disabled="" class="rounded-lg border border-border/60 p-1.5 transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true">
            <path d="m15 18-6-6 6-6"></path>
          </svg>
          <span class="sr-only">Previous finding</span>
        </button>
        <button type="button" class="rounded-lg border border-border/60 p-1.5 transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true">
            <path d="m9 18 6-6-6-6"></path>
          </svg>
          <span class="sr-only">Next finding</span>
        </button>
      </div>
    </div>
  </article>
</div>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

5 more blocks in Inline AI Actions

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Inline AI Actions

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Inline AI Actions blocks

View category
Open the full page for this block

Selection Action Toolbar

Highlight a passage and hand it to the model. Positioned from the range rect relative to its container, raised by selectionchange so Shift-Arrow works, and every button prevents the mousedown that would collapse the selection.

Inline AI Actions251 lines1 dep
Open the full page for this block

Ghost Text Completion

Copilot-style completion in a plain textarea: a transparent-caret field over a metric-matched mirror, Tab to accept, Escape that actually sticks, and the suggestion announced rather than left as invisible grey text.

Inline AI Actions206 lines1 dep
Open the full page for this block

Agent-Adjusted Inspector

The model edits real properties and every change is attributed and reversible — per-property provenance in words, per-property revert, and sliders whose aria-valuetext carries the unit.

Inline AI Actions381 lines1 dep
Open the full page for this block

Editor Slash Menu

The "/" menu inside a document: focus never leaves the editor, every row names what it acts on, and Escape cleans up the trigger text it inserted instead of leaving you to backspace it.

Inline AI Actions343 lines1 dep
Open the full page for this block

Inline AI Action Menu

The actions offered on a selection, each saying whether it rewrites in place or opens a draft beside it.

Inline AI Actions151 linesNo deps