Pulse KPI
A metric card with a sparkline that morphs between ranges, plus pointer and keyboard scrubbing.
npx shadcn@latest add https://sundaeui.dev/r/pulse-kpi.json'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
export interface KpiPoint {
/** ISO date or any label. */
date: string
value: number
}
export interface PulseKpiProps {
label: string
/** One series per range key, e.g. { '7D': [...], '30D': [...] }. */
series: Record<string, KpiPoint[]>
defaultRange?: string
format?: (n: number) => string
formatDate?: (d: string) => string
/** Treat a decrease as good (e.g. churn, latency). */
invert?: boolean
className?: string
}
const N = 64 // resample resolution for smooth morphing
const W = 320
const H = 96
function resample(points: KpiPoint[]) {
const vs = points.map((p) => p.value)
return Array.from({ length: N }, (_, i) => {
const t = (i / (N - 1)) * (vs.length - 1)
const a = Math.floor(t)
const b = Math.min(a + 1, vs.length - 1)
return vs[a] + (vs[b] - vs[a]) * (t - a)
})
}
function toPath(vs: number[], min: number, max: number) {
const span = max - min || 1
const pts = vs.map((v, i) => [(i / (vs.length - 1)) * W, H - 6 - ((v - min) / span) * (H - 16)] as const)
// Catmull-Rom → cubic Bézier for a calm, continuous curve.
let d = `M${pts[0][0]},${pts[0][1]}`
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[i + 2] ?? p2
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]
d += `C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`
}
return d
}
function useTween(target: number[], ms = 520) {
const [v, setV] = useState(target)
const from = useRef(target)
useEffect(() => {
const start = performance.now()
const a = from.current
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
let raf = 0
const tick = (now: number) => {
const t = reduce ? 1 : Math.min(1, (now - start) / ms)
const e = 1 - Math.pow(1 - t, 4)
const next = target.map((x, i) => (a[i] ?? x) + (x - (a[i] ?? x)) * e)
from.current = next
setV(next)
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target.join(',')])
return v
}
const defaultFormat = (n: number) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(n)
const defaultDate = (d: string) => {
const x = new Date(d)
return Number.isNaN(x.getTime()) ? d : x.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function PulseKpi({ label, series, defaultRange, format = defaultFormat, formatDate = defaultDate, invert, className }: PulseKpiProps) {
const ranges = Object.keys(series)
const [range, setRange] = useState(defaultRange ?? ranges[ranges.length - 1])
const [hover, setHover] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const gid = useId().replace(/:/g, '')
const points = series[range]
const sampled = useMemo(() => resample(points), [points])
const min = Math.min(...sampled)
const max = Math.max(...sampled)
const shown = useTween(sampled)
const [tMin, tMax] = [Math.min(...shown), Math.max(...shown)]
const line = toPath(shown, Math.min(min, tMin), Math.max(max, tMax))
const first = points[0].value
const last = points[points.length - 1].value
const delta = first === 0 ? 0 : ((last - first) / first) * 100
const good = invert ? delta <= 0 : delta >= 0
const total = useTween([last], 600)[0]
const hi = hover === null ? null : Math.round((hover / (N - 1)) * (points.length - 1))
const hx = hover === null ? 0 : (hover / (N - 1)) * W
const hy = hover === null ? 0 : H - 6 - ((shown[hover] - Math.min(min, tMin)) / (Math.max(max, tMax) - Math.min(min, tMin) || 1)) * (H - 16)
const pick = (clientX: number) => {
const r = svgRef.current!.getBoundingClientRect()
setHover(Math.max(0, Math.min(N - 1, Math.round(((clientX - r.left) / r.width) * (N - 1)))))
}
return (
<section
className={[
'w-full max-w-sm rounded-2xl bg-white p-5 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_8px_24px_-12px_rgb(0_0_0/0.12)] dark:bg-neutral-900 dark:shadow-[0_0_0_1px_rgb(255_255_255/0.07)]',
className,
]
.filter(Boolean)
.join(' ')}
aria-label={`${label}: ${format(last)}, ${delta >= 0 ? 'up' : 'down'} ${Math.abs(delta).toFixed(1)}% over ${range}`}
>
<header className="flex items-start justify-between gap-4">
<div>
<h3 className="text-[13px] font-medium text-neutral-500 dark:text-neutral-400">{label}</h3>
<p className="mt-1 text-[32px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-neutral-900 dark:text-neutral-50">
{hi === null ? format(total) : format(points[hi].value)}
</p>
<p className="mt-2 flex h-5 items-center gap-2 text-[12.5px]">
{hi === null ? (
<>
<span
className={[
'inline-flex items-center gap-0.5 rounded-full px-1.5 py-px font-medium tabular-nums',
good ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' : 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400',
].join(' ')}
>
<svg viewBox="0 0 10 10" className={['size-2.5 transition-transform', delta < 0 ? 'rotate-180' : ''].join(' ')} aria-hidden>
<path d="M5 2 8.5 7h-7z" fill="currentColor" />
</svg>
{Math.abs(delta).toFixed(1)}%
</span>
<span className="text-neutral-400 dark:text-neutral-500">vs. start of period</span>
</>
) : (
<span className="tabular-nums text-neutral-500 dark:text-neutral-400">{formatDate(points[hi].date)}</span>
)}
</p>
</div>
<div role="radiogroup" aria-label="Range" className="flex rounded-lg bg-neutral-100 p-0.5 dark:bg-neutral-800">
{ranges.map((r) => (
<button
key={r}
type="button"
role="radio"
aria-checked={r === range}
onClick={() => setRange(r)}
className={[
'h-6 rounded-md px-2 text-[11.5px] font-medium tabular-nums transition-colors',
r === range
? 'bg-white text-neutral-900 shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.04)] dark:bg-neutral-700 dark:text-white'
: 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white',
].join(' ')}
>
{r}
</button>
))}
</div>
</header>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="mt-5 block w-full touch-none overflow-visible outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/20 rounded"
role="slider"
tabIndex={0}
aria-label={`Scrub ${label}`}
aria-valuemin={0}
aria-valuemax={points.length - 1}
aria-valuenow={hi ?? points.length - 1}
aria-valuetext={hi === null ? undefined : `${formatDate(points[hi].date)}: ${format(points[hi].value)}`}
onPointerMove={(e) => pick(e.clientX)}
onPointerDown={(e) => pick(e.clientX)}
onPointerLeave={() => setHover(null)}
onBlur={() => setHover(null)}
onKeyDown={(e) => {
const step = Math.max(1, Math.round((N - 1) / (points.length - 1)))
if (e.key === 'ArrowRight') setHover((h) => Math.min(N - 1, (h ?? N - 1) + step))
else if (e.key === 'ArrowLeft') setHover((h) => Math.max(0, (h ?? N - 1) - step))
else if (e.key === 'Escape') setHover(null)
else return
e.preventDefault()
}}
>
<defs>
<linearGradient id={`${gid}-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.14" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<g className={good ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}>
<path d={`${line}L${W},${H}L0,${H}Z`} fill={`url(#${gid}-fill)`} />
<path d={line} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
{hover !== null && (
<>
<line x1={hx} x2={hx} y1={0} y2={H} className="stroke-neutral-300 dark:stroke-neutral-600" strokeDasharray="2 3" vectorEffect="non-scaling-stroke" />
<circle cx={hx} cy={hy} r="4" className="fill-white dark:fill-neutral-900" stroke="currentColor" strokeWidth="2" vectorEffect="non-scaling-stroke" />
</>
)}
</g>
</svg>
<div className="mt-2 flex justify-between text-[11px] tabular-nums text-neutral-400 dark:text-neutral-500" aria-hidden>
<span>{formatDate(points[0].date)}</span>
<span>{formatDate(points[points.length - 1].date)}</span>
</div>
</section>
)
}Overview
A quiet card for one important number. Switching between 7D, 30D and 90D tweens the curve between shapes instead of redrawing it: each series is resampled to a fixed resolution and interpolated, so the line flows into its new form and the headline value counts to its new total.
Scrub the chart with a pointer, or focus it and use the arrow keys. The headline shows the exact value under the cursor, the delta line becomes the date, and a dotted rule marks the position. The line colour follows whether the change is good, and invert flips the meaning for metrics like churn or latency.
It is plain SVG and Tailwind, with no charting library. The curve uses Catmull-Rom smoothing for a calm, continuous line that never overshoots.
When to use
- Dashboards and reports where the shape of the data matters.
When not to use
- Exact lookups: give people a table or export too.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/pulse-kpi.jsonnpx shadcn@latest add https://sundaeui.dev/r/pulse-kpi.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/pulse-kpi.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/pulse-kpi.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy pulse-kpi.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { PulseKpi } from '@/components/sundae/pulse-kpi'
<PulseKpi
label="Net revenue"
defaultRange="30D"
series={{ '7D': last7, '30D': last30, '90D': last90 }} // { date, value }[]
format={(n) => '$' + (n / 1000).toFixed(1) + 'k'}
/>Props
| Prop | Type | Default |
|---|---|---|
label*Metric name. | string | — |
series*One series of { date, value } per range. Keys become the range buttons. | Record<string, KpiPoint[]> | — |
defaultRangeInitially selected range. Defaults to the last key. | string | — |
formatValue formatter. Defaults to compact notation. | (n: number) => string | — |
formatDateDate label formatter. | (d: string) => string | — |
invertTreat a decrease as positive, for example churn or response time. | boolean | — |
Accessibility
- The card has an aria-label that summarises the value, the change and the range.
- The chart is a focusable role="slider". The arrow keys scrub, and aria-valuetext announces the date and value.
- The range selector is a radio group with visible selected state.
- Colour is never the only signal: the delta chip also uses an arrow direction.
Customization
- Colours come from Tailwind’s emerald and rose. Change the two class sets to match your palette.
- Raise N (resample resolution) for very long series. 64 points is enough for card-sized charts.
FAQ
- Is Pulse KPI free?
- Yes. Pulse KPI is free for personal and commercial projects, with full source on this page.
- How do I install Pulse KPI?
- Run npx shadcn@latest add https://sundaeui.dev/r/pulse-kpi.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Pulse KPI support keyboard and screen readers?
- The card has an aria-label that summarises the value, the change and the range. The chart is a focusable role="slider". The arrow keys scrub, and aria-valuetext announces the date and value.
- Does Pulse KPI respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All data →Data Table
Data · Understated- Pro
Kanban Board
Data · Understated Usage Meters
Data · Understated- Pro
Scrub Sparkline
Data · Refined Stacked Bars
Data · Refined- Pro
Heatmap Grid
Data · Understated