Sparkline Metric Cards
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
A year-over-year trend in inline SVG with a printed axis, no charting dependency and the same data repeated as a screen-reader table.
components/line-chart-panel.tsxnpx hoverlab add line-chart-panel
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.
Compared with the same month last year.
| Month | This year | Last year |
|---|---|---|
| Jan | 3,120 | 2,980 |
| Feb | 3,260 | 3,010 |
| Mar | 3,180 | 3,090 |
| Apr | 3,540 | 3,120 |
| May | 3,720 | 3,050 |
| Jun | 3,610 | 3,210 |
| Jul | 3,880 | 3,180 |
| Aug | 4,020 | 3,340 |
| Sep | 3,960 | 3,290 |
| Oct | 4,180 | 3,410 |
| Nov | 4,320 | 3,380 |
| Dec | 4,410 | 3,520 |
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <LineChartPanel> — a trend over time, in SVG, with no charting library.
*
* <MetricSparklineCards> answers "is this going up". <BarChartPanel>
* compares discrete things. Neither answers the question a dashboard is
* usually opened for: what happened, and when. That needs a dated axis, a
* readable scale and two series you can compare — which is the point at
* which most teams reach for Recharts and add 90 KB to a page that draws
* one line.
*
* The whole chart is a path and a polygon in an inline SVG, sized with a
* viewBox and `preserveAspectRatio="none"` so it stretches to its
* container. No dependency, no client component, no hydration: this is a
* server component that renders identical markup on the server and in a
* static export.
*
* WHY THE Y AXIS DOES NOT START AT ZERO, AND SAYS SO
*
* It starts at a rounded floor below the data. Forcing zero on a series
* that moves between 2,900 and 4,400 flattens the shape into a straight
* line near the top, which is a real way to hide a trend. The axis labels
* are printed, so the choice is visible rather than concealed — a truncated
* axis is honest when it is legible and a lie when it is not.
*
* ACCESSIBILITY: THE TABLE IS THE CHART
*
* An SVG polyline is unreadable to a screen reader no matter how many ARIA
* attributes are hung off it, and `role="img"` with a summary label reduces
* twelve months to one sentence somebody else wrote. So the same data is
* rendered as a real `<table>`, positioned off-screen with `sr-only`. The
* graphic carries `aria-hidden`. Anyone using assistive technology gets the
* numbers rather than a description of a picture of them.
*
* The grid, the labels and the two series are all in one coordinate space
* (0–100 on both axes) so the maths stays readable: every point is a
* percentage of the plot, and the SVG scales it.
*/
import type * as React from 'react'
export interface LineSeries {
name: string
values: number[]
/**
* A Tailwind *text* colour class, not a colour value.
*
* The series is drawn with `currentColor`, so one class colours the line,
* the area fill and the legend swatch together and there is no hex to
* keep in step across three places. It also means the block inherits the
* consuming project's palette — `text-primary` is whatever their primary
* is — instead of shipping a brand colour of its own.
*/
colorClass?: string
/** Fill the area under this series. One series should, both should not. */
area?: boolean
}
export interface LineChartPanelProps {
heading?: string
description?: string
labels?: string[]
series?: LineSeries[]
/** Formats the axis and the accessible table. */
format?: (value: number) => string
className?: string
}
const DEFAULT_LABELS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]
const DEFAULT_SERIES: LineSeries[] = [
{
name: 'This year',
values: [3120, 3260, 3180, 3540, 3720, 3610, 3880, 4020, 3960, 4180, 4320, 4410],
colorClass: 'text-primary',
area: true,
},
{
name: 'Last year',
values: [2980, 3010, 3090, 3120, 3050, 3210, 3180, 3340, 3290, 3410, 3380, 3520],
colorClass: 'text-muted-foreground',
},
]
/** Round a bound outward to something a human would have chosen. */
function niceFloor(value: number, step: number): number {
return Math.floor(value / step) * step
}
function niceCeil(value: number, step: number): number {
return Math.ceil(value / step) * step
}
/**
* A per-instance heading id, derived rather than generated.
*
* `React.useId()` is the textbook fix for a literal id in a reusable
* component and is not available here: this block has no `'use client'`
* and does not warrant one. It renders text, and a band that hydrates on
* every page to render text is a cost with nothing on the other side of
* it. Same call, and the same reasoning, as `footer-mega`.
*
* Hashing the heading *and* the description is deliberate. Either alone works
* until two callers happen to agree, which is exactly what a catalog hub
* rendering every page into one document arranges for; between them they
* are the two props nobody leaves at the default.
*/
function headingId(heading: string, description: string): string {
let hash = 0
const source = `${heading}|${description}`
for (let i = 0; i < source.length; i++) {
hash = (Math.imul(hash, 31) + source.charCodeAt(i)) | 0
}
return `line-chart-heading-${(hash >>> 0).toString(36).slice(0, 4)}`
}
export function LineChartPanel({
heading = 'Monthly active workspaces',
description = 'Compared with the same month last year.',
labels = DEFAULT_LABELS,
series = DEFAULT_SERIES,
format = (value) => value.toLocaleString('en-US'),
className = '',
}: LineChartPanelProps) {
const all = series.flatMap((s) => s.values)
const rawMin = Math.min(...all)
const rawMax = Math.max(...all)
/*
A step sized to the data rather than a constant, so the axis reads in
round numbers whether the series is in hundreds or millions.
*/
const magnitude = Math.pow(10, Math.floor(Math.log10(rawMax - rawMin || 1)))
const min = niceFloor(rawMin - (rawMax - rawMin) * 0.1, magnitude)
const max = niceCeil(rawMax + (rawMax - rawMin) * 0.1, magnitude)
const span = max - min || 1
const x = (index: number, length: number) =>
length <= 1 ? 0 : (index / (length - 1)) * 100
const y = (value: number) => 100 - ((value - min) / span) * 100
const ticks = [max, min + span * 0.5, min]
return (
<section
aria-labelledby={headingId(heading, description)}
className={`mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="relative rounded-2xl border border-border bg-card p-6 sm:p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 id={headingId(heading, description)} className="text-lg font-semibold text-foreground">
{heading}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<ul className="flex flex-wrap items-center gap-4">
{series.map((s) => (
<li key={s.name} className="flex items-center gap-2 text-sm text-muted-foreground">
<span
aria-hidden
className={`h-0.5 w-5 rounded-full bg-current ${s.colorClass ?? 'text-primary'}`}
/>
{s.name}
</li>
))}
</ul>
</div>
<div className="mt-6 flex gap-3">
{/* Axis labels live outside the SVG so they render at real text
size — text inside a non-uniformly scaled viewBox is stretched
with it, which is the classic way this pattern goes wrong. */}
<ul className="flex min-w-14 shrink-0 flex-col justify-between py-0.5 text-end font-mono text-xs text-muted-foreground">
{ticks.map((tick) => (
<li key={tick}>{format(Math.round(tick))}</li>
))}
</ul>
<div className="min-w-0 flex-1">
<svg
aria-hidden
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-56 w-full overflow-visible"
>
{ticks.map((tick) => (
<line
key={tick}
x1="0"
x2="100"
y1={y(tick)}
y2={y(tick)}
className="text-border"
stroke="currentColor"
strokeWidth="0.4"
vectorEffect="non-scaling-stroke"
/>
))}
{series.map((s) => {
const points = s.values
.map((value, i) => `${x(i, s.values.length)},${y(value)}`)
.join(' ')
/* One class on the group; everything inside inherits it
through `currentColor`. */
return (
<g key={s.name} className={s.colorClass ?? 'text-primary'}>
{s.area ? (
<polygon
points={`0,100 ${points} 100,100`}
fill="currentColor"
opacity="0.12"
/>
) : null}
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
)
})}
</svg>
<ul className="mt-2 flex justify-between gap-1 font-mono text-xs text-muted-foreground">
{labels.map((label, i) =>
/* Every other label below `sm`, all of them above it —
twelve months in 320 pixels overlap into a smudge. */
i % 2 === 0 ? (
<li key={label} className="min-w-0 truncate">{label}</li>
) : (
<li key={label} className="hidden min-w-0 truncate sm:block">
{label}
</li>
),
)}
</ul>
</div>
</div>
{/* The chart, for anyone who cannot see the chart. */}
<table className="sr-only">
<caption>{heading}</caption>
<thead>
<tr>
<th scope="col">Month</th>
{series.map((s) => (
<th key={s.name} scope="col">
{s.name}
</th>
))}
</tr>
</thead>
<tbody>
{labels.map((label, i) => (
<tr key={label}>
<th scope="row">{label}</th>
{series.map((s) => (
<td key={s.name}>{s.values[i] === undefined ? '—' : format(s.values[i]!)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/line-chart-panel.tsx and import it where you need the section:
import { LineChartPanel } from '@/components/line-chart-panel'2 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 | 'Monthly active workspaces' |
description | string | 'Compared with the same month last year.' |
labels | string[] | DEFAULT_LABELS |
series | LineSeries[] | DEFAULT_SERIES |
formatFormats the axis and the accessible table. | (value: number) => string | (value) => value.toLocaleString('en-US') |
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.
<!--
Dual-Series Line Chart — 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.
-->
<section aria-labelledby="line-chart-heading-1pxp" class="mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="relative rounded-2xl border border-border bg-card p-6 sm:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 id="line-chart-heading-1pxp" class="text-lg font-semibold text-foreground">Monthly active workspaces</h2>
<p class="mt-1 text-sm text-muted-foreground">Compared with the same month last year.</p>
</div>
<ul class="flex flex-wrap items-center gap-4">
<li class="flex items-center gap-2 text-sm text-muted-foreground"><span aria-hidden="true" class="h-0.5 w-5 rounded-full bg-current text-primary"></span>This year</li>
<li class="flex items-center gap-2 text-sm text-muted-foreground"><span aria-hidden="true" class="h-0.5 w-5 rounded-full bg-current text-muted-foreground"></span>Last year</li>
</ul>
</div>
<div class="mt-6 flex gap-3">
<ul class="flex min-w-14 shrink-0 flex-col justify-between py-0.5 text-end font-mono text-xs text-muted-foreground">
<li>5,000</li>
<li>3,500</li>
<li>2,000</li>
</ul>
<div class="min-w-0 flex-1">
<svg aria-hidden="true" viewBox="0 0 100 100" preserveAspectRatio="none" class="h-56 w-full overflow-visible">
<line x1="0" x2="100" y1="0" y2="0" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
<line x1="0" x2="100" y1="50" y2="50" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
<line x1="0" x2="100" y1="100" y2="100" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
<g class="text-primary">
<polygon points="0,100 0,62.666666666666664 9.090909090909092,58 18.181818181818183,60.66666666666667 27.27272727272727,48.66666666666667 36.36363636363637,42.666666666666664 45.45454545454545,46.333333333333336 54.54545454545454,37.33333333333333 63.63636363636363,32.66666666666667 72.72727272727273,34.66666666666667 81.81818181818183,27.33333333333333 90.9090909090909,22.66666666666667 100,19.66666666666667 100,100" fill="currentColor" opacity="0.12"></polygon>
<polyline points="0,62.666666666666664 9.090909090909092,58 18.181818181818183,60.66666666666667 27.27272727272727,48.66666666666667 36.36363636363637,42.666666666666664 45.45454545454545,46.333333333333336 54.54545454545454,37.33333333333333 63.63636363636363,32.66666666666667 72.72727272727273,34.66666666666667 81.81818181818183,27.33333333333333 90.9090909090909,22.66666666666667 100,19.66666666666667" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></polyline>
</g>
<g class="text-muted-foreground">
<polyline points="0,67.33333333333334 9.090909090909092,66.33333333333334 18.181818181818183,63.666666666666664 27.27272727272727,62.666666666666664 36.36363636363637,65 45.45454545454545,59.66666666666667 54.54545454545454,60.66666666666667 63.63636363636363,55.333333333333336 72.72727272727273,57 81.81818181818183,53 90.9090909090909,54 100,49.33333333333333" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></polyline>
</g>
</svg>
<ul class="mt-2 flex justify-between gap-1 font-mono text-xs text-muted-foreground">
<li class="min-w-0 truncate">Jan</li>
<li class="hidden min-w-0 truncate sm:block">Feb</li>
<li class="min-w-0 truncate">Mar</li>
<li class="hidden min-w-0 truncate sm:block">Apr</li>
<li class="min-w-0 truncate">May</li>
<li class="hidden min-w-0 truncate sm:block">Jun</li>
<li class="min-w-0 truncate">Jul</li>
<li class="hidden min-w-0 truncate sm:block">Aug</li>
<li class="min-w-0 truncate">Sep</li>
<li class="hidden min-w-0 truncate sm:block">Oct</li>
<li class="min-w-0 truncate">Nov</li>
<li class="hidden min-w-0 truncate sm:block">Dec</li>
</ul>
</div>
</div>
<table class="sr-only">
<caption>Monthly active workspaces</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">This year</th>
<th scope="col">Last year</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Jan</th>
<td>3,120</td>
<td>2,980</td>
</tr>
<tr>
<th scope="row">Feb</th>
<td>3,260</td>
<td>3,010</td>
</tr>
<tr>
<th scope="row">Mar</th>
<td>3,180</td>
<td>3,090</td>
</tr>
<tr>
<th scope="row">Apr</th>
<td>3,540</td>
<td>3,120</td>
</tr>
<tr>
<th scope="row">May</th>
<td>3,720</td>
<td>3,050</td>
</tr>
<tr>
<th scope="row">Jun</th>
<td>3,610</td>
<td>3,210</td>
</tr>
<tr>
<th scope="row">Jul</th>
<td>3,880</td>
<td>3,180</td>
</tr>
<tr>
<th scope="row">Aug</th>
<td>4,020</td>
<td>3,340</td>
</tr>
<tr>
<th scope="row">Sep</th>
<td>3,960</td>
<td>3,290</td>
</tr>
<tr>
<th scope="row">Oct</th>
<td>4,180</td>
<td>3,410</td>
</tr>
<tr>
<th scope="row">Nov</th>
<td>4,320</td>
<td>3,380</td>
</tr>
<tr>
<th scope="row">Dec</th>
<td>4,410</td>
<td>3,520</td>
</tr>
</tbody>
</table>
</div>
</section>
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 Charts & MetricsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.
Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.
Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.
A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.
A funnel that leads with the step-to-step rate and the people who fell out of it, not the tidy share-of-top percentages nobody can act on. Biggest drop named in words, and a footnote saying which definition of funnel this is.
Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.
The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.
The moving price rail that sits under an exchange’s navbar — direction carried by an arrow and a sign as well as by colour, and the duplicate half dropped rather than read twice.