A small trend line for compact metrics, with optional area fill.
Active projects
128
+18 this month
pnpm dlx shadcn@latest add @latheui/sparklineFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { animate } from "motion/react"
import { cn } from "@/lib/utils"
import { duration, ease, useMotionSafe } from "@/lib/motion"
export type SparklineProps = React.ComponentProps<"svg"> & {
data: number[]
label: string
fillArea?: boolean
/** Glide the line to its new shape when `data` changes, instead of jumping. */
animated?: boolean
/** Fill whatever box the className gives it, at any aspect ratio, keeping the stroke even. */
stretch?: boolean
/** Width and height, as px or any CSS length. Default to a small inline size (112 × 36). */
width?: number | string
height?: number | string
/** Line thickness in px. */
strokeWidth?: number
/** How strong the area fill is at the top, from 0 to 1. */
fillOpacity?: number
/** "smooth" bends the line through the points; "linear" joins them with straight segments. */
curve?: "linear" | "smooth"
/** A dot on the latest value. */
dot?: boolean
/** Dot diameter in px. */
dotSize?: number
/** Line color, as any CSS color. Defaults to the text color, set with a text-* class. */
color?: string
}
type Point = [number, number]
function toPoints(values: number[]): Point[] {
const min = Math.min(...values)
const max = Math.max(...values)
const span = max - min || 1
return values.map((v, i) => [
values.length === 1 ? 60 : 4 + (i / (values.length - 1)) * 112,
32 - ((v - min) / span) * 28,
])
}
const r = (n: number) => Math.round(n * 100) / 100
const straight = (points: Point[]) => points.map((p, i) => (i ? "L" : "M") + r(p[0]) + " " + r(p[1])).join(" ")
/** A Catmull-Rom curve through every point, written as cubic Béziers. */
function smooth(points: Point[]) {
if (points.length < 3) return straight(points)
let d = "M" + r(points[0][0]) + " " + r(points[0][1])
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] ?? points[i]
const [p1, p2] = [points[i], points[i + 1]]
const p3 = points[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${r(c1[0])} ${r(c1[1])} ${r(c2[0])} ${r(c2[1])} ${r(p2[0])} ${r(p2[1])}`
}
return d
}
const toLine = (points: Point[], curve: "linear" | "smooth" = "linear") =>
curve === "smooth" ? smooth(points) : straight(points)
const toArea = (points: Point[], curve: "linear" | "smooth" = "linear") => toLine(points, curve) + " L116 36 L4 36 Z"
/** A zero-length stroke with round caps: it stays a circle even when the drawing is stretched. */
const toDot = (points: Point[]) => {
const last = points[points.length - 1]
return last ? `M${r(last[0])} ${r(last[1])} l0 0` : ""
}
/** Reads a line at `count` evenly spaced points, so lines with different lengths can blend. */
function resample(points: Point[], count: number): Point[] {
if (points.length === count) return points
if (points.length === 0) return Array.from({ length: count }, (_, i) => [4 + (i / Math.max(1, count - 1)) * 112, 32])
if (points.length === 1) return Array.from({ length: count }, () => points[0])
return Array.from({ length: count }, (_, i) => {
const at = count === 1 ? 0 : (i / (count - 1)) * (points.length - 1)
const lo = Math.floor(at)
const hi = Math.min(points.length - 1, lo + 1)
const t = at - lo
return [
points[lo][0] + (points[hi][0] - points[lo][0]) * t,
points[lo][1] + (points[hi][1] - points[lo][1]) * t,
]
})
}
export function Sparkline({
data,
label,
fillArea = false,
animated = false,
stretch = false,
width,
height,
strokeWidth,
fillOpacity = 0.2,
curve = "linear",
dot = false,
dotSize = 5,
color,
className,
style,
...props
}: SparklineProps) {
const values = data.map((v) => (Number.isFinite(v) ? v : 0))
const points = toPoints(values)
const line = toLine(points, curve)
const id = React.useId()
const { reduced } = useMotionSafe()
const lineRef = React.useRef<SVGPathElement>(null)
const areaRef = React.useRef<SVGPathElement>(null)
const dotRef = React.useRef<SVGPathElement>(null)
// The shape on screen, which a change mid-glide carries on from.
const shown = React.useRef<Point[] | null>(null)
const key = line
// Before paint, so the new shape never flashes in ahead of the glide.
React.useLayoutEffect(() => {
const target = toPoints(values)
const from = shown.current
const write = (next: Point[]) => {
shown.current = next
lineRef.current?.setAttribute("d", toLine(next, curve))
areaRef.current?.setAttribute("d", toArea(next, curve))
dotRef.current?.setAttribute("d", toDot(next))
}
if (!animated || reduced || !from || target.length < 2) {
write(target)
return
}
const start = resample(from, target.length)
write(start)
const controls = animate(0, 1, {
duration: duration.slow * 1.6,
ease: ease.out,
onUpdate: (t) =>
write(target.map(([x, y], i) => [start[i][0] + (x - start[i][0]) * t, start[i][1] + (y - start[i][1]) * t])),
})
return () => controls.stop()
// `key` stands for the values, so a new array with the same numbers does not replay the glide.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, animated, reduced, curve])
return (
<svg
role="img"
aria-label={label}
viewBox="0 0 120 36"
preserveAspectRatio={stretch ? "none" : undefined}
className={cn("h-9 w-28 text-chart-1", className)}
style={{ width, height, color, ...style }}
{...props}
>
<title>{label}</title>
<desc>{values.length ? "Values: " + values.join(", ") : "No data"}</desc>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={fillOpacity} />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
{fillArea && points.length > 1 && (
<path ref={areaRef} d={toArea(points, curve)} fill={"url(#" + id + ")"} />
)}
<path
ref={lineRef}
d={line}
fill="none"
stroke="currentColor"
// In px on screen, whatever size the line is drawn at.
strokeWidth={strokeWidth ?? 2}
vectorEffect="non-scaling-stroke"
strokeLinecap="round"
strokeLinejoin="round"
/>
{(dot || points.length === 1) && points.length > 0 && (
<path
ref={dotRef}
d={toDot(points)}
stroke="currentColor"
strokeWidth={dotSize}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| data* (required) | number[] | — | Ordered numeric samples. Empty and constant series are supported. |
| label* (required) | string | — | Accessible trend description. |
| width | number | string | 112 | Width as px or any CSS length, such as "100%". A w-* class works too. |
| height | number | string | 36 | Height as px or any CSS length. An h-* class works too. |
| strokeWidth | number | 2 | Line thickness in screen pixels, at any size. |
| curve | "linear" | "smooth" | "linear" | Straight segments between points, or a smooth curve through them. |
| fillOpacity | number | 0.2 | Strength of the area fill at its top, from 0 to 1. Used with fillArea. |
| dot | boolean | false | Marks the latest value with a dot. |
| dotSize | number | 5 | Dot diameter in px. |
| color | string | — | Line color as any CSS color. Without it the line uses the text color, so a text-* class sets it. |
| fillArea | boolean | false | Add a subtle gradient under the line. |
| animated | boolean | false | Glide the line to its new shape when data changes, instead of jumping. |
| stretch | boolean | false | Fill the box your className gives it at any aspect ratio, such as h-16 w-full, with an even 2px stroke. Without it the line keeps its shape and centers. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { Sparkline } from "@/components/ui/sparkline"
export default function Demo(){return <div className="flex w-full max-w-sm items-center justify-between gap-6 rounded-lg border border-border bg-card p-5"><div><p className="text-xs text-muted-foreground">Active projects</p><p className="mt-1 text-2xl font-medium tabular-nums">128</p><p className="mt-1 text-xs text-muted-foreground">+18 this month</p></div><Sparkline data={[12,18,16,24,20,32,28,38,42]} label="Projects trending upward" fillArea/></div>}