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
1 of 3
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.
components/ai-insight-cards.tsxnpx hoverlab add ai-insight-cards
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
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
1 of 3
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <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>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/ai-insight-cards.tsx and import it where you need the section:
import { AiInsightCards } from '@/components/ai-insight-cards'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.
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.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Noticed while working' |
insights | Insight[] | DEFAULT_INSIGHTS |
className | string | '' |
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.
<!--
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, <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, &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, <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>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 ActionsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
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.
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.
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.
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.
The actions offered on a selection, each saying whether it rewrites in place or opens a draft beside it.